diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index a88a82470f..c6845be702 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -303,11 +303,15 @@ jobs: if-no-files-found: error retention-days: 3 - dashboard-acceptance: + dashboard-browser: needs: [changes, chat-bundle] if: needs.changes.outputs.core_tests == 'true' runs-on: ubuntu-latest timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] steps: - name: Check out repository uses: actions/checkout@v7 @@ -339,16 +343,47 @@ jobs: - name: Measure Dashboard unit and browser interactions env: + LOOPX_PERSONAL_WORKSPACE_SHARD: "${{ matrix.shard }}/3" LOOPX_DASHBOARD_COVERAGE: "1" LOOPX_PLAYWRIGHT_PACKAGE: ${{ github.workspace }}/apps/presentation/dashboard/node_modules/playwright run: | + node --test examples/personal-workspace-browser/shard.test.mjs npm run test:dashboard:coverage cd apps/presentation/dashboard npx playwright install --with-deps chromium --only-shell node ../../../examples/personal-workspace-browser-smoke.mjs + cp ../../../output/playwright/personal-workspace/acceptance-results.json ../../../coverage/dashboard/acceptance-results.json - name: Upload Dashboard coverage uses: actions/upload-artifact@v7 + with: + name: dashboard-coverage-${{ matrix.shard }} + path: | + coverage/dashboard/lcov.info + coverage/dashboard/browser-coverage.json + coverage/dashboard/acceptance-results.json + if-no-files-found: error + retention-days: 3 + + dashboard-acceptance: + needs: [changes, dashboard-browser] + if: needs.changes.outputs.core_tests == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: "22.22.3" + - run: npm ci --ignore-scripts + - run: node --test scripts/ci/merge-dashboard-coverage.test.mjs + - uses: actions/download-artifact@v7 + with: + pattern: dashboard-coverage-* + path: coverage/dashboard-shards + - name: Require every acceptance shard and merge source coverage + run: node scripts/ci/merge-dashboard-coverage.mjs coverage/dashboard-shards coverage/dashboard + - uses: actions/upload-artifact@v7 with: name: dashboard-coverage path: coverage/dashboard/*.info @@ -427,7 +462,7 @@ jobs: strategy: fail-fast: false matrix: - shard: [1, 2, 3, 4] + shard: [1, 2, 3, 4, 5, 6] steps: - uses: actions/checkout@v7 with: @@ -473,7 +508,7 @@ jobs: # Each runner retains the measured two-worker pool. run: >- python -m pytest -q -n 2 -m "not stage2c_e2e" - --splits 4 --group ${{ matrix.shard }} + --splits 6 --group ${{ matrix.shard }} --splitting-algorithm least_duration --durations=25 --durations-min=1 --junitxml=junit.xml @@ -523,7 +558,7 @@ jobs: - name: Combine complete coverage and enforce the existing floor run: | shards=() - for shard in 1 2 3 4; do + for shard in 1 2 3 4 5 6; do path="coverage-shards/python-coverage-${shard}/.coverage" test -s "$path" shards+=("$path") diff --git a/apps/presentation/dashboard/smoke/chat-turn-acceptance-http-fixture.py b/apps/presentation/dashboard/smoke/chat-turn-acceptance-http-fixture.py index 015d4c04c1..ba77e46088 100644 --- a/apps/presentation/dashboard/smoke/chat-turn-acceptance-http-fixture.py +++ b/apps/presentation/dashboard/smoke/chat-turn-acceptance-http-fixture.py @@ -11,7 +11,7 @@ from loopx.chat_runtime import ChatRuntimeController from loopx.chat_server import ChatHTTPServer, ChatRequestHandler -from loopx.chat_store import ChatSessionStore +from loopx.chat_store import CHAT_TURN_SCHEMA_VERSION, ChatSessionStore class _HealthyAdapter: @@ -39,11 +39,14 @@ def close_session(self) -> None: def _turn_count(store: ChatSessionStore, session_id: str) -> int: - return sum( - 1 - for path in (store.sessions_root / session_id / "turns").glob("*.json") - if not path.name.endswith(".events.json") - ) + count = 0 + for path in (store.sessions_root / session_id / "turns").glob("*.json"): + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise TypeError("persisted turn-directory JSON must be an object") + if payload.get("schema_version") == CHAT_TURN_SCHEMA_VERSION: + count += 1 + return count def main() -> int: @@ -59,6 +62,7 @@ def main() -> int: json.dumps( { "schema_version": "0.1", + "common_runtime_root": str(root), "goals": [ { "id": "goal-one", diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 53ab43680f..a787ea831e 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1,6 +1,6 @@ import { HANDOFF_MODES, EXECUTION_HANDOFF_MODES } from "../../../../../loopx/control_plane/coordination/handoff_mode_vocabulary.js"; import { normalizeGoalDraft } from "../../../../../loopx/control_plane/collaboration/goal_draft.js"; -import { parseTurnStep, type TurnStep } from "./turn-steps"; +import { parseTurnStep, type TurnStep } from "./turn-steps.js"; import { z } from "zod"; import { actionSourceBasisSchema } from "./action-source-basis.js"; @@ -2324,7 +2324,7 @@ export async function disconnectLarkGoalTopic(goalId: string, connectionId: stri ); } -export { CONTEXTS as usageContexts } from "../../../../../loopx/control_plane/runtime/usage_statistics_contract"; +export { CONTEXTS as usageContexts } from "../../../../../loopx/control_plane/runtime/usage_statistics_contract.js"; const usageStatisticsSchema = z.object({ consent: z.enum(["default", "enabled", "disabled"]), sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(), @@ -2432,6 +2432,7 @@ export async function changePrivateAgentTarget(bindingId: string, revision: numb })); } + // Display validation only; finding status semantics remain owned by Explore. const exploreResultPageSchema = z.object({ ok: z.literal(true), goal_id: z.string(), total: z.number().int().nonnegative(), diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 6793dcd875..26960f94e6 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -537,6 +537,7 @@ export const runRecordSchema = z.object({ }); export const runGoalSchema = z.object({ + zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]), acceptance_observation: goalAcceptanceObservationSchema.optional().nullable().catch(null), id: z.string(), activation_state: z.enum(["active", "stopped"]).optional().default("active"), diff --git a/apps/presentation/dashboard/src/data/zcode-goal.ts b/apps/presentation/dashboard/src/data/zcode-goal.ts new file mode 100644 index 0000000000..b457590f49 --- /dev/null +++ b/apps/presentation/dashboard/src/data/zcode-goal.ts @@ -0,0 +1,60 @@ +import { ZCODE_GOAL_ACTIONS, ZCODE_NATIVE_GOAL_STATUSES, ZCODE_IDENTITY_SCOPES, type ZCodeGoalAction, type ZCodeGoalReadback, type ZCodeModelSelection } from "../../../../../loopx/zcode_goal_mode/contract.js"; +import {z} from "zod"; +import {requestJson} from "./chat.js"; + +const zcodeModelSelectionSchema = z.object({ + providerId: z.string().min(1), modelId: z.string().min(1), + options: z.object({reasoningLevel: z.string().min(1)}).optional(), +}); + +// Validate provider transport observations; action permission remains with the typed provider owner. +const zcodeGoalReadbackSchema = z.object({ + ok: z.boolean(), available: z.boolean(), reason: z.string().optional(), + goal_id: z.string(), agent_id: z.string(), goal_creation_operation_id: z.string().nullable(), + goal_ref: z.object({goal_id: z.string(), goal_instance_id: z.string().min(1).optional()}), + identity_scope: z.enum(ZCODE_IDENTITY_SCOPES).optional(), + binding: z.object({mode: z.literal("managed_cli"), connected: z.boolean(), cli_path: z.string(), protocol: z.string()}).nullable(), + native: z.object({ + session_id: z.string(), target_id: z.string().nullable(), + status: z.enum(ZCODE_NATIVE_GOAL_STATUSES).nullable(), running: z.boolean(), + session_status: z.string().optional(), raw_status: z.string().nullable().optional(), usage: z.null().optional(), + objective_sha256: z.string().nullable().optional(), selected_model: zcodeModelSelectionSchema.nullable().optional(), + available_models: z.array(z.object({selection: zcodeModelSelectionSchema, label: z.string(), provider_label: z.string().optional(), + reasoning_levels: z.array(z.string()), default_reasoning_level: z.string().nullable(), disabled: z.boolean()})).optional(), + }).nullable(), + quota: z.object({should_run: z.boolean(), reason: z.string().optional(), checked_at: z.string()}).nullable(), + actions: z.array(z.enum(ZCODE_GOAL_ACTIONS)), +}); + +function zcodeGoalUrl(goalId: string, agentId: string) { + return `/api/goals/${encodeURIComponent(goalId)}/agents/${encodeURIComponent(agentId)}/zcode-goal`; +} + +function zcodeGoalReadback(payload: unknown, goalId: string, agentId: string): ZCodeGoalReadback { + const result = zcodeGoalReadbackSchema.parse(payload); + if (result.goal_id !== goalId || result.goal_ref.goal_id !== goalId || result.agent_id !== agentId) { + throw new Error("ZCode Goal source changed; read the current Goal and Agent again."); + } + return result; +} + +export async function fetchZCodeGoal(goalId: string, agentId: string, signal?: AbortSignal) { + return zcodeGoalReadback(await requestJson(zcodeGoalUrl(goalId, agentId), {signal}), goalId, agentId); +} + +export async function updateZCodeGoal(goalId: string, agentId: string, action: Exclude, + expectedBinding: Pick, + options?: {cliPath?: string; modelSelection?: ZCodeModelSelection}, signal?: AbortSignal) { + const result = zcodeGoalReadback(await requestJson(zcodeGoalUrl(goalId, agentId), { + method: "POST", signal, + body: JSON.stringify({action, expected_binding: expectedBinding, + ...(action === "bind" && options?.cliPath?.trim() ? {cli_path: options.cliPath.trim()} : {}), + ...(action === "select_model" && options?.modelSelection ? {model_selection: options.modelSelection} : {}), + }), + }), goalId, agentId); + if (result.goal_ref.goal_instance_id !== expectedBinding.goal_ref.goal_instance_id + || result.goal_creation_operation_id !== expectedBinding.goal_creation_operation_id) { + throw new Error("ZCode Goal source changed; read the current Goal and Agent again."); + } + return result; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index 3d3c498293..6c7b0aa094 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1,3 +1,4 @@ +import { ZCodeGoalControl } from "./zcode-goal-control"; import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; import { AttentionActions } from "./attention-actions"; import { AttentionDetailCard } from "./attention-detail-card"; @@ -803,6 +804,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention )} + {selection.item.zcodeGoalEligibleAgentIds?.length ? : null} {!readOnly ?
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index fe271e78e7..4842c7e75e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -12,6 +12,62 @@ export const workspaceLocaleStorageKey = "loopx-pw-locale"; export type WorkspaceLocale = "en" | "zh-CN"; const en = { + "zcode.executionFailed": "ZCode native execution failed. Check the host session and model configuration, then read status again.", + "zcode.currentModel": "Current model", + "zcode.modelNotSelected": "Not selected", + "zcode.model": "Configured model", + "zcode.chooseModel": "Choose an existing model", + "zcode.modelDisabled": "Unavailable", + "zcode.reasoning": "Reasoning level", + "zcode.chooseReasoning": "Choose a reasoning level", + "zcode.useModel": "Use this model", + "zcode.modelsUnavailable": "ZCode did not provide an available model. Configure a model in ZCode, then bind the CLI again.", + "zcode.identity": "Goal identity", + "zcode.identity.exact_goal_instance": "Exact Goal instance", + "zcode.identity.legacy_goal_alias": "Legacy Goal alias", + "zcode.identityBoundary": "Identity boundary", + "zcode.legacyBoundary": "This binding follows the legacy alias and creation witness. Recreating the same alias is not an exact lifetime guarantee.", + "zcode.title": "ZCode native Goal", + "zcode.scope": "Bind a managed ZCode CLI session to a registered Agent in this Goal. Binding does not start model work; Start executes the Goal.", + "zcode.remote": "Switch to the local workspace to bind and control a CLI session.", + "zcode.noAgents": "No registered Agent is available for this Goal. Register an Agent before binding a session.", + "zcode.agent": "Registered Agent", + "zcode.cli": "CLI path", + "zcode.pathPlaceholder": "Use ZCode from PATH", + "zcode.pathHelp": "Leave blank to use PATH. A new path takes effect only after binding.", + "zcode.bind": "Bind CLI", + "zcode.refresh": "Read status", + "zcode.pending": "Waiting for host readback…", + "zcode.failed": "Operation could not be confirmed.", + "zcode.readAgain": "Read status before continuing.", + "zcode.unavailable": "Native control unavailable", + "zcode.binding": "CLI binding", + "zcode.connected": "Connected", + "zcode.disconnected": "Disconnected", + "zcode.unbound": "Not bound", + "zcode.native": "Native Goal state", + "zcode.unknown": "Unknown", + "zcode.state.active": "Active", + "zcode.state.paused": "Paused", + "zcode.state.completed": "Completed", + "zcode.state.budget_limited": "Native budget limit reached", + "zcode.execution": "Observed execution", + "zcode.running": "Running", + "zcode.idle": "Not running", + "zcode.quota": "LoopX quota admission", + "zcode.quotaAllowed": "Continuation permitted", + "zcode.quotaHeld": "Continuation held", + "zcode.quotaBoundary": "LoopX checks quota before start and continuation, and monitors revocation during execution. ZCode manages internal model calls.", + "zcode.usageUnknown": "Native token usage and a model-call token limit are not provided.", + "zcode.checked": "Host state read at {time}", + "zcode.diagnostics": "Connection details", + "zcode.sessionState": "Session state", + "zcode.rawState": "Raw Goal state", + "zcode.pathChanged": "Bind the edited CLI path before starting or resuming.", + "zcode.start": "Start", + "zcode.pause": "Pause", + "zcode.resume": "Resume", + "zcode.stop": "Stop", "storage.title": "Goal data storage", "storage.boundary": "Confirmation changes only this existing Goal. New-Goal defaults and task ownership are separate. Preserve journal, metadata and receipts; no execution authority is granted.", "storage.current": "Current source (fresh readback)", @@ -1367,6 +1423,62 @@ const en = { export type WorkspaceMessageKey = keyof typeof en; const zhCN: Record = { + "zcode.executionFailed": "ZCode 原生执行失败。请检查宿主会话与模型配置,再回读状态。", + "zcode.currentModel": "当前模型", + "zcode.modelNotSelected": "未选择", + "zcode.model": "已配置模型", + "zcode.chooseModel": "选择已有模型", + "zcode.modelDisabled": "不可用", + "zcode.reasoning": "推理级别", + "zcode.chooseReasoning": "选择推理级别", + "zcode.useModel": "使用此模型", + "zcode.modelsUnavailable": "ZCode 未提供可用模型。请先在 ZCode 配置模型,再绑定 CLI。", + "zcode.identity": "Goal 身份", + "zcode.identity.exact_goal_instance": "精确 Goal 实例", + "zcode.identity.legacy_goal_alias": "旧版 Goal 别名", + "zcode.identityBoundary": "身份边界", + "zcode.legacyBoundary": "此绑定沿用旧版别名与创建凭据;同名重建不具备精确生命周期保证。", + "zcode.title": "ZCode 原生 Goal", + "zcode.scope": "为当前 Goal 的已注册 Agent 绑定托管 ZCode CLI 会话。绑定不启动模型;点击启动会执行 Goal。", + "zcode.remote": "请切回本地工作区后绑定和控制 CLI 会话。", + "zcode.noAgents": "此 Goal 没有可用的已注册 Agent。请先注册 Agent,再绑定会话。", + "zcode.agent": "已注册 Agent", + "zcode.cli": "CLI 路径", + "zcode.pathPlaceholder": "使用 PATH 中的 ZCode", + "zcode.pathHelp": "留空使用 PATH。新路径在绑定后生效。", + "zcode.bind": "绑定 CLI", + "zcode.refresh": "回读状态", + "zcode.pending": "等待宿主回读…", + "zcode.failed": "无法确认操作结果。", + "zcode.readAgain": "请先回读状态,再继续操作。", + "zcode.unavailable": "原生控制不可用", + "zcode.binding": "CLI 绑定", + "zcode.connected": "已连接", + "zcode.disconnected": "未连接", + "zcode.unbound": "未绑定", + "zcode.native": "原生 Goal 状态", + "zcode.unknown": "未知", + "zcode.state.active": "活跃", + "zcode.state.paused": "已暂停", + "zcode.state.completed": "已完成", + "zcode.state.budget_limited": "原生预算已达限额", + "zcode.execution": "观察到的执行", + "zcode.running": "正在运行", + "zcode.idle": "未运行", + "zcode.quota": "LoopX 配额准入", + "zcode.quotaAllowed": "允许续跑", + "zcode.quotaHeld": "续跑受限", + "zcode.quotaBoundary": "LoopX 检查启动与续跑配额,并在运行中监测撤销。ZCode 管理内部模型调用。", + "zcode.usageUnknown": "原生 Token 用量与逐次模型调用的 Token 限额未提供。", + "zcode.checked": "宿主状态回读于 {time}", + "zcode.diagnostics": "连接详情", + "zcode.sessionState": "会话状态", + "zcode.rawState": "原始 Goal 状态", + "zcode.pathChanged": "启动或恢复前,请先绑定修改后的 CLI 路径。", + "zcode.start": "启动", + "zcode.pause": "暂停", + "zcode.resume": "恢复", + "zcode.stop": "停止", "storage.title": "Goal 数据存储", "storage.boundary": "明确确认后仅切换此既有 Goal。新 Goal 默认值与任务所有权是各自的设置。保留日志、metadata 和回执,不授予执行权限。", "storage.current": "当前来源(实时读回)", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index fd6293bf5c..ebb22f3b6a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -160,6 +160,10 @@ export type WorkspaceGoal = { lastActivityAt?: string | null; state?: string | null; }>; + /** Canonical registered identities; discovered task claimants do not grant binding authority. */ + registeredAgentIds?: string[]; + /** Host-compatible registered IDs from the canonical backend; absence grants no controls. */ + zcodeGoalEligibleAgentIds?: string[]; agentLaneCount?: number; agentLabel?: string; agentSentence: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.css b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.css new file mode 100644 index 0000000000..bbe539ddd4 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.css @@ -0,0 +1,14 @@ +.personal-zcode-goal > summary, .personal-zcode-diagnostics > summary { cursor: pointer; min-height: 44px; display: list-item; align-content: center; font-size: 13px; font-weight: 500; } +.personal-zcode-goal label { display: grid; gap: 6px; margin-block: 12px; font-size: 12px; color: var(--pw-muted, #666); } +.personal-zcode-goal input, .personal-zcode-goal select { box-sizing: border-box; width: 100%; min-width: 0; min-height: 44px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; padding: 8px 10px; background: var(--pw-surface, #fff); color: var(--pw-text, #171717); font: inherit; } +.personal-zcode-goal .personal-zcode-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-block: 12px; } +.personal-zcode-goal .personal-zcode-actions > button { min-height: 44px; margin-top: 0; } +.personal-detail-card.personal-zcode-goal dl > div { grid-template-columns: minmax(110px, .8fr) minmax(0, 1fr); } +.personal-zcode-goal .personal-zcode-help { font-size: 12px; color: var(--pw-muted, #666); } +.personal-zcode-goal .personal-zcode-error { color: var(--pw-red, #b42318); } +.personal-zcode-goal dd { overflow-wrap: anywhere; } +.personal-zcode-goal :is(summary, input, select, button):focus-visible { outline: 2px solid var(--pw-text, #171717); outline-offset: 3px; } +.personal-zcode-diagnostics { border-top: 1px solid var(--pw-line, #ebebeb); margin-top: 12px; } + +.personal-zcode-model > summary { min-height: 44px; align-content: center; cursor: pointer; font-size: 12px; } +.personal-zcode-model > button { min-height: 44px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx new file mode 100644 index 0000000000..eb594a0e78 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/zcode-goal-control.tsx @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchZCodeGoal, updateZCodeGoal } from "../../data/zcode-goal"; +import type { ZCodeGoalAction, ZCodeGoalReadback, ZCodeModelSelection } from "../../../../../../loopx/zcode_goal_mode/contract.js"; +import { useWorkspaceI18n, type WorkspaceTranslate } from "./i18n"; +import type { WorkspaceGoal } from "./personal-workspace-model"; +import "./zcode-goal-control.css"; + +function zcodeReason(reason: string | undefined, t: WorkspaceTranslate) { + return reason === "zcode_native_execution_failed" ? t("zcode.executionFailed") : reason; +} + +export function ZCodeGoalControl({goal, readOnly}: {goal: WorkspaceGoal; readOnly: boolean}) { + const {t} = useWorkspaceI18n(); + const [opened, setOpened] = useState(false); + const eligibleAgentIds = goal.zcodeGoalEligibleAgentIds ?? []; + const [agentId, setAgentId] = useState(eligibleAgentIds[0] ?? ""); + const selectedAgent = eligibleAgentIds.includes(agentId) ? agentId : eligibleAgentIds[0] ?? ""; + if (eligibleAgentIds.length === 0) return null; + return
setOpened(event.currentTarget.open)}> + {t("zcode.title")} + {opened ? <> +

{t("zcode.scope")}

+ {readOnly ?

{t("zcode.remote")}

: <> + + + } + : null} +
; +} + +function ZCodeAgentControl({goalId, agentId}: {goalId: string; agentId: string}) { + const {t} = useWorkspaceI18n(); + const [snapshot, setSnapshot] = useState(null); + const [cliPath, setCliPath] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [checkedAt, setCheckedAt] = useState(""); + const [modelChoice, setModelChoice] = useState(""); + const [reasoningLevel, setReasoningLevel] = useState(""); + const mounted = useRef(false); + const request = useRef(null); + const pathEdited = useRef(false); + const pending = useRef(false); + + const perform = useCallback(async (action: ZCodeGoalAction = "status", options?: {cliPath?: string; modelSelection?: ZCodeModelSelection}) => { + if (pending.current) return; + if (action !== "status" && !snapshot) {setError(t("zcode.readAgain")); return;} + pending.current = true; + const controller = new AbortController(); + request.current = controller; + setBusy(true); + setError(""); + try { + const result = action === "status" + ? await fetchZCodeGoal(goalId, agentId, controller.signal) + : await updateZCodeGoal(goalId, agentId, action, + {goal_ref: snapshot!.goal_ref, goal_creation_operation_id: snapshot!.goal_creation_operation_id}, options, controller.signal); + if (!mounted.current || controller.signal.aborted) return; + setSnapshot(result); + if (action === "bind" && result.ok) {setModelChoice(""); setReasoningLevel("");} + setCheckedAt(new Date().toLocaleTimeString()); + if ((action === "bind" && result.ok) || !pathEdited.current) { + setCliPath(result.binding?.cli_path ?? ""); + pathEdited.current = false; + } + if (!result.ok) setError(zcodeReason(result.reason, t) || t("zcode.failed")); + } catch (failure) { + if (!mounted.current || controller.signal.aborted) return; + // A failed operation can have an unknown outcome; only a fresh readback enables another action. + setSnapshot(null); + setCheckedAt(""); + setError(`${failure instanceof Error ? failure.message : t("zcode.failed")} ${t("zcode.readAgain")}`); + } finally { + pending.current = false; + if (mounted.current && !controller.signal.aborted) setBusy(false); + } + }, [goalId, agentId, snapshot, t]); + + useEffect(() => { + mounted.current = true; + void perform(); + return () => { mounted.current = false; request.current?.abort(); }; + // Goal/Agent identity is also the parent key; locale changes do not restart an in-flight request. + }, [goalId, agentId]); + + const allowed = (action: ZCodeGoalAction) => !busy && Boolean(snapshot?.actions.includes(action)); + const pathChanged = pathEdited.current && cliPath.trim() !== (snapshot?.binding?.cli_path ?? ""); + const native = snapshot?.native; + const reason = zcodeReason(snapshot?.reason, t); + const models = native?.available_models ?? []; + const chosenModel = models.find(model => JSON.stringify(model.selection) === modelChoice); + const selectedModel = native?.selected_model; + const currentModel = models.find(model => model.selection.providerId === selectedModel?.providerId && model.selection.modelId === selectedModel.modelId); + const selectModel = () => { + if (!chosenModel || chosenModel.disabled || !allowed("select_model")) return; + const selection: ZCodeModelSelection = { + ...chosenModel.selection, + ...(chosenModel.reasoning_levels.length ? {options: {reasoningLevel}} : {}), + }; + void perform("select_model", {modelSelection: selection}); + }; + const controls =
+ {(["start", "pause", "resume", "stop"] as const).map(action => )} +
; + return
+ +

{t("zcode.pathHelp")}

+
+ + +
+ {busy ?

{t("zcode.pending")}

: null} + {error ?

{error}

: null} + {pathChanged ?

{t("zcode.pathChanged")}

: null} + {snapshot ?
+ {!snapshot.available ?

{t("zcode.unavailable")}{reason && !error ? ` · ${reason}` : ""}

: reason && !error ?

{reason}

: null} +
+
{t("zcode.binding")}
{snapshot.binding ? t(snapshot.binding.connected ? "zcode.connected" : "zcode.disconnected") : t("zcode.unbound")}
+
{t("zcode.native")}
{native?.status ? t(`zcode.state.${native.status}`) : t("zcode.unknown")}
+
{t("zcode.execution")}
{native ? t(native.running ? "zcode.running" : "zcode.idle") : t("zcode.unknown")}
+ {native ?
{t("zcode.currentModel")}
{selectedModel ? currentModel?.label || selectedModel.modelId : selectedModel === null ? t("zcode.modelNotSelected") : t("zcode.unknown")}
: null} +
{t("zcode.quota")}
{snapshot.quota ? t(snapshot.quota.should_run ? "zcode.quotaAllowed" : "zcode.quotaHeld") : t("zcode.unknown")}
+
+ {snapshot.binding ?
+ {t("zcode.model")} + {models.length ? <> + + {chosenModel?.reasoning_levels.length ? : null} + + :

{t("zcode.modelsUnavailable")}

} +
: null} + {snapshot.quota && !snapshot.quota.should_run && snapshot.quota.reason ?

{snapshot.quota.reason}

: null} + {controls} +

{t("zcode.quotaBoundary")}

+

{t("zcode.usageUnknown")}

+ {checkedAt ?

{t("zcode.checked", {time: checkedAt})}

: null} +
{t("zcode.diagnostics")} +
+
{t("zcode.cli")}
{snapshot.binding?.cli_path || "PATH"}
+ {snapshot.identity_scope ?
{t("zcode.identity")}
{t(`zcode.identity.${snapshot.identity_scope}`)}
: null} + {snapshot.identity_scope === "legacy_goal_alias" ?
{t("zcode.identityBoundary")}
{t("zcode.legacyBoundary")}
: null} +
Protocol
{snapshot.binding?.protocol || t("zcode.unknown")}
+
Session
{native?.session_id || t("zcode.unknown")}
+ {native?.session_status ?
{t("zcode.sessionState")}
{native.session_status}
: null} + {native?.raw_status ?
{t("zcode.rawState")}
{native.raw_status}
: null} +
Target
{native?.target_id || t("zcode.unknown")}
+
+
+
: null} + {!snapshot ? controls : null} +
; +} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index b517d7b333..2ea48a0055 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1084,6 +1084,8 @@ function buildPersonalHomeModel( agentId: agentRow?.agentId ?? registeredAgentIds[0] ?? "codex", agentLaneCount: goalAgentLanes.length, agentLanes: goalAgentLanes, + registeredAgentIds, + zcodeGoalEligibleAgentIds: goal.zcode_goal_eligible_agent_ids, agentLabel: agentRow?.agentId, agentSentence: personalAgentSentence(payload, row, state, t), agentTodos: [...goalAgentTodos, ...agentTodoFacts.recentCompleted], diff --git a/demo/workspace/README.md b/demo/workspace/README.md index 43ea70eab0..f2c153383a 100644 --- a/demo/workspace/README.md +++ b/demo/workspace/README.md @@ -25,7 +25,7 @@ Only a new empty directory or this demo's matching manifest is accepted. Prepare | Home Energy Buying Guide | 12 source cards, three household profiles, 27 tariff/efficiency combinations, conflicting assumptions, five-section editorial plan | Cost assumptions; publication approval | | Riverside Neighborhood Website | Six pages, 18-route inventory, 24 accessibility criteria, navigation/form review findings, content permissions, rollback and handoff | Content freeze; deployment approval | -Each project has seven replayed completion checkpoints, five ready tasks, four blocked tasks and two deferred follow-ups. Dependency notes retain predecessor IDs; deferred tasks use real `todo_done` resume conditions. Two watch-only monitors have cadence and next-due metadata. No scheduler or live Agent is started by the demo. +Each project has seven replayed completion checkpoints, five ready tasks, four blocked tasks and two deferred follow-ups. Checkpoints acquire a real hard lease and complete through the canonical Todo owner, which releases that lease atomically. Dependency notes retain predecessor IDs; deferred tasks use real `todo_done` resume conditions. Two watch-only monitors have cadence and next-due metadata. No scheduler or live Agent is started by the demo. Switch Board/List, filter by Agent, expand completed history, inspect the owner decisions and scheduled watches. `BRIEF.md`, `working-table.csv`, `calculations.json` and `DELIVERY-PLAN.md` preserve the planning inputs and dependencies. The energy sensitivity table and event contingency are calculated when preparing the workspace. @@ -44,7 +44,7 @@ These are authored scenario replays using real LoopX APIs and state transitions, Each newly prepared story Goal selects the existing `soft_claim` handoff mode before tasks are seeded. The stories have named claim owners but no live managed worker or execution identity, so the replay does not acquire task leases. This setting applies only to the isolated demo Goals and does not change LoopX defaults. -The demo does not import personal registries, session history or credentials, and does not sync into the global registry. Prepare, advance and serve run in a separate HOME/CODEX_HOME with a minimal environment; even preparation never discovers personal default registries. The loopback server uses unavailable Agent/Lark binaries. Chat and Lark connection errors are intentional isolation and do not qualify live IM behavior. Stop with Ctrl-C. +The demo does not import personal registries, session history or credentials, and does not sync into the global registry. Prepare, advance and serve run in a separate HOME (USERPROFILE on Windows) and CODEX_HOME with a minimal environment; even preparation never discovers personal default registries. The loopback server uses unavailable Agent/Lark binaries. Chat and Lark connection errors are intentional isolation and do not qualify live IM behavior. Stop with Ctrl-C. This remains a source-checkout demo under `demo/`, outside the installed wheel and capability catalog. Screenshots and recordings belong in ignored `output/playwright/`. Keep real operating statistics separately timestamped with their counting scope. diff --git a/demo/workspace/__main__.py b/demo/workspace/__main__.py index 1c377aca2e..c3badaaa8f 100644 --- a/demo/workspace/__main__.py +++ b/demo/workspace/__main__.py @@ -17,7 +17,7 @@ from loopx.configure_goal import configure_goal from loopx.control_plane.todos.handoff_mode import set_goal_handoff_mode from loopx.state_refresh import refresh_state_run -from loopx.todos import add_goal_todo, complete_goal_todo, update_goal_todo +from loopx.todos import add_goal_todo, complete_goal_todo HERE = Path(__file__).resolve().parent REPO = HERE.parents[1] @@ -72,16 +72,37 @@ def write_story_artifacts(project: Path, story: dict[str, Any], notice: str) -> def seed_delivery_tasks(story: dict[str, Any], registry: Path, runtime: Path) -> list[dict[str, Any]]: """Record each story dependency as the typed relation its state allows.""" - todos: list[dict[str, Any]] = [] ids: dict[str, str] = {} by_key = {task["key"]: task for task in story["tasks"]} dependents: dict[str, list[str]] = {} for task in story["tasks"]: if task.get("after"): dependents.setdefault(task["after"], []).append(task["key"]) - for task in story["tasks"]: + # Author dependency metadata during creation. A blocked Todo cannot acquire + # an execution lease merely to add its relationship after the fact. + pending = dict(by_key) + while pending: + ready = None + for task in pending.values(): + linked = [key for key in dependents.get(task["key"], []) + if by_key[key]["status"] != "deferred"] + if len(linked) > 1 and task["status"] != "done": + raise ValueError(f"{task['key']} can unblock only one task") + required = ([task["after"]] if task["status"] == "deferred" + else linked if task["status"] != "done" else []) + if all(key in ids for key in required): + ready = task + break + if ready is None: + raise ValueError("Story task relationships contain a cycle or missing predecessor") + task = ready owner, status, title = task["agent"], task["status"], task["title"] after = task.get("after") + linked = [key for key in dependents.get(task["key"], []) + if by_key[key]["status"] != "deferred"] + required_scope = ([{"schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "action", "scope_key": f"{story['id']}:{after[5:]}"}] + if after and after.startswith("gate:") else None) result = checked( add_goal_todo( registry_path=registry, @@ -95,11 +116,14 @@ def seed_delivery_tasks(story: dict[str, Any], registry: Path, runtime: Path) -> claimed_by=owner, status="open" if status == "done" else status, resume_when="todo_done:" + ids[after] if status == "deferred" else None, + unblocks_todo_id=ids[linked[0]] if linked and status != "done" else None, + required_decision_scopes=required_scope, note=f"Phase: {task['phase']}. See BRIEF.md and calculations.json.", ) ) ids[task["key"]] = result["todo_id"] - todos.append({**task, "todo_id": result["todo_id"]}) + pending.pop(task["key"]) + todos = [{**task, "todo_id": ids[task["key"]]} for task in story["tasks"]] for task in story["tasks"]: # Deferred work already waits on its condition; the rest needs a link. linked = [ @@ -119,19 +143,6 @@ def seed_delivery_tasks(story: dict[str, Any], registry: Path, runtime: Path) -> no_followup=not linked, ) ) - elif linked: - if len(linked) > 1: - raise ValueError(f"{task['key']} can unblock only one task") - checked( - update_goal_todo( - registry_path=registry, - runtime_root_arg=str(runtime), - goal_id=story["id"], - todo_id=ids[task["key"]], - agent_id=task["agent"], - unblocks_todo_id=ids[linked[0]], - ) - ) return todos @@ -189,6 +200,11 @@ def seed_story(root: Path, story: dict[str, Any], notice: str) -> dict[str, Any] gated = {t["after"]: t["todo_id"] for t in todos if (t.get("after") or "").startswith("gate:")} gates = {} for decision in story["gates"]: + targets = [todo for todo in todos if todo.get("after") == "gate:" + decision["key"]] + if len(targets) != 1: + raise ValueError("Each demo decision must have one direct dependent") + scope = {"schema_version": "decision_scope_v0", "kind": "direction", + "granularity": "action", "scope_key": f"{story['id']}:{decision['key']}"} gate = checked( add_goal_todo( registry_path=registry, @@ -199,6 +215,7 @@ def seed_story(root: Path, story: dict[str, Any], notice: str) -> dict[str, Any] action_kind="approve", blocks_agent=decision["agent"], unblocks_todo_id=gated["gate:" + decision["key"]], + decision_scope=scope, text="[P0] " + decision["title"], ) ) @@ -206,27 +223,6 @@ def seed_story(root: Path, story: dict[str, Any], notice: str) -> dict[str, Any] "todo_id": gate["todo_id"], "agent": decision["agent"], } - # The App and CLI replay share the canonical User completion relationship. - # Do not teach the demo a second writer that opens the dependent afterward. - for key, gate in gates.items(): - targets = [todo for todo in todos if todo.get("after") == "gate:" + key] - if len(targets) != 1: - raise ValueError("Each demo decision must have one direct dependent") - target = targets[0] - scope = {"schema_version": "decision_scope_v0", "kind": "direction", - "granularity": "action", "scope_key": f"{story['id']}:{key}"} - checked(update_goal_todo( - registry_path=registry, runtime_root_arg=str(runtime), - goal_id=story["id"], todo_id=gate["todo_id"], - unblocks_todo_id=target["todo_id"], decision_scope=scope, - agent_id=gate["agent"], reason="Bind the demo decision to its dependent.", - )) - checked(update_goal_todo( - registry_path=registry, runtime_root_arg=str(runtime), - goal_id=story["id"], todo_id=target["todo_id"], - required_decision_scopes=[scope], agent_id=target["agent"], - reason="Wait for the demo owner decision.", - )) monitors = [] for owner, title, cadence, target in story["monitors"]: monitor = checked( @@ -352,9 +348,10 @@ def run_isolated(args: argparse.Namespace, root: Path) -> None: env = { k: v for k, v in os.environ.items() - if k in {"PATH", "LANG", "LC_ALL", "TMPDIR", "SYSTEMROOT"} + if k in {"PATH", "LANG", "LC_ALL", "TMPDIR", "TEMP", "TMP", "SYSTEMROOT"} } - env.update(HOME=str(home), CODEX_HOME=str(home / ".codex"), PYTHONPATH=str(REPO)) + env.update(HOME=str(home), USERPROFILE=str(home), + CODEX_HOME=str(home / ".codex"), PYTHONPATH=str(REPO)) # Paths and ports are data, never arguments to an interpreter invocation. result = subprocess.run( [sys.executable, "-m", "demo.workspace", args.command, "--_isolated"], diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 59effe5c8b..05292bec72 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -110,6 +110,15 @@ These directly determine whether a long-running team is usable. A directory or R The bounded S6/S8 source-reading slice provides a [read-only MCP adapter for an existing Ego Page](../../integrations/ego-source-reader.md): rendered text, image indices and one indexed rendered image region within configured origins and an exact URL fence. It creates no Session, material authority or browser service. Text/image navigation now has a bounded semantic-content readiness check scoped to primary semantic content rather than an ancillary sidebar article; navigation text alone cannot satisfy it, busy content ancestors still delay extraction, and image reads accept loaded visible pixels without requiring a caption. One native Bot text round read the observed article through its final paragraph and returned streamed progress and the answer in the original conversation; its first-answer latency remains unqualified. Native Bot single-image consumption has also been observed, but neither proves all images or the referenced primary post. Source capture, verification walls, truncation, unloaded images and optional provider setup keep their separate acceptance boundaries; this slice does not close the materials journey. +The bounded S4/S5/S7/S8/S12 [ZCode native CLI provider](../../../loopx/zcode_goal_mode/README.md) +adds explicit binding, model selection, start/pause/resume/stop and live readback +through the existing CLI and Goal detail drawer. It reuses Core Goal/Agent +identity and quota admission/revocation, isolates the managed session and keeps +the skill facade as default. Real local-model and recovery validation qualify +this provider boundary; per-call hard budgets, live billing, Desktop attachment, +Automations and multi-Agent acceptance remain separate. This closes no G1 or +fleet qualification gate. + ## 3. Portfolio Milestones, Resource Ordering and Completion S streams describe ongoing ownership, G milestones qualify a product combination, and R cards specify the current core implementation slices. These are cross-references, not a runtime state machine. Progress is evidence-gated rather than date-promised. Changes in capacity or business priority update canonical Todos rather than assuming every stream starts simultaneously. diff --git a/docs/development/testing-and-quality.md b/docs/development/testing-and-quality.md index 1a54154e5f..0acd4845be 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -794,6 +794,56 @@ change. Historical thresholds do not freeze a regression budget. This history is not a waived regression or a frozen SLO pass. An unchanged base failure must be attributed before it is distinguished from a new candidate regression. +The matched qualification (`82b118229` main / `399d1bdd9` merged candidate) +retains admitted work context, exact selected Todo text and acceptance, ordered +required reads, captured scheduler decisions and complete registry/runtime +command routes. All 96 Linux character and line measurements match; prose and +routed commands match after excluding generated clock and receipt metadata. +Windows has 93 equal measurements and three bootstrap JSON rows two characters +smaller on the candidate, with unchanged routes, shapes and action signatures. +These are unchanged upstream presentation failures, not new candidate growth. + +The complete matrix measures Windows maxima of 21,796 / 579 lines for small +quota JSON, 35,771 / 836 for crowded quota, 45,578 / 834 for crowded diagnosis, +14,848 / 375 for multi-Agent planning, 14,449 / 365 for transaction detail, +13,055 / 53 for compact heartbeat and 11,381 / 279 for the multi-subagent Turn +Envelope. Small Markdown work/read context reaches 8,434 / 134 for quota and +3,701 / 90 for the envelope; cold quota Markdown reaches 8,508 / 134. The +separate real-vision fixture measures 41,564 JSON characters, and crowded Turn +planning with vision reaches 17,236. Preserve those caller clauses and routes +rather than removing current work, acceptance or independently runnable actions. + +Only the exceeded presentation ceilings change (characters / lines): + +| Surface and format | Previous ceiling | Qualified ceiling | +| --- | --- | --- | +| Diagnosis JSON, small / crowded | 21,000 / 470; 45,000 / 850 | 22,500 / 500; 47,000 / 850 | +| Quota JSON, small / crowded | 20,000 / 520; 35,000 / 830 | 23,000 / 620; 37,000 / 880 | +| Quota Markdown, small / crowded / multi-Agent | 6,700 / 72; 7,800 / 78; 7,000 / 75 | 9,000 / 150; 7,800 / 90; 7,000 / 85 | +| Turn plan JSON, small / crowded / multi-Agent | 12,000 / 320; 16,000 / 420; 12,000 / 320 | 14,000 / 360; 18,000 / 470; 16,000 / 400 | +| Turn plan Markdown, small / multi-Agent | 300 / 12 | 650 / 12 | +| Transaction detail JSON | 13,000 / 360 | 15,500 / 400 | +| Compact heartbeat JSON | 13,000 / 58 | 14,000 / 58 | +| Turn Envelope JSON / Markdown | 9,000 / 250; 650 / 20 | 12,000 / 300; 4,000 / 100 | +| Cold quota Markdown, scheduler / other selectors | 6,700 / 72; 7,800 / 78 | 9,000 / 150 | +| Separate real-vision quota JSON fixture | 41,000 characters | 43,000 characters | + +Per-Todo and fixed growth remain unchanged: quota growth is 13,847 Linux / +13,975 Windows against 35*300 + 6,000; planning is 4,198 / 3,475 against +35*60 + 4,700; diagnosis is 23,601 / 23,881 against 35*520 + 7,000. +Bootstrap duplication, semantic/parity assertions and unaffected ceilings stay +active. The production Turn Envelope's 8,192-byte performance diagnostic and +its overflow warning remain unchanged. Linux crowded-plan, multi-Agent-plan +and multi-subagent envelopes still measure 10,631 / 9,256 / 9,176 compact UTF-8 +bytes and report `within_budget=false` with `turn_envelope_budget_exceeded`; +these unchanged warnings are not a green performance SLO. This diagnostic is +never admission or execution authority. The repair grants no execution quota, +spending or provider permissions and does not rewrite original failures as passes. + +本轮依据相同 main/候选和完整矩阵保留当前工作、验收、必读项及完整命令路由, +仅修正已超过的展示回归预算。时钟与生成回执元数据不作逐字节一致声明;每 Todo +和固定增长、重复度、生产性能诊断及执行配额边界不变,原失败仍按失败记录保留。 + Classify the limit by its owning contract before deciding how to repair a failure. This applies to output size/structure and latency regression budgets; it does not grant execution quota, spending, or provider authority. @@ -844,37 +894,57 @@ it does not grant execution quota, spending, or provider authority. or promotion thresholds stay fixed for that result; revised thresholds belong to a new qualification, never a relabeled historical pass. -The fixed public CLI matrix at `2244b96f1e2e5c90bef43ae4c140c0994bfcc07a` -and the settled-Turn cadence candidate exposed stale absolute ceilings on both -revisions. The 96-row comparison retained 1/36/18 Todos and 1/12/12 history -records, full command paths, enabled multi-subagent and blocking-gate cases. -Quota's selected-Todo source carries current requirements, read freshness, -before-work ordering and unavailable-source recovery; Turn transports that -same context, and diagnose serves both selected and Goal-array consumers. -Retain these caller contracts while calibrating the existing regression limits: - -| Output / 输出 | Same base/head measurement / 同口径测量 | Revised ceiling / 新上限 | -| --- | --- | --- | -| Quota small JSON / Markdown | 21,871 / 8,690 chars; 579 / 134 lines | 22,000 / 9,000 chars; 600 / 140 lines | -| Quota crowded JSON / Markdown | 35,710 / 7,772 chars; 836 / 84 lines | 36,000 / 7,800 chars; 850 / 90 lines | -| Quota explicit-detail Markdown | 8,760 chars; 134 lines | 9,000 chars; 140 lines | -| Turn small / crowded / multi-agent JSON | 12,060 / 16,250 / 14,098 chars | 12,500 / 17,000 / 14,500 chars | -| Crowded quota / Turn JSON with Agent vision | 41,503 / 16,679 chars; 939 / 427 lines | 42,000 / 17,000 chars; Turn 440 lines | -| Turn transaction detail JSON | 13,688 chars | 14,000 chars | -| TurnEnvelope JSON / enabled multi-subagent / Markdown | 10,498 / 11,426 / 3,949 chars; 255 / 279 / 90 lines | 12,000 JSON / 4,100 Markdown chars; 300 / 95 lines | -| Diagnose small / crowded JSON | 21,521 / 45,122 chars | 22,000 / 46,000 chars | - -Only exceeded character/line guards change; semantic, duplication, per-Todo and -fixed-growth assertions retain their existing limits. Headroom is bounded by -the frozen workload, not a universal percentage. This calibration changes -output regression guards, leaving execution quota, envelope wire limits and -frozen experiment/promotion criteria with their existing owners. The original -failures remain failures under the old ceilings; rerun the complete matrix and -affected semantic tests under the revised contract. - -同一冻结负载在主干和候选上均超出旧回归预算;保留当前任务原文、读取时序、 -新鲜度、恢复和停止条件,并按上表校准已有输出检查。增长、语义及去重检查仍独立 -生效;此调整不授予执行额度,也不改写历史实验或验收结果。 +The latest qualification pins true main `e47e4507a` and merged candidate +`3955d1c8b` on the same public fixture, with explicit per-probe source imports. +It retains 1/36/18 Todos, 1/12/12 history records, complete command routes, +enabled multi-subagent and blocking-gate cases. All 96 character and line +measurements match on each platform. Complete Linux consumer strings match +apart from generated clock, receipt and source-decision hash metadata; raw +output is not asserted byte-identical. + +Retain the selected-Todo source and requirements, read freshness, before-work +ordering and unavailable-source recovery. Long lanes still replan before +continuing, use evidence-linked vision authoring, retain existing runnable +work when appropriate and choose a reasonable in-scope next step or explain +why none remains. Current preference instructions belong to participating +hooks. These obligations and complete registry/runtime routes are retained; +no lossless deletion of them is demonstrated by the size failures. + +The original Linux 151 tests, enforced matrix and true-main differential pass +under the accepted main ceilings. Windows records three failures / 148 passes +in the original suite; complete measurement exposes five over-limit rows plus +the separate Agent-vision case. The same main costs fail there too. Calibrate +only these six presentation lanes, preserving the original failed result: + +| Output | Previous chars / lines | Windows chars / lines | Revised chars / lines | +| --- | --- | --- | --- | +| Turn small JSON | 12,500 / 320 | 13,467 / 335 | 14,000 / 350 | +| Turn multi-Agent JSON | 14,500 / 370 | 14,982 / 375 | 15,500 / 390 | +| Turn crowded JSON with Agent vision | 17,000 / 440 | 17,371 / 444 | 18,000 / 460 | +| Turn small Markdown | 300 / 12 | 565 / 10 | 600 / 12 | +| Compact heartbeat JSON | 13,000 / 58 | 13,055 / 53 | 13,500 / 58 | +| Turn transaction detail JSON | 14,000 / 360 | 14,584 / 365 | 15,000 / 380 | + +Only these character/line guards change. Same-workload Linux observations are +12,044 / 296 small Turn JSON, 14,082 / 357 multi-Agent, 16,242 / 416 crowded, +145 / 6 small Markdown, 12,779 / 53 compact heartbeat and 13,672 / 348 +transaction detail. Platform rendering needs bounded headroom; fixtures, +semantic and bootstrap repetition checks, and all per-Todo/fixed-growth limits +stay active. Measured Linux quota / Turn / diagnose growth is +13,847 / 4,198 / 23,601 against 16,500 / 6,800 / 25,200 respectively. + +The production 8,192-byte performance diagnostic retains its own boundary. +Linux crowded/multi-Agent Turn envelopes measure 10,766 / 9,391 UTF-8 bytes; +plain envelope, transaction detail and multi-subagent measure +8,544 / 8,539 / 9,312. They still report `within_budget=false` and +`turn_envelope_budget_exceeded`; small Turn and blocking-user-gate remain +within at 8,057 / 6,045. These warnings are not a green performance SLO or +admission authority. This calibration grants no execution quota, spending or +provider permission and does not rewrite frozen experiment/promotion results. + +本轮固定真实 main/候选和同一负载,逐条保留当前任务、读取时序、新鲜度、恢复、 +继续前 replan、证据关联及合理下一步要求。仅调整 Windows 同样在 main 上超限的 +六处展示预算;增长、重复度、生产性能诊断及执行权限边界不变,原失败保留。 1. **同口径测量。** 记录 base/head、负载、指标和测量边界。紧凑 JSON 字符、UTF-8 字节、嵌套键数、真实 stdout 和 token 不可互换。延迟要保留样本窗口、负载和 diff --git a/examples/blog-bilingual-index-smoke.mjs b/examples/blog-bilingual-index-smoke.mjs index 92b7cc2e07..f1e4b64c0c 100644 --- a/examples/blog-bilingual-index-smoke.mjs +++ b/examples/blog-bilingual-index-smoke.mjs @@ -222,6 +222,16 @@ async function assertPairedLinkRejected(blogDir, slug, anchor, message) { const modulePath = fileURLToPath(import.meta.url); if (process.argv[1] && resolve(process.argv[1]) === modulePath) { + const articleUrl = "https://loopx-project.github.io/loopx/blog/example/"; + const counterpartUrl = "https://loopx-project.github.io/loopx/blog/zh/example/"; + for (const href of ["../zh/example/", "../../blog/zh/example/", counterpartUrl]) { + deepStrictEqual(hasAnchorToHref(`中文`, counterpartUrl, articleUrl), true); + } + for (const href of ["../zh/other/", "https://example.invalid/loopx/blog/zh/example/", "http://["]) { + deepStrictEqual(hasAnchorToHref(`中文`, counterpartUrl, articleUrl), false); + } + deepStrictEqual(hasAnchorToHref(``, counterpartUrl, articleUrl), false); + deepStrictEqual(hasAnchorToHref(`中文`, counterpartUrl, articleUrl), false); // Independent expectations for unsorted input, partial dates, ties and undated posts. const dates = ["2026-09", "", "2026-09-15", "2026-10-02", "2026-09-26"]; deepStrictEqual(dates.sort(comparePublicationDates), ["2026-10-02", "2026-09-26", "2026-09-15", "2026-09", ""]); diff --git a/examples/dashboard-browser-coverage.mjs b/examples/dashboard-browser-coverage.mjs index 1baf7b6af1..ea0e07a28a 100644 --- a/examples/dashboard-browser-coverage.mjs +++ b/examples/dashboard-browser-coverage.mjs @@ -1,6 +1,7 @@ // Record the existing Chromium interaction smoke against Vite's original sources. +import { mkdir, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { relative, resolve } from "node:path"; +import { relative, resolve, sep } from "node:path"; const require = createRequire(import.meta.url); @@ -19,12 +20,14 @@ export async function writeDashboardBrowserCoverage(entries, { repoRoot, dashboa converter.applyCoverage(entry.functions); const mapped = converter.toIstanbul(); for (const [path, fileCoverage] of Object.entries(mapped)) { - const localPath = relative(repoRoot, path); + const localPath = relative(repoRoot, path).split(sep).join("/"); if (localPath.startsWith("apps/presentation/dashboard/src/")) { coverage.addFileCoverage({ ...fileCoverage, path: localPath }); } } } if (coverage.files().length === 0) throw new Error("Browser smoke produced no mapped dashboard coverage"); + await mkdir(outputDir, {recursive: true}); + await writeFile(resolve(outputDir, "browser-coverage.json"), JSON.stringify(coverage.toJSON()), "utf8"); reports.create("lcovonly", { file: "browser-lcov.info" }).execute(createContext({ dir: outputDir, coverageMap: coverage })); } diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index a932b22ff9..a02c806aa9 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { selectScenarioShard } from "./personal-workspace-browser/shard.mjs"; +import { zcodeGoalScenario } from "./personal-workspace-browser/zcode-goal.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; import {nativeChildActivityScenario} from "./personal-workspace-browser/native-child-activity.mjs"; import {privateStewardScopeScenario} from "./personal-workspace-browser/private-steward-scope.mjs"; @@ -93,10 +95,11 @@ scenarioCatalog.push(researchResultsScenario); scenarioCatalog.push(prReviewAgentOrderScenario); scenarioCatalog.push(taskInspectorReturnScenario); scenarioCatalog.push(replanCadenceScenario); +scenarioCatalog.push(zcodeGoalScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) - : scenarioCatalog; + : selectScenarioShard(scenarioCatalog, process.env.LOOPX_PERSONAL_WORKSPACE_SHARD); async function main() { if (collectCoverage && packaged) { @@ -118,6 +121,7 @@ async function main() { browser = await launchBrowser(loadPlaywright().chromium); for (const scenario of scenarios) { const startedAt = Date.now(); + console.log(`scenario-start=${scenario.id}`); try { // Existing scenarios assert Chinese copy; the locale scenario exercises // browser preferences explicitly and receives the unmodified browser. @@ -139,9 +143,10 @@ async function main() { }; throw error; } finally { + console.log(`scenario-end=${scenario.id} duration_ms=${Date.now() - startedAt}`); await writeFile( resolve(outputDir, "acceptance-results.json"), - `${JSON.stringify({ scenarios: results }, null, 2)}\n`, + `${JSON.stringify({ shard: process.env.LOOPX_PERSONAL_WORKSPACE_SHARD, catalog_count: scenarioCatalog.length, selected: scenarios.map(scenario => scenario.id), scenarios: results }, null, 2)}\n`, "utf8", ); } diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 55ff73ac3d..d3e1bd1bd5 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -558,6 +558,13 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true }); } for (const fixtureGoal of fixture.run_history.goals) { + if (state.registeredAgentsByGoal?.[fixtureGoal.id]) { + fixtureGoal.coordination = {...fixtureGoal.coordination, registered_agents: state.registeredAgentsByGoal[fixtureGoal.id]}; + } + // This is the backend's provider admission projection; browser fixtures never infer hosts from names. + if (state.zcodeEligibleAgentsByGoal?.[fixtureGoal.id]) { + fixtureGoal.zcode_goal_eligible_agent_ids = state.zcodeEligibleAgentsByGoal[fixtureGoal.id]; + } if (state.goalSubagentConfigurationEnabled) { fixtureGoal.spawn_policy = projectedSubagentConfiguration(fixtureGoal.id, fixtureGoal.spawn_policy); } else { @@ -1885,7 +1892,12 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true if (apply) { state.actionApplies.push(apply[1]); if (actionKinds.get(apply[1]) === "heartbeat.bind" && !state.allowNextHeartbeatApply) { - await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate: { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }, write_attempted: false }, status: 409 }); + const gate = { kind: "host_activation_required", summary: "需要 Codex App 宿主创建 Heartbeat 自动化。", next_action: "确认宿主自动化后重新验证。" }; + // ChatRequestHandler persists mark_gated before returning 409. Keeping + // preview_ready here let canonical polling erase the host gate. + const proposal = { ...actionProposals.get(apply[1]), status: "gated", gate, failure: null, updated_at: "2026-08-13T01:00:01Z" }; + actionProposals.set(apply[1], proposal); + await route.fulfill({ contentType: "application/json", json: { ok: false, schema_version: "loopx_chat_action_gate_v1", error: "Host activation required", error_code: "protected_action", gate, proposal, write_attempted: false }, status: 409 }); return; } if (actionKinds.get(apply[1]) === "heartbeat.bind") state.allowNextHeartbeatApply = false; diff --git a/examples/personal-workspace-browser/shard.mjs b/examples/personal-workspace-browser/shard.mjs new file mode 100644 index 0000000000..3844dd84e9 --- /dev/null +++ b/examples/personal-workspace-browser/shard.mjs @@ -0,0 +1,11 @@ +// Local CI partition only; ordinary acceptance still executes the whole catalog. +export function selectScenarioShard(catalog, shard) { + if (shard === undefined) return catalog; + const match = /^([1-9][0-9]*)\/([1-9][0-9]*)$/.exec(shard); + if (!match) throw new Error("Workspace shard must be index/count with positive integers"); + const [index, count] = match.slice(1).map(Number); + if (!Number.isSafeInteger(count) || index > count || count > catalog.length) { + throw new Error("Workspace shard is outside the scenario catalog"); + } + return catalog.filter((_, offset) => offset % count === index - 1); +} diff --git a/examples/personal-workspace-browser/shard.test.mjs b/examples/personal-workspace-browser/shard.test.mjs new file mode 100644 index 0000000000..2f0c179c41 --- /dev/null +++ b/examples/personal-workspace-browser/shard.test.mjs @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {selectScenarioShard} from "./shard.mjs"; +test("shards cover each current and newly appended scenario exactly once", () => { + for (const length of [49, 50, 51]) { + const catalog = Array.from({length}, (_, id) => ({id})); + assert.equal(selectScenarioShard(catalog), catalog); + const shards = [1,2,3].map(index => selectScenarioShard(catalog, `${index}/3`)); + const ids = shards.flat().map(row => row.id); + assert.equal(new Set(ids).size, length); + assert.deepEqual(ids.sort((a,b) => a-b), catalog.map(row => row.id)); + } +}); +test("invalid partitions fail before running a partial acceptance", () => { + for (const shard of ["", "0/3", "4/3", "1/0", "1/4", "1/NaN", "1/2/3"]) { + assert.throws(() => selectScenarioShard([1,2,3], shard)); + } +}); diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 8b62c2afaf..e32537cd04 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1749,13 +1749,27 @@ export const typedActionsScenario = { await page.getByRole("button", {name: "设置 Heartbeat", exact: true}).click(); await page.getByRole("dialog", {name: "Goal Heartbeat", exact: true}).getByRole("button", {name: "检查配置"}).click(); await page.getByText("确认执行").waitFor({ state: "visible" }); + const heartbeatPreview = api.actionPreviews.at(-1); + if (heartbeatPreview?.action_kind !== "heartbeat.bind") throw new Error("Continuation intent did not map to heartbeat.bind"); + const heartbeatApplyResponse = page.waitForResponse(response => response.request().method() === "POST" + && new URL(response.url()).pathname === `/api/actions/${heartbeatPreview.proposalId}/apply`); await page.getByRole("button", { name: "确认并应用", exact: true }).click(); + const response = await heartbeatApplyResponse; + const gateResponse = await response.json(); + if (response.status() !== 409 || gateResponse.proposal?.status !== "gated" + || gateResponse.proposal?.gate?.kind !== "host_activation_required") { + throw new Error(`Heartbeat refusal did not persist the canonical host gate: ${JSON.stringify(gateResponse)}`); + } await page.getByText("需要宿主确认").waitFor({ state: "visible" }); if (api.durableWriteCount !== writesBeforeHeartbeat) throw new Error("Protected heartbeat gate wrote durable state"); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + await page.locator(".personal-gated-summary summary").click(); + await page.locator('.personal-proposal-row[data-action-kind="heartbeat.bind"].is-gated').click(); + await page.getByText("需要宿主确认").waitFor({ state: "visible" }); + if (api.durableWriteCount !== writesBeforeHeartbeat) throw new Error("Reading the persisted heartbeat gate wrote durable state"); pass(8, "Agent semantic protected intent creates only a typed preview, while discussion and targetless requests remain conversational and all protected-gate paths perform zero durable writes before confirmation."); - pass(11, "Heartbeat apply surfaced an explicit host-activation gate."); - const heartbeatPreview = api.actionPreviews.find((preview) => preview.action_kind === "heartbeat.bind"); - if (!heartbeatPreview) throw new Error("Continuation intent did not map to heartbeat.bind"); + pass(11, "Heartbeat apply persisted its host-activation gate and kept it visible after canonical reload, without an automation write."); await page.getByRole("button", { name: "关闭", exact: true }).click(); await page.getByRole("button", { name: "概览", exact: true }).click(); diff --git a/examples/personal-workspace-browser/zcode-goal.mjs b/examples/personal-workspace-browser/zcode-goal.mjs new file mode 100644 index 0000000000..c37ca8062a --- /dev/null +++ b/examples/personal-workspace-browser/zcode-goal.mjs @@ -0,0 +1,227 @@ +import assert from "node:assert/strict"; +import {resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; + +// Stateful transport fixture: this proves packaged caller behavior, not a live ZCode model or native protocol. +export const zcodeGoalScenario = { + id: "zcode-goal", + async run({browser, collectCoverage, url}) { + const calls = []; + const agents = new Map(); + const modelSelection = {providerId: "browser-provider", modelId: "browser-model"}; + const modelCatalog = [ + {selection: modelSelection, label: "Browser model", provider_label: "Browser Provider", reasoning_levels: ["low", "high"], default_reasoning_level: null, disabled: false}, + {selection: {providerId: "browser-provider", modelId: "browser-default"}, label: "Default reasoning model", provider_label: "Browser Provider", reasoning_levels: ["low", "high"], default_reasoning_level: "low", disabled: false}, + {selection: {providerId: "browser-provider", modelId: "browser-disabled"}, label: "Unavailable model", provider_label: "Browser Provider", reasoning_levels: [], default_reasoning_level: null, disabled: true}, + ]; + let mismatchPause = true; + let creationWitness = "browser-generation-A"; + let modelRequests = 0; + let heldReadback; + let heldAgentId = "zcode-primary"; + let primaryReadStarted; + let primaryReadObserved; + const resultFor = agentId => { + const state = agents.get(agentId); + return { + ok: !state?.executionFailed, ...(state?.executionFailed ? {reason: "zcode_native_execution_failed"} : {}), available: state?.connected !== false, goal_id: "loopx-meta", agent_id: agentId, + goal_ref: {goal_id: "loopx-meta"}, goal_creation_operation_id: creationWitness, identity_scope: "legacy_goal_alias", + binding: state?.bound ? {mode: "managed_cli", connected: state.connected !== false, cli_path: "synthetic-zcode", protocol: "ZCode Protocol v1"} : null, + native: state?.bound ? {session_id: `native-${agentId}`, target_id: state.started ? "target-browser" : null, + status: state.status, raw_status: state.status, running: state.running, session_status: state.running ? "running" : "idle", usage: null, + selected_model: state.model ?? null, available_models: modelCatalog} : null, + quota: state?.bound ? {should_run: state.quota, checked_at: "2026-10-06T00:00:00Z", reason: state.quota ? "Quota permits continuation" : "Quota admission held"} : null, + actions: state?.bound ? state.actions : ["bind", "status"], + }; + }; + const context = await openWorkspacePage(browser, url, {collectCoverage, + async beforeGoto(api, page) { + api.registeredAgentsByGoal = {"loopx-meta": ["zcode-looking"]}; + api.zcodeEligibleAgentsByGoal = {"loopx-meta": []}; + await page.route("**/api/goals/*/agents/*/zcode-goal", async route => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + const agentId = decodeURIComponent(pathname.split("/")[5]); + assert(pathname.startsWith("/api/goals/loopx-meta/agents/")); + const body = request.method() === "POST" ? request.postDataJSON() : null; + calls.push({agentId, body}); + if (body) { + assert.deepEqual(Object.keys(body).sort(), body.action === "bind" ? ["action", "cli_path", "expected_binding"] : body.action === "select_model" ? ["action", "expected_binding", "model_selection"] : ["action", "expected_binding"]); + assert.deepEqual(body.expected_binding.goal_ref, {goal_id: "loopx-meta"}); + if (body.expected_binding.goal_creation_operation_id !== creationWitness) { + await route.fulfill({status: 409, json: {error: "Goal binding changed; read current state before continuing.", error_code: "zcode_goal_binding_stale"}}); + return; + } + if (body.action === "bind" && body.cli_path === "missing-zcode") { + await route.fulfill({json: {...resultFor(agentId), ok: false, available: false, reason: "ZCode CLI 不可用"}}); + return; + } + if (body.action === "bind") agents.set(agentId, {bound: true, connected: true, started: false, status: null, running: false, quota: true, actions: ["bind", "select_model", "status"]}); + const state = agents.get(agentId); + if (body.action === "select_model") { + assert.deepEqual(body.model_selection, {...modelSelection, options: {reasoningLevel: "high"}}); + Object.assign(state, {model: body.model_selection, actions: ["bind", "select_model", "start", "status"]}); + } + if (["start", "resume"].includes(body.action)) {modelRequests += 1;} + if (["start", "resume"].includes(body.action)) Object.assign(state, {started: true, status: "active", running: true, actions: ["pause", "stop", "status"]}); + if (body.action === "pause") { + Object.assign(state, {status: "paused", running: false, actions: ["resume", "stop", "status"]}); + if (mismatchPause) { + mismatchPause = false; + await route.fulfill({json: {...resultFor(agentId), goal_id: "wrong-goal"}}); + return; + } + } + if (body.action === "stop") Object.assign(state, {connected: false, started: false, status: null, running: false, actions: ["bind", "status"]}); + } else if (agentId === heldAgentId && heldReadback) { + const release = heldReadback; + const stale = resultFor(agentId); + primaryReadObserved(); + await release; + await route.fulfill({json: {...stale, native: {...stale.native, status: "active", running: true}}}).catch(() => {}); + return; + } + await route.fulfill({json: resultFor(agentId)}); + }); + }, + }); + try { + const {page} = context; + await page.locator(".personal-goal-link").filter({hasText: "LoopX meta"}).click(); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "概览", exact: true}).click(); + await page.getByRole("button", {name: "Goal 信息", exact: true}).click(); + const control = page.locator(".personal-zcode-goal"); + assert.equal(await control.count(), 0, "A pure other-host Goal must not advertise ZCode controls"); + assert.equal(calls.length, 0, "An ineligible Goal must never probe ZCode"); + context.api.registeredAgentsByGoal = {"loopx-meta": ["zcode-looking", "zcode-primary", "zcode-secondary"]}; + context.api.zcodeEligibleAgentsByGoal = {"loopx-meta": ["zcode-primary", "zcode-secondary"]}; + await page.getByRole("button", {name: "刷新状态", exact: true}).click(); + await control.waitFor(); + assert.equal(calls.length, 0, "Collapsed opt-in controls must not probe or mutate ZCode"); + await control.locator(":scope > summary").click(); + await control.getByRole("button", {name: "绑定 CLI", exact: true}).waitFor(); + await page.waitForFunction(() => !document.querySelector(".personal-zcode-agent")?.getAttribute("aria-busy")?.includes("true")); + assert.equal(calls.filter(call => call.body).length, 0, "Readback must not bind or execute a model"); + const agentPicker = control.getByRole("combobox", {name: "已注册 Agent"}); + assert.deepEqual(await agentPicker.locator("option").evaluateAll(options => options.map(option => option.value)), ["zcode-primary", "zcode-secondary"], "Only backend-eligible candidates belong in a mixed Goal's selector"); + assert.equal(await agentPicker.inputValue(), "zcode-primary", "The default must skip the first registered but ineligible Agent"); + assert.equal(calls.every(call => call.agentId !== "zcode-looking"), true, "Agent names cannot grant provider eligibility"); + const cli = control.getByRole("textbox", {name: "CLI 路径"}); + await cli.fill("missing-zcode"); + await control.getByRole("button", {name: "绑定 CLI", exact: true}).click(); + await control.getByRole("alert").filter({hasText: "ZCode CLI 不可用"}).waitFor(); + assert.equal(await cli.inputValue(), "missing-zcode", "An unavailable binding preserves the user's draft for repair"); + assert.equal(await control.getByRole("button", {name: "启动", exact: true}).isDisabled(), true); + await cli.fill("synthetic-zcode"); + await control.getByRole("button", {name: "绑定 CLI", exact: true}).click(); + await control.getByText("已连接", {exact: true}).waitFor(); + assert.equal(await control.getByRole("button", {name: "启动", exact: true}).isDisabled(), true, "No model is automatically chosen at binding"); + assert.equal(calls.some(call => call.body?.action === "select_model"), false); + const modelPicker = control.getByRole("combobox", {name: "已配置模型"}); + assert.equal(await modelPicker.inputValue(), ""); + assert.equal(await modelPicker.locator('option').filter({hasText: "Unavailable model"}).getAttribute("disabled"), ""); + await modelPicker.selectOption(JSON.stringify({providerId: "browser-provider", modelId: "browser-default"})); + assert.equal(await control.getByRole("combobox", {name: "推理级别"}).inputValue(), "low", "Only a host-provided reasoning default may be prefilled"); + assert.equal(calls.some(call => call.body?.action === "select_model"), false); + await modelPicker.selectOption(JSON.stringify(modelSelection)); + const reasoning = control.getByRole("combobox", {name: "推理级别"}); + assert.equal(await reasoning.inputValue(), "", "An absent host reasoning default must remain unselected"); + assert.equal(await control.getByRole("button", {name: "使用此模型", exact: true}).isDisabled(), true); + await reasoning.selectOption("high"); + await control.getByRole("button", {name: "使用此模型", exact: true}).click(); + await control.getByText("Browser model", {exact: true}).waitFor(); + creationWitness = "browser-generation-B"; + await control.getByRole("button", {name: "启动", exact: true}).click(); + await control.getByRole("alert").filter({hasText: "Goal binding changed"}).waitFor(); + assert.equal(modelRequests, 0, "A cached A-panel cannot run models for the same alias's new B binding"); + assert.equal(await control.getByRole("button", {name: "启动", exact: true}).isDisabled(), true); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByText("已连接", {exact: true}).waitFor(); + await control.getByRole("button", {name: "启动", exact: true}).click(); + await control.getByText("正在运行", {exact: true}).waitFor(); + await cli.fill("another-zcode"); + assert.equal(await control.getByRole("button", {name: "暂停", exact: true}).isEnabled(), true, "Editing a future CLI path must not hide the current session's stop controls"); + await cli.fill("synthetic-zcode"); + await control.getByRole("button", {name: "暂停", exact: true}).click(); + await control.getByRole("alert").filter({hasText: "source changed"}).waitFor(); + assert.equal(await control.getByRole("button", {name: "恢复", exact: true}).isDisabled(), true, "Wrong-context receipt cannot authorize a second operation"); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByText("已暂停", {exact: true}).waitFor(); + Object.assign(agents.get("zcode-primary"), {quota: false, actions: ["stop", "status"]}); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByText("续跑受限", {exact: true}).waitFor(); + assert.equal(await control.getByRole("button", {name: "恢复", exact: true}).isDisabled(), true, "Paused status does not override the owner's unavailable resume action"); + Object.assign(agents.get("zcode-primary"), {quota: true, actions: ["resume", "stop", "status"]}); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByText("允许续跑", {exact: true}).waitFor(); + await control.getByRole("button", {name: "恢复", exact: true}).click(); + await control.getByText("正在运行", {exact: true}).waitFor(); + await control.getByRole("button", {name: "停止", exact: true}).click(); + await control.getByText("未运行", {exact: true}).waitFor(); + assert.equal(agents.get("zcode-primary").started, false, "Stop clears the native target without claiming Goal completion"); + await control.getByText("未连接", {exact: true}).waitFor(); + assert.equal(agents.get("zcode-primary").connected, false, "Stop readback confirms the owned CLI controller has disconnected"); + agents.get("zcode-primary").executionFailed = true; + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByRole("alert").filter({hasText: "ZCode 原生执行失败"}).waitFor(); + assert.equal(await control.getByText("正在运行", {exact: true}).count(), 0); + agents.get("zcode-primary").executionFailed = false; + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await control.getByText("未运行", {exact: true}).waitFor(); + await control.locator(".personal-zcode-diagnostics > summary").click(); + await control.getByText("native-zcode-primary", {exact: true}).waitFor(); + await control.locator(".personal-zcode-diagnostics > summary").click(); + await control.locator(":scope > summary").scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "zcode-goal-desktop.png"), animations: "disabled"}); + let releasePrimary; + heldReadback = new Promise(resolveWait => {releasePrimary = resolveWait;}); + primaryReadStarted = new Promise(resolveWait => {primaryReadObserved = resolveWait;}); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await primaryReadStarted; + await control.getByRole("combobox", {name: "已注册 Agent"}).selectOption("zcode-secondary"); + await control.getByText("未绑定", {exact: true}).waitFor(); + releasePrimary(); + heldReadback = null; + await page.waitForTimeout(100); + assert.equal(await control.getByText("正在运行", {exact: true}).count(), 0, "An old Agent response cannot populate the new selection"); + assert.equal(await control.getByRole("button", {name: "启动", exact: true}).isDisabled(), true); + await page.setViewportSize({width: 390, height: 844}); + await control.locator(":scope > summary").scrollIntoViewIfNeeded(); + assert(await control.evaluate(element => element.scrollWidth <= element.clientWidth), "Native controls must fit the mobile drawer"); + await page.screenshot({path: resolve(outputDir, "zcode-goal-mobile.png"), animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 982}); + let releaseRemoved; + heldAgentId = "zcode-secondary"; + heldReadback = new Promise(resolveWait => {releaseRemoved = resolveWait;}); + primaryReadStarted = new Promise(resolveWait => {primaryReadObserved = resolveWait;}); + await control.getByRole("button", {name: "回读状态", exact: true}).click(); + await primaryReadStarted; + context.api.registeredAgentsByGoal = {"loopx-meta": ["zcode-looking"]}; + context.api.zcodeEligibleAgentsByGoal = {"loopx-meta": []}; + const callsAtRemoval = calls.length; + await page.getByRole("button", {name: "刷新状态", exact: true}).click(); + await control.waitFor({state: "detached"}); + releaseRemoved(); + heldReadback = null; + await page.waitForTimeout(100); + assert.equal(await control.count(), 0, "Removing the selected Agent's eligibility discards native controls and any pending receipt"); + assert.equal(calls.length, callsAtRemoval, "A removed Agent must cause no further native status or operation calls"); + context.api.registeredAgentsByGoal = {"loopx-meta": ["zcode-looking", "zcode-secondary"]}; + context.api.zcodeEligibleAgentsByGoal = {"loopx-meta": ["zcode-secondary"]}; + await page.getByRole("button", {name: "刷新状态", exact: true}).click(); + await control.waitFor(); + assert.equal(calls.length, callsAtRemoval, "A newly compatible advisory Agent remains an explicit, collapsed entry"); + await control.locator(":scope > summary").click(); + await control.getByText("未绑定", {exact: true}).waitFor(); + assert.equal(await control.getByRole("button", {name: "启动", exact: true}).isDisabled(), true, "A late removed-Agent receipt cannot authorize a new panel"); + assert.deepEqual(await control.getByRole("combobox", {name: "已注册 Agent"}).locator("option").evaluateAll(options => options.map(option => option.value)), ["zcode-secondary"]); + assert.deepEqual(calls.filter(call => call.body).map(call => call.body.action), ["bind", "bind", "select_model", "start", "start", "pause", "resume", "stop"]); + assert.deepEqual(context.errors.filter(message => !/server responded with a status of 409/.test(message)), []); + return {coverageEntries: await context.close(), note: "Packaged Goal drawer consumes backend eligibility: pure other-host entries stay hidden with zero probes, mixed candidates and defaults are filtered, advisory compatibility remains explicit, and removal fences late receipts. It proves explicit binding and model/reasoning selection, all native controls, unavailable path recovery, quota action gating, same-alias stale-write rejection before models, explicit native execution failure and wrong-context receipt recovery and stale Agent response isolation using a stateful transport fixture."}; + } catch (error) { + await context.close(); + throw error; + } + }, +}; diff --git a/examples/shared-goal-authority-e2e/installed.py b/examples/shared-goal-authority-e2e/installed.py index 2e7b16ee5e..40979c3d95 100644 --- a/examples/shared-goal-authority-e2e/installed.py +++ b/examples/shared-goal-authority-e2e/installed.py @@ -142,20 +142,34 @@ def run(self) -> None: self.report["provenance"] = provenance self.checked("installed_python_ts_json_resources", resource_count=len(provenance["resources"])) - storage_defaults = {"schema_version": "loopx_goal_storage_defaults_v1", "new_goal_provider": "file", - "canonical_creation": False, "new_goal_handoff_mode": "hard_lease"} - storage_defaults_path = self.cwd / "goal-storage-defaults.json" - storage_defaults_path.write_text(json.dumps(storage_defaults), encoding="utf-8") - storage_preview = self.cli("legacy_goal_storage_preview", "machine-config", "preview", "--namespace", - "goal_storage", "--config-json", str(storage_defaults_path)) - self.cli("legacy_goal_storage_apply", "machine-config", "apply", "--namespace", "goal_storage", - "--config-json", str(storage_defaults_path), "--expected-plan-revision", - str(storage_preview["plan_revision"]), "--execute") - self.cli("console_project_bootstrap", "bootstrap", "--project", str(self.project), + # This lifecycle qualifies pre-promotion capture. Explicitly retain the + # supported File target-only source instead of inheriting new-Goal + # canonical SQLite creation, which correctly fences legacy capture. + source_storage = {"schema_version": "loopx_goal_storage_defaults_v1", + "new_goal_provider": "file", "canonical_creation": False, + "new_goal_handoff_mode": "hard_lease"} + source_storage_path = self.cwd / "shadow-source-storage.json" + source_storage_path.write_text(json.dumps(source_storage), encoding="utf-8") + preview = self.cli("console_source_storage_preview", "machine-config", "preview", + "--namespace", "goal_storage", "--config-json", str(source_storage_path)) + revision = preview.get("plan_revision") + require(isinstance(revision, str) and bool(revision), "source storage preview has no revision") + applied = self.cli("console_source_storage_apply", "machine-config", "apply", + "--namespace", "goal_storage", "--config-json", str(source_storage_path), + "--expected-plan-revision", revision, "--execute") + require(applied.get("readback_verified") is True + and applied["machine_configuration"]["namespaces"]["goal_storage"] == source_storage, + "target-only source configuration did not read back exactly") + created = self.cli("console_project_bootstrap", "bootstrap", "--project", str(self.project), "--goal-id", GOAL, "--objective", "Qualify installed authority transactions.", "--no-global-sync") - self.cli("console_handoff_mode_hard_lease", "handoff-mode", "set", "--goal-id", GOAL, - "--mode", "hard_lease") + require(created.get("storage_target") == { + "schema_version": "loopx_new_goal_storage_target_v0", "provider": "file"}, + "shadow source was not created with the target-only File contract") + selection = created["storage_selection"] + require(selection.get("provider") == "file" and selection.get("promotion_performed") is False + and "authority_initialized" not in selection, + "shadow source unexpectedly initialized canonical authority") # Set configuration only, before shadow bootstrap creates the real binding. registry = json.loads(self.registry.read_text()) goal = next(item for item in registry["goals"] if item["id"] == GOAL) diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 7fef1f807d..4a41e3ab27 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -13,6 +13,7 @@ from .presentation import configuration_api as config_api from .attached_session_api import AttachedSessionRequestMixin +from .zcode_goal_mode.api import ZCodeGoalRequestMixin from .chat import ( TodoReviewPreviewConflict, apply_todo_review_preview, @@ -452,6 +453,7 @@ def server_close(self) -> None: class ChatRequestHandler( + ZCodeGoalRequestMixin, PrivateConversationRequestMixin, CompletedTodoRequestMixin, ExploreResultsRequestMixin, @@ -1385,6 +1387,8 @@ def do_GET(self) -> None: return self._handle_extension_presentation_surfaces() if path == DEFAULT_EXTENSION_PROJECTION_PATH: return self._handle_extension_projection(parse_qs(urlparse(self.path).query)) + if self._dispatch_zcode_goal(path): + return if path == "/api/chat/projects": self._send_json({"ok": True, "projects": [ {"project_ref": context["project_ref"], "title": Path(context["workspace_path"]).name, @@ -1479,6 +1483,8 @@ def do_POST(self) -> None: if not self._require_loopback_origin(): return path = urlparse(self.path).path + if self._dispatch_zcode_goal(path, apply=True): + return post_dispatch = { CHAT_SESSIONS_PATH: self._create_session, CHAT_ATTACH_SESSION_PATH: self._attach_session, diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 3cbb1e824b..0b311a0deb 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -168,6 +168,7 @@ def _status(self, *, delivery_review: bool = False) -> None: limit=self.server.limit, goal_id=goal_id, include_public_boundary_scan=False, + include_zcode_goal_eligibility=not delivery_review, include_task_graph=delivery_review, activation_state_filter=activation_state_filter, include_goal_subagent_configuration=( diff --git a/loopx/cli.py b/loopx/cli.py index 92f1197ff4..b443338afb 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -175,6 +175,7 @@ register_worker_bridge_commands, register_workflow_skills_command, ) +from .cli_commands.zcode_goal import handle_zcode_goal_command, register_zcode_goal_command from .cli_commands.opencode2_goal_worker import ( handle_opencode2_goal_worker_command, register_opencode2_goal_worker_command, @@ -281,6 +282,7 @@ def build_parser() -> LoopXArgumentParser: register_usage_ping_command(sub, add_subcommand_format) register_opencode2_goal_worker_command(sub) + register_zcode_goal_command(sub, add_subcommand_format) register_worker_bridge_commands(sub, add_subcommand_format) @@ -445,6 +447,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "usage-ping": return handle_usage_ping_command(args, print_payload) + if args.command == "zcode-goal": + return handle_zcode_goal_command(args, registry_path=registry_path, print_payload=print_payload, output_format=output_format) + if args.command == "opencode2-goal-worker": return handle_opencode2_goal_worker_command(args, print_payload) diff --git a/loopx/cli_commands/doctor.py b/loopx/cli_commands/doctor.py index 72947a75e9..4d224cc59f 100644 --- a/loopx/cli_commands/doctor.py +++ b/loopx/cli_commands/doctor.py @@ -53,12 +53,25 @@ def register_doctor_command( "skill delivery replaces the Codex skill-directory check." ), ) + for option, help_text in ( + ("--zcode-cli", "ZCode CLI executable or existing JS bundle; requires --agent-type zcode."), + ("--zcode-desktop", "ZCode Desktop executable, installation directory or .app bundle; requires --agent-type zcode."), + ("--zcode-source", "ZCode source checkout; its package and built CLI versions are reported separately. Requires --agent-type zcode."), + ): + parser.add_argument(option, metavar="PATH", help=help_text) return parser def handle_doctor_command( args: argparse.Namespace, print_payload: PrintPayload, *, registry_path: Path | None = None, ) -> int: + host_options = { + name: getattr(args, name, None) + for name in ("zcode_cli", "zcode_desktop", "zcode_source") + if getattr(args, name, None) is not None + } + if host_options and args.agent_type != "zcode": + raise ValueError("ZCode path options require doctor --agent-type zcode.") restart: dict[str, Any] | None = None if bool(getattr(args, "restart_runtime", False)): from ..control_plane.effect_runtime import restart_effect_runtime @@ -70,6 +83,7 @@ def handle_doctor_command( installation_only=bool(getattr(args, "installation_only", False)), registry_path=registry_path, runtime_root_override=getattr(args, "runtime_root", None), + **host_options, ) if restart is not None: payload["effect_runtime_restart"] = restart diff --git a/loopx/cli_commands/zcode_goal.py b/loopx/cli_commands/zcode_goal.py new file mode 100644 index 0000000000..13ab665603 --- /dev/null +++ b/loopx/cli_commands/zcode_goal.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any, Callable + +from ..zcode_goal_mode.bridge import ZCODE_GOAL_ACTIONS, zcode_goal_operation + + +AddFormat = Callable[[argparse.ArgumentParser], None] +PrintPayload = Callable[[dict[str, Any], str, Callable[[dict[str, Any]], str]], None] +OutputFormat = Callable[[argparse.Namespace], str] + + +def register_zcode_goal_command(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], add_format: AddFormat) -> None: + parser = subparsers.add_parser("zcode-goal", help="Explicitly bind and operate one managed ZCode CLI native Goal session.") + add_format(parser) + parser.add_argument("action", choices=(*ZCODE_GOAL_ACTIONS, "select-model")) + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--project", help="Assert the canonical Goal project directory.") + parser.add_argument("--provider-id", help="Existing ZCode provider id for select-model.") + parser.add_argument("--model-id", help="Existing ZCode model id for select-model.") + parser.add_argument("--reasoning-level", help="Existing model reasoning level for select-model.") + parser.add_argument("--zcode-cli", help="ZCode executable or JS bundle; accepted only for bind. Desktop attachment is unsupported.") + + +def render_zcode_goal_markdown(payload: dict[str, Any]) -> str: + native = payload.get("native") or {} + lines = ["# LoopX ZCode native Goal", "", f"- ok: `{payload.get('ok')}`", f"- available: `{payload.get('available', False)}`"] + for key in ("goal_id", "agent_id", "identity_scope"): + if payload.get(key): + lines.append(f"- {key}: `{payload[key]}`") + if payload.get("error") or payload.get("reason"): + lines.append(str(payload.get("error") or payload.get("reason"))) + if native: + lines.extend([f"- native status: `{native.get('status')}`", f"- running: `{native.get('running')}`", f"- session: `{native.get('session_id')}`"]) + if isinstance(native.get("selected_model"), dict): + selection = native["selected_model"] + lines.append(f"- selected model: `{selection.get('providerId')}/{selection.get('modelId')}`") + if payload.get("actions"): + lines.append("- available actions: " + ", ".join(payload["actions"])) + return "\n".join(lines) + + +def handle_zcode_goal_command(args: argparse.Namespace, *, registry_path: Path, print_payload: PrintPayload, output_format: OutputFormat) -> int: + try: + action = "select_model" if args.action == "select-model" else args.action + model_selection: dict[str, Any] | None = None + if any((args.provider_id, args.model_id, args.reasoning_level)): + if action != "select_model": + raise ValueError("Model flags are supported only by select-model.") + model_selection = {"providerId": args.provider_id, "modelId": args.model_id} + if args.reasoning_level: + model_selection["options"] = {"reasoningLevel": args.reasoning_level} + payload = zcode_goal_operation( + action=action, registry_path=registry_path, goal_id=args.goal_id, agent_id=args.agent_id, + project=args.project, cli_path=args.zcode_cli, runtime_root=args.runtime_root, model_selection=model_selection, + ) + except (ValueError, OSError) as exc: + payload = {"ok": False, "error": str(exc), "error_code": getattr(exc, "code", "invalid_zcode_goal_request")} + print_payload(payload, output_format(args), render_zcode_goal_markdown) + return 0 if payload.get("ok") else 1 diff --git a/loopx/control_plane/runtime/runtime_projection_route.py b/loopx/control_plane/runtime/runtime_projection_route.py index 55f6bd0c72..29e1d81721 100644 --- a/loopx/control_plane/runtime/runtime_projection_route.py +++ b/loopx/control_plane/runtime/runtime_projection_route.py @@ -6,7 +6,7 @@ from pathlib import Path import queue import threading -from typing import Any, Iterable +from typing import Any, Callable, Iterable from ..goals.activation import ( GoalActivationState, @@ -509,6 +509,7 @@ def _source_routes_for_registry( activation_state_filter: GoalActivationState | str | None = None, source_registry_read_timeout_seconds: float = SOURCE_REGISTRY_READ_TIMEOUT_SECONDS, registry: dict[str, Any] | None = None, + source_goal_observer: Callable[[str, dict[str, Any] | None], None] | None = None, ) -> list[tuple[Path, Path, str, str | None]]: if registry is None: registry = load_registry(registry_path) @@ -534,6 +535,10 @@ def _source_routes_for_registry( else registry_path.resolve() ) if source_registry is None: + if source_goal_observer is not None: + matches = [item for item in registry_goals(registry) + if str(item.get("id") or "") == current_goal_id] + source_goal_observer(current_goal_id, matches[0] if len(matches) == 1 else None) continue source_key = str(source_registry) if source_key not in source_reads: @@ -542,6 +547,10 @@ def _source_routes_for_registry( timeout_seconds=source_registry_read_timeout_seconds, ) source_payload, source_error = source_reads[source_key] + if source_goal_observer is not None: + matches = [item for item in registry_goals(source_payload) + if str(item.get("id") or "") == current_goal_id] if source_payload is not None else [] + source_goal_observer(current_goal_id, matches[0] if len(matches) == 1 else None) if source_payload is None: source_runtime = runtime_root else: @@ -608,6 +617,7 @@ def collect_runtime_projection_route_diagnostics( activation_state_filter: GoalActivationState | str | None = None, source_registry_read_timeout_seconds: float = SOURCE_REGISTRY_READ_TIMEOUT_SECONDS, registry: dict[str, Any] | None = None, + source_goal_observer: Callable[[str, dict[str, Any] | None], None] | None = None, ) -> dict[str, Any]: items: list[dict[str, Any]] = [] source_routes = _source_routes_for_registry( @@ -617,6 +627,7 @@ def collect_runtime_projection_route_diagnostics( activation_state_filter=activation_state_filter, source_registry_read_timeout_seconds=source_registry_read_timeout_seconds, registry=registry, + source_goal_observer=source_goal_observer, ) if registry is None: registry = load_registry(registry_path) diff --git a/loopx/control_plane/status/collection.py b/loopx/control_plane/status/collection.py index da2bfe5240..4da820055d 100644 --- a/loopx/control_plane/status/collection.py +++ b/loopx/control_plane/status/collection.py @@ -81,6 +81,7 @@ def collect_status( include_public_boundary_scan: bool = True, recent_run_limit: int | None = None, include_goal_subagent_configuration: bool = False, + include_zcode_goal_eligibility: bool = False, activation_state_filter: GoalActivationState | str | None = None, agent_lane_id: str | None = None, ) -> dict[str, Any]: @@ -177,13 +178,31 @@ def collect_status( runtime_root_override=str(runtime_root), registry=registry, ) + zcode_eligibility: dict[str, list[str]] = {} + source_goal_observer: Callable[[str, dict[str, Any] | None], None] | None = None + if include_zcode_goal_eligibility: + from ...zcode_goal_mode.bridge import zcode_goal_eligible_agent_ids + + def observe_source_goal(current_goal_id: str, source_goal: dict[str, Any] | None) -> None: + zcode_eligibility[current_goal_id] = ( + zcode_goal_eligible_agent_ids(source_goal) if source_goal is not None else [] + ) + + source_goal_observer = observe_source_goal runtime_projection_routes = collect_runtime_projection_route_diagnostics( registry_path=registry_path, runtime_root=runtime_root, goal_id=goal_filter, activation_state_filter=activation_filter, registry=registry, + source_goal_observer=source_goal_observer, ) + if include_zcode_goal_eligibility: + for row in runtime_summaries["run_history"]["goals"]: + row["zcode_goal_eligible_agent_ids"] = ( + zcode_eligibility.get(str(row.get("id") or ""), []) + if row.get("registry_member") is True else [] + ) routes_read_at = now_utc_iso() runtime_projection_route_health = { "healthy": ( diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 73f7ceefb5..ebaac99606 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -173,33 +173,29 @@ class CliOutputCommandClassification: semantic_json_keys=("route", "turn_envelope", "effects", "boundary"), markdown_anchor="# LoopX Turn Plan", max_chars={ - "small": {"json": 12_500, "markdown": 300}, - # Required vision carries the validator's complete authoring schema, - # executable registry-bound commands and the overflow diagnostic. - # The same fixed-path base/head fixture emits 16,250 chars, - # or 16,679 with Agent vision. 17,000 leaves at least 321 while retaining - # the temporal, evidence and reasonable-next-step obligations. - # Keep the line, per-Todo and fixed semantic-growth guards below. - "crowded": {"json": 17_000, "markdown": 600}, - "multi_agent": {"json": 14_500, "markdown": 600}, + "small": {"json": 14_000, "markdown": 600}, + # Pinned main/head costs match on each platform. Windows emits + # 13,467 / 335 small, 14,982 / 375 multi-Agent and 17,371 / 444 + # crowded with vision. Keep the full schema, routed commands, + # temporal/evidence obligations and independent growth guards. + "crowded": {"json": 18_000, "markdown": 600}, + "multi_agent": {"json": 15_500, "markdown": 600}, }, max_lines={ - "small": {"json": 320, "markdown": 12}, - # Complete Agent vision state renders in 427 lines; 440 leaves 13. + "small": {"json": 350, "markdown": 12}, + # Windows complete Agent vision state renders in 444 lines. # Characters and per-Todo growth remain independent cost guards. - "crowded": {"json": 440, "markdown": 12}, - "multi_agent": {"json": 370, "markdown": 12}, + "crowded": {"json": 460, "markdown": 12}, + "multi_agent": {"json": 390, "markdown": 12}, }, scale_axis="todo_count", max_json_growth_chars_per_unit=60, # The complete validator-owned vision-authoring schema appears only on # the required-vision route. Account for that fixed semantic packet # separately so it does not relax the per-Todo growth budget. The - # complete schema and guidance produce crowded-minus-small = 6,544 - # chars on both current main and the restored head. Keep 35*60 = 2,100 - # as the per-Todo allowance; 4,700 leaves 256 fixed chars of headroom. - # The historical 4,200 + 2,100 ceiling was 244 short without any - # candidate output growth. This is a regression budget, not authority. + # matched current main/head grows 4,198 Linux / 3,475 Windows chars. + # Keep 35*60 = 2,100 plus 4,700 fixed characters independently of + # platform presentation ceilings. This budget does not grant authority. max_json_fixed_semantic_growth_chars=4_700, ), CliOutputBudgetSpec( @@ -481,8 +477,8 @@ class CliOutputCommandClassification: "boundary", ), markdown_anchor=None, - max_chars={"json": 14_000}, - max_lines={"json": 360}, + max_chars={"json": 15_000}, + max_lines={"json": 380}, ), CliOutputModeVariantSpec( variant_id="loopx_turn_run_once_preview", @@ -535,7 +531,7 @@ class CliOutputCommandClassification: output_formats=("json", "markdown"), semantic_json_keys=("task_body", "quota_guard_command", "interface_budget"), markdown_anchor="# Heartbeat Automation Prompt", - max_chars={"json": 13_000, "markdown": 11_500}, + max_chars={"json": 13_500, "markdown": 11_500}, max_lines={"json": 58, "markdown": 155}, ), CliOutputModeVariantSpec( diff --git a/loopx/doctor.py b/loopx/doctor.py index 9a6f1d4b64..8b02d8ade6 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone +from functools import partial from importlib.metadata import PackageNotFoundError, distribution import json import os @@ -718,7 +719,15 @@ def collect_doctor( installation_only: bool = False, registry_path: Path | None = None, runtime_root_override: str | None = None, + zcode_cli: str | None = None, + zcode_desktop: str | None = None, + zcode_source: str | None = None, ) -> dict[str, Any]: + if any(value is not None for value in (zcode_cli, zcode_desktop, zcode_source)): + from .host_loop_activation import normalize_agent_type + + if installation_only or not agent_type or normalize_agent_type(agent_type) != "zcode": + raise ValueError("ZCode paths require --agent-type zcode and host integration scope") if installation_only: from .release_candidate import collect_installation_doctor @@ -790,9 +799,29 @@ def collect_doctor( comparison_source["label"] = "loopx-canary" path_entries = os.environ.get("PATH", "").split(os.pathsep) local_bin = user_local_bin() - skill_roots = codex_skill_roots() - skills = installed_skill_summary(skill_roots) - project_skill = skills["loopx-project"] + zcode_skill_repair_command = None + skill_roots: tuple[Path, ...] + if canonical_agent_type == "zcode": + from .slash_command_install import inspect_skill_facades + from .zcode_goal_mode import zcode_home + + selected_zcode_home = zcode_home() + skill_roots = (selected_zcode_home / "skills",) + skills = inspect_skill_facades(skill_roots[0]) + project_skill = skills["loopx"] + home_arg = ( + _powershell_literal(selected_zcode_home) + if os.name == "nt" + else shlex.quote(str(selected_zcode_home)) + ) + zcode_skill_repair_command = ( + "loopx slash-commands --install --surface zcode " + f"--zcode-home {home_arg}" + ) + else: + skill_roots = codex_skill_roots() + skills = installed_skill_summary(skill_roots) + project_skill = skills["loopx-project"] skill_path = Path(str(project_skill["path"])) project_scoped_skill_ids = discover_project_scoped_skill_ids( repo_root / "skills" @@ -862,7 +891,8 @@ def collect_doctor( latest_promotion_readiness_event(selected_runtime_root) ), } - install_freshness = build_install_freshness( + freshness_projection = partial( + build_install_freshness, command_path=command_path, release_root=release_root, repo_root=repo_root, @@ -870,10 +900,27 @@ def collect_doctor( release_manifest=release_manifest, comparison_source=comparison_source, freshness_source=freshness_source, - require_installed_skills=installed_skills_required, doctor_agent_type=canonical_agent_type, python_distribution=python_distribution, + now=datetime.now(timezone.utc), ) + install_freshness = freshness_projection(require_installed_skills=installed_skills_required) + zcode_installation_requires_upgrade = False + if zcode_skill_repair_command: + install_freshness["skill_repair_command"] = zcode_skill_repair_command + # Reuse the installation owner without Skill admission: the aggregate + # classification reports only its first problem and can hide another repair. + zcode_installation_requires_upgrade = bool( + freshness_projection(require_installed_skills=False)["requires_upgrade"] + ) + if command_path is not None and not all( + skill.get("required_phrases") for skill in skills.values() + ): + surface_repair = zcode_skill_repair_command + "\nloopx doctor --agent-type zcode" + install_freshness["upgrade_command"] = ( + install_freshness["upgrade_command"] + "\n" + surface_repair + if zcode_installation_requires_upgrade else surface_repair + ) externally_managed_skills = bool( install_freshness.get("externally_managed_skills") ) @@ -910,10 +957,15 @@ def collect_doctor( ), "mode": "surface_managed" if installed_skills_required else "host_managed", "codex_skills_root_applicable": installed_skills_required - and not external_skill_delivery, + and not external_skill_delivery + and canonical_agent_type != "zcode", "installed_skills_required_for_freshness": installed_skills_required, "skill_roots": [str(root) for root in skill_roots], "status": skill_delivery_status, + **( + {"repair_command": zcode_skill_repair_command} + if zcode_skill_repair_command else {} + ), **( {"filesystem_readback": host_skill_install_readback} if host_skill_install_readback @@ -1128,7 +1180,7 @@ def collect_doctor( }) if deep_validation: checks.extend(deep_validation["checks"]) - payload = { + payload: dict[str, Any] = { "ok": all(check["ok"] for check in checks if check["required"]), "mode": "deep" if deep else "standard", "service_runtime_identity": release_runtime_identity(), @@ -1218,6 +1270,24 @@ def collect_doctor( ) ), } + if canonical_agent_type == "zcode": + from .zcode_goal_mode.diagnostics import collect_zcode_host_diagnostics + + payload["zcode"] = collect_zcode_host_diagnostics( + cli_path=zcode_cli, desktop_path=zcode_desktop, source_root=zcode_source, + ) + skill_fix = ( + f"Run `{zcode_skill_repair_command}` and then `loopx doctor --agent-type zcode` " + "with the same ZCode home. User-owned files are preserved; resolve any reported " + "name collision before installing. Filesystem checks do not verify runtime skill loading." + ) + if ( + payload["ok"] and command_path is not None + and not zcode_installation_requires_upgrade + ): + payload["fix"] = skill_fix + else: + payload["fix"] += "\nAfter restoring the LoopX installation/runtime, " + skill_fix if deep_validation: payload["release_candidate"] = deep_validation return payload @@ -1389,6 +1459,24 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: recommended_action = typescript_control_plane.get("recommended_action") if recommended_action: lines.append(f"- recommended_action: {recommended_action}") + if payload.get("agent_type") == "zcode": + lines.extend(["", "## ZCode Skill Delivery"]) + for name, skill in sorted((payload.get("skills") or {}).items()): + lines.append( + f"- {name}: `{skill.get('readback_status')}` — {skill.get('reason')} " + f"(`{skill.get('path')}`)" + ) + repair_command = (payload.get("skill_delivery") or {}).get("repair_command") + if repair_command: + lines.extend([ + "", "Refresh managed facades (user files are preserved):", + "```", str(repair_command), "```", + ]) + zcode = payload.get("zcode") + if isinstance(zcode, dict): + from .zcode_goal_mode.diagnostics import render_zcode_diagnostics_markdown + + lines.extend(render_zcode_diagnostics_markdown(zcode)) restart = payload.get("effect_runtime_restart") if isinstance(restart, dict): previous = restart.get("previous_runtime_identity") diff --git a/loopx/extensions/process_runtime.py b/loopx/extensions/process_runtime.py index f47cb52865..bfdf151a9f 100644 --- a/loopx/extensions/process_runtime.py +++ b/loopx/extensions/process_runtime.py @@ -14,6 +14,7 @@ _PROCESS_IO_CHUNK_BYTES = 64 * 1024 _PROCESS_TERMINATE_GRACE_SECONDS = 1.0 +_PROCESS_GROUP_STOP_TIMEOUT_SECONDS = 1.0 @dataclass(frozen=True) @@ -31,25 +32,59 @@ def _wait_for_process(process: subprocess.Popen[bytes], timeout: float) -> bool: return True +def _posix_owned_group_has_exited(process_group_id: int, timeout: float) -> bool: + snapshot = subprocess.run( + ["ps", "-A", "-o", "pgid=", "-o", "stat="], + capture_output=True, text=True, encoding="utf-8", check=False, + timeout=timeout, + ) + if snapshot.returncode != 0 or not snapshot.stdout.strip(): + raise RuntimeError("owned POSIX process-group observation failed") + live = False + for line in snapshot.stdout.splitlines(): + if not line.strip(): + continue + fields = line.split() + if len(fields) != 2 or not fields[0].isdecimal(): + raise RuntimeError("invalid owned POSIX process-group observation") + # Zombies cannot execute. Stopped and unknown states remain live. + if int(fields[0]) == process_group_id and not fields[1].startswith("Z"): + live = True + return not live + + +def _wait_for_posix_process_group_stop(process_group_id: int) -> None: + deadline = time.monotonic() + _PROCESS_GROUP_STOP_TIMEOUT_SECONDS + while True: + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return + except PermissionError: + # Darwin can report EPERM for a dead, unreaped group. Require + # observation rather than accepting a sent signal as cleanup. + pass + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("owned POSIX process group did not stop before cleanup deadline") + try: + exited = _posix_owned_group_has_exited(process_group_id, remaining) + except (OSError, subprocess.TimeoutExpired, UnicodeError) as error: + raise RuntimeError("owned POSIX process-group observation failed") from error + if exited: + return + time.sleep(min(.01, max(0, deadline - time.monotonic()))) + + def _darwin_owned_group_has_exited(process: subprocess.Popen[bytes]) -> bool: # Darwin can report EPERM rather than ESRCH for a now-empty process group. # A reaped leader alone does not prove its descendants have exited. if sys.platform != "darwin" or process.poll() is None: return False try: - snapshot = subprocess.run( - ["/bin/ps", "-axo", "pgid="], capture_output=True, text=True, - encoding="utf-8", check=False, timeout=1, - ) - except (OSError, subprocess.TimeoutExpired, UnicodeError): + return _posix_owned_group_has_exited(process.pid, 1) + except (OSError, subprocess.TimeoutExpired, UnicodeError, RuntimeError): return False - groups = snapshot.stdout.split() - return ( - snapshot.returncode == 0 - and bool(groups) - and all(group.isdecimal() for group in groups) - and str(process.pid) not in groups - ) def _terminate_posix_process_group( @@ -78,9 +113,14 @@ def _terminate_posix_process_group( except PermissionError: if not _darwin_owned_group_has_exited(process): raise + process.wait() + return if process.poll() is None: process.kill() process.wait() + # KILL delivery is asynchronous; reaping only the leader does not prove + # descendants stopped writing. Observe absence or an all-zombie group. + _wait_for_posix_process_group_stop(process_group_id) def _terminate_windows_process_tree( @@ -112,7 +152,10 @@ def terminate_process_tree( POSIX callers must launch with ``start_new_session=True``. Zero grace sends one force-kill signal, not TERM followed by KILL against an exiting group. - This is OS transport only; callers own deadlines and failure decisions. + After KILL, POSIX cleanup confirms absence or only zombie members within a + one-second observation budget. Unknown/failed observation raises rather than + certifying cleanup. This is OS transport only; callers own execution deadlines + and failure decisions. """ if os.name == "posix": _terminate_posix_process_group(process, grace_seconds) diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 87706d15f8..5e71612b99 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -3,10 +3,11 @@ from typing import Any import shlex +from .zcode_goal_mode import native_goal_activation from .agent_registry import normalize_registered_agents from .agy_goal_mode import AGY_ACCEPTED_INPUTS from .control_plane.scheduler.execution_context import SchedulerRuntimeProfile -from .host_loop_activation_skill_facade import ( +from .hosts.skill_facade import ( agy_cli_activation, cursor_agent_activation, gemini_cli_activation, @@ -1298,6 +1299,11 @@ def build_host_loop_activation_packet( surface = cursor_agent_activation(commands, cli_bin) elif canonical == "zcode": surface = zcode_activation(commands, cli_bin) + surface["native_goal_provider"] = native_goal_activation( + cli_bin=cli_bin, runtime_root=runtime_root, goal_id=goal_id, + agent_id=selected_agent_id, + activation_allowed=activation_allowed and selected_agent_id in identity["registered_agents"], + ) elif canonical == "agy": surface = agy_cli_activation(commands, cli_bin) elif canonical == "kiro-cli": diff --git a/loopx/hosts/__init__.py b/loopx/hosts/__init__.py new file mode 100644 index 0000000000..4cd5d7b926 --- /dev/null +++ b/loopx/hosts/__init__.py @@ -0,0 +1 @@ +"""Internal host activation projections and adapters.""" diff --git a/loopx/host_loop_activation_skill_facade.py b/loopx/hosts/skill_facade.py similarity index 93% rename from loopx/host_loop_activation_skill_facade.py rename to loopx/hosts/skill_facade.py index 0335cd6304..7033ecd262 100644 --- a/loopx/host_loop_activation_skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -16,13 +16,13 @@ from typing import Any -from .agy_goal_mode import agy_activation_extras -from .kiro_cli_goal_mode import ( +from ..agy_goal_mode import agy_activation_extras +from ..kiro_cli_goal_mode import ( KIRO_CLI_INSTALL_SURFACE, SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, kiro_cli_activation_extras, ) -from .zcode_goal_mode import ( +from ..zcode_goal_mode import ( SKILLS_ROOT_LABEL as ZCODE_SKILLS_ROOT_LABEL, ZCODE_INSTALL_SURFACE, ) @@ -136,9 +136,10 @@ def zcode_activation(commands: dict[str, str], cli_bin: str) -> dict[str, Any]: skills_root=ZCODE_SKILLS_ROOT_LABEL, extra_host_mutation={ "missing_host_tool_gate": ( - "LoopX is currently integrated with ZCode via skill facade and " - "has no direct machine binding for ZCode native Goal Mode or " - "Automations. If the session cannot keep entering through quota " + "The default ZCode entry remains the LoopX skill facade. Explicit " + "zcode-goal bind selects a separate managed native CLI session; " + "it does not attach this conversation or enable Automations. " + "If the skill session cannot keep entering through quota " "should-run, show the exact heartbeat-prompt command for the user " "to run and do not claim autonomous heartbeat support." ), diff --git a/loopx/presentation/chat_bundle.py b/loopx/presentation/chat_bundle.py index a6c11f6f1a..950d83a845 100644 --- a/loopx/presentation/chat_bundle.py +++ b/loopx/presentation/chat_bundle.py @@ -24,6 +24,8 @@ "apps/presentation/dashboard/package-lock.json", "apps/presentation/dashboard/vite.chat.config.ts", "apps/presentation/dashboard/tsconfig.json", + "loopx/zcode_goal_mode/contract.ts", + "loopx/zcode_goal_mode/contract.json", ) BUILD_HELP = "Run npm ci and npm run build:chat in apps/presentation/dashboard (or reinstall a complete LoopX package)." diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index efdea5719a..b5ab09355e 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -495,7 +495,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 1009, + "line": 1011, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -503,7 +503,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 536, + "line": 538, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -511,7 +511,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1588, + "line": 1594, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1697, + "line": 1703, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -535,7 +535,7 @@ }, { "site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#2", - "line": 179, + "line": 180, "column": 21, "kind": "codec_read", "api": "load_registry", @@ -559,7 +559,7 @@ }, { "site": "loopx/cli.py::.main::codec_read:load_project_registry#1", - "line": 817, + "line": 822, "column": 17, "kind": "codec_read", "api": "load_project_registry", @@ -1631,7 +1631,7 @@ }, { "site": "loopx/control_plane/runtime/runtime_projection_route.py::._read_source_registry_with_deadline.read::codec_read:load_registry#1", - "line": 578, + "line": 587, "column": 23, "kind": "codec_read", "api": "load_registry", @@ -1639,7 +1639,7 @@ }, { "site": "loopx/control_plane/runtime/runtime_projection_route.py::._source_routes_for_registry::codec_read:load_registry#1", - "line": 514, + "line": 515, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1647,7 +1647,7 @@ }, { "site": "loopx/control_plane/runtime/runtime_projection_route.py::.collect_runtime_projection_route_diagnostics::codec_read:load_registry#1", - "line": 622, + "line": 633, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -1687,7 +1687,7 @@ }, { "site": "loopx/control_plane/status/collection.py::.collect_status::codec_read:load_registry#1", - "line": 99, + "line": 100, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -2340,6 +2340,14 @@ "kind": "codec_read", "api": "load_registry", "classification": "codec_api" + }, + { + "site": "loopx/zcode_goal_mode/bridge.py::.validate_zcode_binding::codec_read:load_registry#1", + "line": 67, + "column": 16, + "kind": "codec_read", + "api": "load_registry", + "classification": "codec_api" } ] } diff --git a/loopx/slash_command_files.py b/loopx/slash_command_files.py index 9245dbe7f0..1cc27d74ae 100644 --- a/loopx/slash_command_files.py +++ b/loopx/slash_command_files.py @@ -92,6 +92,19 @@ def skill_body( ) + "\n" +def skill_facade_content(spec: CommandFacadeSpec) -> str: + """Render the shared command facade installed in a host's skill root.""" + return skill_body( + command=str(spec["command"]), + title=f"LoopX {spec['command']}", + description=str(spec["description"]), + argument_hint=str(spec["argument_hint"]), + instructions=list(spec["instructions"]), + surface="claude-skills", + front_matter_name=str(spec["name"]), + ) + + def _is_legacy_upgradable_loopx_file(existing: str) -> bool: return any(signature in existing for signature in LEGACY_UPGRADABLE_SIGNATURES) @@ -186,15 +199,7 @@ def install_skill_facade( } ) continue - content = skill_body( - command=str(spec["command"]), - title=f"LoopX {spec['command']}", - description=str(spec["description"]), - argument_hint=str(spec["argument_hint"]), - instructions=list(spec["instructions"]), - surface="claude-skills", - front_matter_name=str(spec["name"]), - ) + content = skill_facade_content(spec) installed.append( { "surface": surface, diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 797310026e..df289c4931 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -3,9 +3,11 @@ import contextlib import json import os +import re import sys import tempfile from collections.abc import Callable +from enum import Enum from pathlib import Path from typing import Any @@ -26,12 +28,14 @@ ) from .slash_command_files import ( CommandFacadeSpec, + MANAGED_MARKER_PREFIX, front_matter as _front_matter, install_skill_facade as _install_skill_facade, managed_marker as _managed_marker, retire_managed_file as _retire_managed_file, retire_status as _retire_status, skill_body as _skill_body, + skill_facade_content, target_status as _target_status, ) from .skill_install_readback import retire_duplicate_managed_skills @@ -309,6 +313,94 @@ def _command_skill_content(spec: CommandFacadeSpec, *, surface: str) -> str: ) +class SkillFacadeReadbackStatus(str, Enum): + """Local filesystem diagnostics; these do not classify host readiness.""" + + READY = "ready" + MISSING = "missing" + STALE = "stale" + USER_OWNED = "user_owned" + UNREADABLE = "unreadable" + + +_MAX_SKILL_FACADE_READ_BYTES = 1024 * 1024 + + +def inspect_skill_facades(skills_dir: Path) -> dict[str, dict[str, Any]]: + """Read the canonical facades without changing files or running a host. + + Compare the installer's current rendering, allowing its existing cli_bin + parameter to vary consistently. No independent phrase list defines freshness. + """ + cli_placeholder = "__LOOPX_FACADE_CLI_PARAMETER__" + summaries: dict[str, dict[str, Any]] = {} + for spec in _command_prompt_specs(cli_bin=cli_placeholder, include_legacy_aliases=False): + name = str(spec["name"]) + path = skills_dir / name / "SKILL.md" + status = SkillFacadeReadbackStatus.MISSING + reason = "The managed command facade is missing; refresh this host's surface." + exists = False + configured_cli_bin = None + try: + with path.open("rb") as handle: + content = handle.read(_MAX_SKILL_FACADE_READ_BYTES + 1) + exists = True + if len(content) > _MAX_SKILL_FACADE_READ_BYTES: + status = SkillFacadeReadbackStatus.UNREADABLE + reason = ( + "The command facade exceeds the 1 MiB diagnostic read limit; " + "reduce its size or resolve the command-name collision. The file is preserved." + ) + text = None + else: + text = content.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n") + if text is None: + pass + elif MANAGED_MARKER_PREFIX not in text: + status = SkillFacadeReadbackStatus.USER_OWNED + reason = ( + "A user-owned file occupies this command; the installer preserves it. " + "Resolve the name collision before installing the managed facade." + ) + else: + template = skill_facade_content(spec) + parts = template.split(cli_placeholder) + pattern = re.escape(parts[0]) + for index, part in enumerate(parts[1:]): + pattern += ( + r"(?P[^\r\n]+?)" if index == 0 else r"(?P=cli_bin)" + ) + re.escape(part) + match = re.fullmatch(pattern, text) + if match is not None: + status = SkillFacadeReadbackStatus.READY + configured_cli_bin = match.groupdict().get("cli_bin") + reason = "The file matches the current managed facade; runtime skill loading is unverified." + else: + status = SkillFacadeReadbackStatus.STALE + reason = "The managed facade differs from the current installer; refresh this host's surface." + except FileNotFoundError: + pass + except (OSError, UnicodeError): + exists = True + status = SkillFacadeReadbackStatus.UNREADABLE + reason = "The command facade cannot be read as UTF-8; check its file type and read access." + ready = status is SkillFacadeReadbackStatus.READY + summaries[name] = { + "path": str(path), + "candidate_paths": [str(path)] if exists else [], + "route_count": int(exists), + "route_conflict": False, + "source_root": str(skills_dir) if exists else None, + "managed_externally": False, + "exists": exists, + "required_phrases": ready, + "readback_status": status.value, + "reason": reason, + "cli_bin": configured_cli_bin, + } + return summaries + + def materialize_loopx_entry_skill( *, skills_dir: Path, diff --git a/loopx/status.py b/loopx/status.py index f8a65d4893..33e41cabc9 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -1358,6 +1358,7 @@ def collect_status( include_public_boundary_scan: bool = True, recent_run_limit: int | None = None, include_goal_subagent_configuration: bool = False, + include_zcode_goal_eligibility: bool = False, activation_state_filter: str | None = None, agent_lane_id: str | None = None, ) -> dict[str, Any]: @@ -1375,6 +1376,7 @@ def collect_status( include_goal_subagent_configuration=( include_goal_subagent_configuration ), + include_zcode_goal_eligibility=include_zcode_goal_eligibility, activation_state_filter=activation_state_filter, agent_lane_id=agent_lane_id, context=build_status_collection_context(), diff --git a/loopx/zcode_goal_mode/README.md b/loopx/zcode_goal_mode/README.md index 8532dc906b..ffd536bf1a 100644 --- a/loopx/zcode_goal_mode/README.md +++ b/loopx/zcode_goal_mode/README.md @@ -1,47 +1,172 @@ -# ZCode goal mode +# ZCode host integration -LoopX adapter for [ZCode](https://zcode.z.ai/) — a terminal coding agent -supporting [skills](https://zcode.z.ai/en/docs/skill), [Goal Mode](https://zcode.z.ai/en/docs/goal), -and [Automations](https://zcode.z.ai/en/docs/automations). +LoopX has two explicit ZCode entry points: the existing `$loopx` skill facade +and an opt-in provider for one managed CLI native Goal. Native execution is off +until the user binds and starts it. Selecting a host or installing skills does +not start a process, run a model, or enable Automations. -## What this surface is +## Existing skill entry -ZCode discovers user skills from `~/.zcode/skills//SKILL.md`. -While ZCode provides native Goal Mode and Automations, LoopX currently -integrates through the managed `$loopx` skill facade. In this mode, the loop -driver is the agent's own turn loop gated by LoopX quota — every continuation -enters through `quota should-run`, and a stop decision ends the session loop. +```bash +loopx slash-commands --install --surface zcode +``` + +The installer refreshes marked LoopX files in `ZCODE_HOME/skills` (default +`~/.zcode/skills`, with the legacy `ZCODE_AGENTS_HOME` fallback). It preserves +user-owned files. Refresh Settings → Skills in ZCode and invoke `$loopx` or +`/loopx ` in a connected project. The default activation still runs +`start-goal --guided --project . --host-surface zcode`; the agent carries the +canonical heartbeat task and checks `quota should-run` on each continuation. -Direct machine binding to ZCode native Goal Mode or Automations is not yet -integrated and will be supported through dedicated provider contracts in the -future. +## Managed native CLI Goal -## Install +Use an existing active LoopX Goal, its canonical project, and an Agent already +registered to that Goal. This provider reads existing authority; it does not +register an Agent or invent a Goal instance. Node.js must satisfy LoopX's +existing TypeScript runtime requirement. Supply an installed CLI executable or +an existing ZCode JS bundle if `zcode` is absent from PATH. On Windows, select +the JS bundle instead of a `.cmd`/`.bat` shim or Desktop executable. ```bash -loopx slash-commands --install --surface zcode +loopx --format json zcode-goal bind --goal-id GOAL --agent-id AGENT --zcode-cli /path/to/zcode.cjs +loopx --format json zcode-goal status --goal-id GOAL --agent-id AGENT ``` -Writes the managed LoopX skill facades (`loopx`, `loopx-global-*`, …) into -`ZCODE_HOME/skills` (default `~/.zcode/skills`; override with `ZCODE_HOME`). -Managed files carry the `loopx-managed-slash-command` marker and are refreshed by -rerunning the installer; user-owned files are never overwritten. +Binding verifies the actual app-server protocol, creates an idle native session +and persists it through a native pause receipt before reading model availability. +It does not start a native Goal or a model request. An existing ZCode +model default is retained. If no model is selected, choose one from the +readback's `native.available_models`; disabled models cannot be selected: + +```bash +loopx --format json zcode-goal select-model --goal-id GOAL --agent-id AGENT --provider-id PROVIDER --model-id MODEL +``` + +When that model advertises reasoning levels, supply `--reasoning-level LEVEL` +using an advertised level. Selection belongs to this managed native session; +LoopX does not configure provider credentials or infer that a listed model is +usable. A model request can still fail. Then explicitly operate the Goal: + +```bash +loopx --format json zcode-goal start --goal-id GOAL --agent-id AGENT +loopx --format json zcode-goal pause --goal-id GOAL --agent-id AGENT +loopx --format json zcode-goal resume --goal-id GOAL --agent-id AGENT +loopx --format json zcode-goal stop --goal-id GOAL --agent-id AGENT +``` + +The existing Goal detail drawer includes **ZCode native Goal**. Choose a +registered Agent, bind its CLI, select a model when necessary, then use the +same start/status/pause/resume/stop controls. The frontend and CLI share the +provider's action and readback contract. HTTP operations require the existing +local loopback and origin checks. Mutations assert the Goal reference and +creation witness from the last readback before launching provider effects; a stale panel must refresh +after Goal replacement. This provider is not a remote control API. + +The binding journals the LoopX Goal reference, registered Agent, canonical +project, native session and native target. Existing instance identifiers remain +exact. Legacy Goal aliases retain compatibility and their existing creation +witness; `identity_scope` distinguishes their weaker lifetime boundary. If the legacy +registry has no creation witness, identical alias deletion/recreation is not +detectable as a new lifetime. Stop before rebuilding such a Goal; this provider +does not mint a substitute instance identity. Lifecycle-only +`source_session_v1` registry profiles remain unavailable for this runtime, +as required by Core. Replacement identity or changed authority rejects work. + +Start uses the current canonical heartbeat task. Pause confirms cancellation +has drained; resume retains the same session and target. Repeated start cannot +replace an executing target. Cold recovery restores that session, pauses an +active target and never automatically resumes. The managed broker serializes +operations and owns the app-server process tree; a guardian closes that tree +if the broker dies. Its session database is isolated from other CLI/Desktop +sessions. A lost start receipt can be recovered only from a durable admitted +intent with the same canonical objective hash. -After installation, refresh or read back installed skills in ZCode via Settings → Skills. +### Goal controls -## Use +These illustrations use synthetic UI fixtures. They show the controls and +unavailable states, not real execution or billing evidence. -From a ZCode session in a connected project, invoke the `$loopx` skill (or type -`/loopx `). The facade instructs the agent to run: +When quota denies continuation, the panel shows a paused target and disables +start/resume while retaining stop and status readback: + +![Synthetic desktop quota denial](images/native-quota-desktop.png) + +On mobile, a native execution failure stays visible alongside status readback. +A disconnected observation remains unknown; refresh before retrying: + +![Synthetic mobile native execution error](images/native-error-mobile.png) + +### Quota and authority boundary + +Start and resume call Core `quota should-run`. During execution, serial checks +run approximately every two seconds, with a bounded authority/quota subprocess +timeout. Denial or unavailable authority pauses the owned target; an +unconfirmed pause closes the owned host. This is admission plus revocation, +not a per-model-call, per-token or native-round hard budget. Native background +model failures are paused and reported with a safe error reason. Native usage +is unknown here, and native completion does not settle a LoopX Goal, debit +credits, or certify acceptance. Each explicit execution has a one-hour safety deadline; +paused idle controllers close after five minutes and can be restored explicitly. + +The managed host denies interactive permission requests and disables automatic +question resolution. Native execution grants no new tool, shell, filesystem, +credential, scheduler or settlement authority. This phase does not attach the +current terminal/Desktop conversation and does not install MCP, Hooks, Desktop +plugins or Automations. + +### Stop, disable and recover + +`pause` retains the target for explicit resume. `stop` confirms pause, clears +the native target and closes the managed process; it preserves the session +history and binding for a later explicit start. Read status before retrying an +operation whose response was lost. A disconnected readback does not claim the +native process is running. Cleanup remains available for the same registered +identity after the LoopX Goal is stopped; execution does not. + +To change the selected CLI, stop first, then repeat `bind --zcode-cli ...`. +The old owner must finish cleanup before a new native session is created. +Leaving this provider disabled requires no configuration switch: stop it and +do not start/resume it. Uninstalling skill files is a separate operation: ```bash -loopx start-goal --guided --project . --slash-command-arguments="" --host-surface zcode +loopx slash-commands --uninstall --surface zcode ``` -After todo writeback, carry the generated heartbeat task body as the session -objective and start every following turn with `quota should-run`. +This removes only installer-owned skills. It does not stop an already bound +native Goal or delete the user's ZCode configuration, credentials or sessions. + +## Host diagnostics + +```bash +loopx doctor --agent-type zcode +loopx doctor --agent-type zcode --zcode-cli /path/to/zcode.cjs +loopx doctor --agent-type zcode --zcode-desktop /path/to/ZCode +loopx doctor --agent-type zcode --zcode-source /path/to/ZCode-checkout +``` + +Doctor separately observes PATH CLI, installed Desktop/bundled CLI and an +explicit source checkout. Root package and existing runnable dist versions are +separate. Its isolated probes use version/help only: they do not handshake, +execute models, verify authentication, attach Desktop or exercise Automations. +Read `skill_delivery.status` and host observations even when overall required +installation/runtime checks return success. Repair marked skill files with +`slash-commands --install --surface zcode`; use the same `--cli-bin` for a +custom LoopX executable and resolve user-owned conflicts explicitly. + +## Ownership and validation -## Layout +This is a bounded S4/S5/S7/S8/S12 host provider, not a new capability or a second +Goal/quota decision owner. TypeScript owns provider session state and effects +(`contract.ts`, `runtime.ts`, `cli.ts`, `app-server.ts`, `guard.ts`). Python +`bridge.py` and `api.py` adapt existing Core identity/quota and local Chat/CLI +entry points. The local action vocabulary in `contract.json` is loaded by +both transport runtimes; Python does not fork the provider action set. The +existing skill activation exposes explicit native commands without changing +default skill behavior. -- `__init__.py` — host facts: install surface id, skills root resolution, and - the env override used by the installer and the activation packet. +Focused validation covers quota denial/revocation, stale/replacement identity, +negative protocol/CAS/permission cases, durable lost-receipt recovery, process +ownership, model selection and frontend readback. Real CLI qualification uses +an isolated database and local model substitute, with real Core and packaged +frontend entry points. It does not establish live provider billing, Desktop +attachment, multi-Agent collaboration or general unattended qualification. diff --git a/loopx/zcode_goal_mode/__init__.py b/loopx/zcode_goal_mode/__init__.py index 3a2eec16b3..12fb487600 100644 --- a/loopx/zcode_goal_mode/__init__.py +++ b/loopx/zcode_goal_mode/__init__.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +import shlex +from typing import Any from pathlib import Path ZCODE_INSTALL_SURFACE = "zcode" @@ -13,9 +15,8 @@ def zcode_home(value: str | None = None) -> Path: """ZCode discovers user skills from ZCODE_HOME/skills (default ~/.zcode). - While ZCode provides native Goal Mode and Automations, LoopX currently - reaches ZCode through its skill facade where the session turn loop is - gated by LoopX quota should-run. + The default skill entry gates the session turn loop by quota. A separate + native CLI binding is an explicit opt-in and does not change this root. """ raw = ( value @@ -24,3 +25,24 @@ def zcode_home(value: str | None = None) -> Path: or str(Path.home() / DEFAULT_ZCODE_HOME) ) return Path(raw).expanduser() + + +def native_goal_activation( + *, cli_bin: str, runtime_root: str | None, goal_id: str, + agent_id: str | None, activation_allowed: bool, +) -> dict[str, Any]: + """Discover the optional provider; generating commands performs no effects.""" + prefix = [cli_bin] + if runtime_root: + prefix.extend(["--runtime-root", runtime_root]) + commands = { + action: shlex.join([*prefix, "--format", "json", "zcode-goal", action, + "--goal-id", goal_id, "--agent-id", str(agent_id)]) + for action in ("bind", "start", "pause", "resume", "stop", "status") + } if activation_allowed else {} + return { + "default_off": True, "execution_mode": "managed_runtime", "host_surface": "zcode_cli", + "commands": commands, "requires_explicit_host_selection": True, + "quota_boundary": "Admission before start/resume and serial revocation checks during execution; no per-model-call token limit.", + "session_boundary": "One managed app-server session. This does not attach the current Desktop or terminal conversation.", + } diff --git a/loopx/zcode_goal_mode/api.py b/loopx/zcode_goal_mode/api.py new file mode 100644 index 0000000000..6aeb2ad49a --- /dev/null +++ b/loopx/zcode_goal_mode/api.py @@ -0,0 +1,78 @@ +"""Loopback Chat projection for the explicitly bound ZCode CLI provider.""" +from __future__ import annotations + +from typing import Any +from urllib.parse import unquote, urlparse + +from ..chat import redact_local_paths +from ..status_server import is_loopback_host +from .bridge import ZCodeGoalBridgeError, zcode_goal_operation + + +def public_zcode_goal_readback(payload: dict[str, Any]) -> dict[str, Any]: + result = {key: payload[key] for key in ("ok", "available", "goal_id", "goal_ref", "goal_creation_operation_id", "identity_scope", "agent_id", "actions") if key in payload} + if "reason" in payload: + result["reason"] = redact_local_paths(str(payload["reason"])) + binding = payload.get("binding") + result["binding"] = {key: binding[key] for key in ("mode", "connected", "cli_path", "protocol") if key in binding} if isinstance(binding, dict) else None + native = payload.get("native") + result["native"] = {key: native[key] for key in ("session_id", "target_id", "status", "raw_status", "session_status", "objective_sha256", "running", "usage", "selected_model", "available_models") if key in native} if isinstance(native, dict) else None + quota = payload.get("quota") + result["quota"] = {key: quota[key] for key in ("should_run", "checked_at") if key in quota} if isinstance(quota, dict) else None + if isinstance(quota, dict) and "reason" in quota: + result["quota"]["reason"] = redact_local_paths(str(quota["reason"])) + return result + + +class ZCodeGoalRequestMixin: + server: Any + path: str + + def _read_json(self) -> dict[str, Any]: + raise NotImplementedError + + def _require_loopback_origin(self) -> bool: + raise NotImplementedError + + def _send_error(self, message: str, **kwargs: Any) -> None: + raise NotImplementedError + + def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: + raise NotImplementedError + + def _dispatch_zcode_goal(self, path: str, *, apply: bool = False) -> bool: + parts = path.strip("/").split("/") + if len(parts) != 6 or parts[:2] != ["api", "goals"] or parts[3] != "agents" or parts[5] != "zcode-goal": + return False + if not is_loopback_host(str(self.server.server_address[0])): + self._send_error("Managed ZCode operations require a loopback server.", status=403, error_code="zcode_goal_loopback_required") + return True + if not self._require_loopback_origin(): + return True + try: + if urlparse(self.path).query: + raise ValueError("ZCode Goal routes do not accept query parameters.") + goal_id, agent_id = unquote(parts[2]), unquote(parts[4]) + body = self._read_json() if apply else {} + if body is None: + return True + if set(body) - {"action", "cli_path", "model_selection", "expected_binding"}: + raise ValueError("ZCode Goal accepts action, expected_binding, optional cli_path and model_selection.") + action = body.get("action") if apply else "status" + if not isinstance(action, str): + raise ValueError("ZCode Goal action must be a string.") + if "cli_path" in body and (not isinstance(body["cli_path"], str) or not body["cli_path"].strip() or len(body["cli_path"]) > 4096): + raise ValueError("cli_path must be a nonempty string of at most 4096 characters.") + if "model_selection" in body and (action != "select_model" or not isinstance(body["model_selection"], dict)): + raise ValueError("model_selection is a selection object accepted only by select_model.") + if apply and not isinstance(body.get("expected_binding"), dict): + raise ValueError("POST requires expected_binding from the current Goal readback.") + payload = zcode_goal_operation( + action=action, registry_path=self.server.registry_path, goal_id=goal_id, agent_id=agent_id, + cli_path=body.get("cli_path"), runtime_root=self.server.runtime_root_override, model_selection=body.get("model_selection"), expected_binding=body.get("expected_binding"), + ) + except (ValueError, OSError) as exc: + self._send_error(redact_local_paths(str(exc)), status=exc.status if isinstance(exc, ZCodeGoalBridgeError) else 400, error_code=getattr(exc, "code", "invalid_zcode_goal_request")) + else: + self._send_json(public_zcode_goal_readback(payload)) + return True diff --git a/loopx/zcode_goal_mode/app-server.ts b/loopx/zcode_goal_mode/app-server.ts new file mode 100644 index 0000000000..692bda2275 --- /dev/null +++ b/loopx/zcode_goal_mode/app-server.ts @@ -0,0 +1,273 @@ +/** Provider transport for ZCode's legacy NDJSON session/goal protocol. */ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { StringDecoder } from "node:string_decoder"; + +import { ZCodeGoalError, type NativeObservation, type ZCodeModelSelection, type ZCodeModelOption } from "./contract.ts"; +export type { ZCodeModelSelection, ZCodeModelOption } from "./contract.ts"; +export interface NativeGoalReadback { + session_id: string; + target_id: string | null; + objective_sha256: string | null; + selected_model: ZCodeModelSelection | null; + available_models: ZCodeModelOption[]; + status: NativeObservation["status"]; + raw_status: string | null; + session_status: string; + running: boolean; + revision: number; +} +export interface NativeGoalReceipt extends NativeGoalReadback { started_turn: boolean } +export interface ZCodeAppServerOptions { + timeoutMs?: number; + onExit?: (exit: { code: number | null; expected: boolean }) => void; + /** Notifications contain only method/session identity, never model output or private logs. */ + onEvent?: (event: { method: string; session_id?: string }) => void; +} +export class ZCodeProtocolError extends ZCodeGoalError { + readonly code: string; + readonly protocolCode?: number; + constructor(code: string, protocolCode?: number) { + super(`ZCode app-server ${code}`); + this.name = "ZCodeProtocolError"; + this.code = code; + this.protocolCode = protocolCode; + } +} +type JsonObject = Record; +function object(value: unknown): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new ZCodeProtocolError("invalid_response"); + return value as JsonObject; +} +function identity(value: unknown): string { + if (typeof value !== "string" || !value.trim()) throw new ZCodeProtocolError("invalid_identity"); + return value; +} +const MAX_FRAME_BYTES = 4 * 1024 * 1024; + +export class ZCodeAppServer { + readonly command: readonly string[]; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + readonly options: ZCodeAppServerOptions; + private process?: ChildProcessWithoutNullStreams; + private closed = false; + private failure?: ZCodeProtocolError; + private buffer = ""; + private pending = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + private targets = new Map(); + private models = new Map(); + constructor(command: readonly string[], cwd: string, env: NodeJS.ProcessEnv, options: ZCodeAppServerOptions = {}) { + if (!command.length || command.some((part) => typeof part !== "string" || !part)) throw new ZCodeProtocolError("invalid_command"); + this.command = [...command]; this.cwd = resolve(cwd); this.env = { ...env }; this.options = options; + } + private fail(error: ZCodeProtocolError): void { + this.failure ??= error; + for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(error); } + this.pending.clear(); + void this.terminate(); + } + private start(): void { + if (this.process) return; + if (this.closed || this.failure) throw this.failure ?? new ZCodeProtocolError("closed"); + const process = spawn(this.command[0], this.command.slice(1), { cwd: this.cwd, env: this.env, stdio: "pipe", windowsHide: true, detached: globalThis.process.platform !== "win32", shell: false }); + this.process = process; + const decoder = new StringDecoder("utf8"); + process.stdout.on("data", (data: Buffer) => this.receive(decoder.write(data))); + process.stdout.on("end", () => { this.receive(decoder.end()); if (this.buffer.trim()) this.fail(new ZCodeProtocolError("incomplete_frame")); }); + // Drain and discard diagnostics: upstream error text may contain credentials or prompts. + process.stderr.on("data", () => {}); + process.on("error", () => this.fail(new ZCodeProtocolError("spawn_failed"))); + process.on("exit", (code) => { + const expected = this.closed; + if (!expected) this.fail(new ZCodeProtocolError("process_exited")); + this.options.onExit?.({ code, expected }); + }); + process.stdin.on("error", () => { if (!this.closed) this.fail(new ZCodeProtocolError("input_closed")); }); + } + private receive(chunk: string): void { + if (this.failure || this.closed) return; + this.buffer += chunk; + if (Buffer.byteLength(this.buffer) > MAX_FRAME_BYTES && !this.buffer.includes("\n")) { this.fail(new ZCodeProtocolError("frame_limit")); return; } + let newline: number; + while ((newline = this.buffer.indexOf("\n")) !== -1) { + const line = this.buffer.slice(0, newline).replace(/\r$/, ""); this.buffer = this.buffer.slice(newline + 1); + if (!line) continue; + if (Buffer.byteLength(line) > MAX_FRAME_BYTES) { this.fail(new ZCodeProtocolError("frame_limit")); return; } + try { this.receiveFrame(object(JSON.parse(line))); } catch { this.fail(new ZCodeProtocolError("invalid_frame")); return; } + } + } + private receiveFrame(frame: JsonObject): void { + if (frame.jsonrpc !== undefined) throw new ZCodeProtocolError("unexpected_framing"); + const hasId = typeof frame.id === "string" || typeof frame.id === "number"; + if (typeof frame.method === "string") { + if (hasId) { + const result = frame.method === "interaction/requestPermission" + ? { id: frame.id, result: { decision: "deny", reason: "LoopX native host requires explicit permission approval." } } + : frame.method === "session/requestRuntimePreferences" ? { id: frame.id, result: { memoryEnabled: false, nativeSearchEnhancementsEnabled: false, askUserQuestionAutoResolutionEnabled: false } } + : { id: frame.id, error: { code: -32601, message: "Host interaction unavailable." } }; + this.process?.stdin.write(`${JSON.stringify(result)}\n`); + } else { + const params = frame.params && typeof frame.params === "object" ? frame.params as JsonObject : {}; + this.options.onEvent?.({ method: frame.method, ...(typeof params.sessionId === "string" ? { session_id: params.sessionId } : {}) }); + } + return; + } + if (!hasId || (Object.hasOwn(frame, "result") === Object.hasOwn(frame, "error"))) throw new ZCodeProtocolError("invalid_response"); + const request = this.pending.get(String(frame.id)); + if (!request) throw new ZCodeProtocolError("unexpected_response"); + this.pending.delete(String(frame.id)); clearTimeout(request.timer); + if (frame.error !== undefined) { const error = object(frame.error); request.reject(new ZCodeProtocolError("request_rejected", typeof error.code === "number" ? error.code : undefined)); } + else request.resolve(frame.result); + } + private request(method: string, params: JsonObject = {}): Promise { + this.start(); + if (this.closed || this.failure) return Promise.reject(this.failure ?? new ZCodeProtocolError("closed")); + const id = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => this.fail(new ZCodeProtocolError("request_timeout")), this.options.timeoutMs ?? 15_000); + this.pending.set(id, { resolve, reject, timer }); + this.process!.stdin.write(`${JSON.stringify({ id, method, params })}\n`); + }); + } + async initialize(): Promise<{ protocol: "zcode-ndjson-session-goal"; capabilities: { independentPlanState: boolean } }> { + const result = object(await this.request("runtime/capabilities")); + if (typeof result.independentPlanState !== "boolean") throw new ZCodeProtocolError("unsupported_capabilities"); + return { protocol: "zcode-ndjson-session-goal", capabilities: { independentPlanState: result.independentPlanState } }; + } + private selection(value: unknown): ZCodeModelSelection { + const ref = object(value); + const options = ref.options == null ? undefined : object(ref.options); + return { providerId: identity(ref.providerId), modelId: identity(ref.modelId), ...(options?.reasoningLevel ? { options: { reasoningLevel: identity(options.reasoningLevel) } } : {}) }; + } + private readSnapshot(value: unknown, expectedSession?: string, fullCatalogue = false): NativeGoalReadback { + const snapshot = object(value), protocol = object(snapshot.protocol), session = object(snapshot.session), projection = object(snapshot.projection), runtime = object(snapshot.runtime); + if (protocol.name !== "ZCode Protocol" || protocol.version !== 1) throw new ZCodeProtocolError("unsupported_protocol"); + const sessionId = identity(session.sessionId); + if (expectedSession && sessionId !== expectedSession) throw new ZCodeProtocolError("session_identity_mismatch"); + if (!Number.isSafeInteger(runtime.stateRevision) || (runtime.stateRevision as number) < 0) throw new ZCodeProtocolError("invalid_revision"); + const model = object(object(snapshot.settings).model); + if (!Array.isArray(model.available)) throw new ZCodeProtocolError("invalid_models"); + const incoming = model.available.map((value): ZCodeModelOption => { + const candidate = object(value), reasoning = candidate.reasoning == null ? null : object(candidate.reasoning); + if (reasoning && !Array.isArray(reasoning.levels)) throw new ZCodeProtocolError("invalid_models"); + return { selection: this.selection(candidate.ref), label: identity(candidate.label), ...(typeof candidate.providerLabel === "string" ? { provider_label: candidate.providerLabel } : {}), reasoning_levels: reasoning ? (reasoning.levels as unknown[]).map((level) => identity(object(level).value)) : [], default_reasoning_level: reasoning?.defaultLevel == null ? null : identity(reasoning.defaultLevel), disabled: typeof candidate.disabledReason === "string" }; + }); + if (fullCatalogue) this.models.set(sessionId, incoming); + const availableModels = this.models.get(sessionId) ?? incoming; + const selectedModel = model.current == null ? null : this.selection(model.current); + const target = projection.target == null ? null : object(projection.target); + const targetId = target ? identity(target.targetId) : null; + if (target && target.sessionId !== sessionId) throw new ZCodeProtocolError("target_identity_mismatch"); + const rawStatus = target ? identity(target.status) : null; + if (rawStatus !== null && !["active", "paused", "budget_limited", "complete"].includes(rawStatus)) throw new ZCodeProtocolError("unsupported_goal_status"); + const sessionStatus = identity(projection.status); + if (!["idle", "running", "waiting", "paused", "completed", "error"].includes(sessionStatus)) throw new ZCodeProtocolError("unsupported_session_status"); + return { session_id: sessionId, target_id: targetId, selected_model: selectedModel, available_models: availableModels.map((model) => ({ ...model, selection: { ...model.selection }, reasoning_levels: [...model.reasoning_levels] })), objective_sha256: target ? createHash("sha256").update(identity(target.objective)).digest("hex") : null, status: rawStatus === "complete" ? "completed" : rawStatus as NativeGoalReadback["status"], raw_status: rawStatus, session_status: sessionStatus, running: sessionStatus === "running" || sessionStatus === "waiting", revision: runtime.stateRevision as number }; + } + async create(model?: ZCodeModelSelection): Promise { + const readback = this.readSnapshot(await this.request("session/create", { workspace: { workspacePath: this.cwd, workspaceKey: this.cwd }, mode: "build", titleGenerationEnabled: false, ...(model ? { model, ...(model.options?.reasoningLevel ? { thoughtLevel: model.options.reasoningLevel } : {}) } : {}) }), undefined, true); + if (readback.target_id || readback.running) throw new ZCodeProtocolError("unexpected_session_goal"); + this.targets.set(readback.session_id, null); + // Even persistence=immediate leaves an unused native session in memory only. + // Empty Goal pause persists its metadata without starting a Goal or model. + const persisted = await this.pauseGoal(readback.session_id); + if (persisted.target_id || persisted.running) throw new ZCodeProtocolError("empty_session_not_quiescent"); + return persisted; + } + async resumeSession(sessionId: string): Promise { + const readback = this.readSnapshot(await this.request("session/resume", { sessionId, workspace: { workspacePath: this.cwd, workspaceKey: this.cwd } }), sessionId, true); + this.targets.set(sessionId, readback.target_id); return readback; + } + async readGoal(sessionId: string): Promise { + return this.readSnapshot(await this.request("session/read", { sessionId, messageLimit: 1 }), sessionId); + } + async selectModel(sessionId: string, selection: ZCodeModelSelection): Promise { + const before = await this.readGoal(sessionId); + if (!this.targets.has(sessionId) || this.targets.get(sessionId) !== before.target_id) throw new ZCodeProtocolError("goal_identity_changed"); + if (before.running || before.status === "active") throw new ZCodeProtocolError("model_change_requires_pause"); + const candidate = before.available_models.find((model) => model.selection.providerId === selection.providerId && model.selection.modelId === selection.modelId); + if (!candidate || candidate.disabled) throw new ZCodeProtocolError("model_unavailable"); + const reasoning = selection.options?.reasoningLevel; + if ((candidate.reasoning_levels.length && !reasoning) || (reasoning && !candidate.reasoning_levels.includes(reasoning))) throw new ZCodeProtocolError("reasoning_unavailable"); + const after = this.readSnapshot(await this.request("session/setModel", { sessionId, model: selection, expectedRevision: before.revision, persistAsWorkspaceLastUsed: false }), sessionId); + if (after.target_id !== before.target_id) throw new ZCodeProtocolError("goal_identity_changed"); + if (JSON.stringify(after.selected_model) !== JSON.stringify(this.selection(selection))) throw new ZCodeProtocolError("model_selection_not_applied"); + return after; + } + private async mutate(sessionId: string, action: string, objective?: string): Promise { + const before = await this.readGoal(sessionId); + if (!this.targets.has(sessionId) || this.targets.get(sessionId) !== before.target_id) throw new ZCodeProtocolError("goal_identity_changed"); + const result = object(await this.request("session/goal", { sessionId, action, expectedRevision: before.revision, ...(objective === undefined ? {} : { objective }) })); + if ((action === "pause" || action === "clear") && result.startedTurn !== false) throw new ZCodeProtocolError("unexpected_execution"); + const after = this.readSnapshot(result.snapshot, sessionId); + if (action !== "set" && action !== "clear" && after.target_id !== before.target_id) throw new ZCodeProtocolError("goal_identity_changed"); + if (action === "set" && (!after.target_id || after.status !== "active" || result.startedTurn !== true)) throw new ZCodeProtocolError("goal_not_started"); + if (action === "resume" && (!after.target_id || after.status !== "active" || result.startedTurn !== true)) throw new ZCodeProtocolError("goal_not_started"); + if (action === "clear" && after.target_id !== null) throw new ZCodeProtocolError("goal_not_cleared"); + if (action === "set" && after.objective_sha256 !== createHash("sha256").update(objective!.trim()).digest("hex")) throw new ZCodeProtocolError("objective_not_applied"); + this.targets.set(sessionId, after.target_id); + return { ...after, started_turn: result.startedTurn === true }; + } + async setGoal(sessionId: string, objective: string): Promise { + if (!objective.trim()) throw new ZCodeProtocolError("empty_objective"); + return await this.mutate(sessionId, "set", objective); + } + async resumeGoal(sessionId: string): Promise { return await this.mutate(sessionId, "resume"); } + async pauseGoal(sessionId: string): Promise { + const receipt = await this.mutate(sessionId, "pause"); + const deadline = Date.now() + (this.options.timeoutMs ?? 15_000); + let current: NativeGoalReadback = receipt; + if (!current.target_id && current.running) throw new ZCodeProtocolError("goal_missing"); + while (current.target_id && (current.status !== "paused" || current.running)) { + if (Date.now() >= deadline) throw new ZCodeProtocolError("pause_readback_timeout"); + await new Promise((resolve) => setTimeout(resolve, 30)); + current = await this.readGoal(sessionId); + if (current.target_id !== receipt.target_id) throw new ZCodeProtocolError("goal_identity_changed"); + } + return current; + } + async stopGoal(sessionId: string): Promise { return await this.pauseGoal(sessionId); } + async clearGoal(sessionId: string): Promise { + await this.pauseGoal(sessionId); + return await this.mutate(sessionId, "clear"); + } + private async terminate(): Promise { + if (this.process) await terminateOwnedProcess(this.process); + } + async close(): Promise { + if (this.closed) return; + this.closed = true; + for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new ZCodeProtocolError("closed")); } + this.pending.clear(); + await this.terminate(); + } +} + + + + + + + + +/** Terminate only a directly spawned child and its owned process tree. */ +export async function terminateOwnedProcess(child: ChildProcessWithoutNullStreams): Promise { + if (!child.pid || child.exitCode !== null || child.signalCode !== null) return; + if (globalThis.process.platform === "win32") { + await new Promise((done) => { + const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); + const timer = setTimeout(() => { killer.kill(); child.kill(); done(); }, 2_000); + killer.once("error", () => { clearTimeout(timer); child.kill(); done(); }); + killer.once("exit", () => { clearTimeout(timer); child.kill(); done(); }); + }); + } else { + try { globalThis.process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } + } + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((done, reject) => { + const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new ZCodeProtocolError("process_cleanup_unconfirmed")); }, 2_000); + child.once("exit", () => { clearTimeout(timer); done(); }); + }); +} diff --git a/loopx/zcode_goal_mode/bridge.py b/loopx/zcode_goal_mode/bridge.py new file mode 100644 index 0000000000..4ce56fef3d --- /dev/null +++ b/loopx/zcode_goal_mode/bridge.py @@ -0,0 +1,278 @@ +"""ZCode transport and exact Goal/Agent admission; provider decisions live in TS.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +from typing import Any + +from ..agent_registry import agent_profile_for_goal, registered_agent_ids_for_goal, require_registered_agent_id +from ..control_plane.goals.activation import goal_is_stopped +from ..control_plane.goals.goal_ref_validation import exact_goal_ref, require_goal_id +from ..control_plane.runtime.goal_project_route import resolve_goal_project_route +from ..control_plane.projects.registry_codec import load_registry +from ..host_loop_activation import normalize_agent_type +from ..registry import registry_goals + +# The bundled provider contract is the single action vocabulary for Python and TS. +ZCODE_GOAL_ACTIONS: tuple[str, ...] = tuple( + json.loads(Path(__file__).with_name("contract.json").read_text(encoding="utf-8"))["actions"] +) +MAX_TRANSPORT_BYTES = 1_048_576 +_UNSET = object() + + +class ZCodeGoalBridgeError(ValueError): + def __init__(self, message: str, *, code: str = "invalid_zcode_goal_request", status: int = 400): + super().__init__(message) + self.code = code + self.status = status + + +def _zcode_host_compatible(profile: dict[str, Any] | None) -> bool: + declared_host = (profile or {}).get("agent_type") or (profile or {}).get("host_surface") + # An advisory profile need not declare a host. Binding remains an explicit choice. + if declared_host is None: + return True + if not isinstance(declared_host, str): + return False + try: + return normalize_agent_type(declared_host) == "zcode" + except ValueError: + return False + + +def zcode_goal_eligible_agent_ids(goal: dict[str, Any]) -> list[str]: + """Project registered actors compatible with this host, without probing it.""" + return [agent_id for agent_id in registered_agent_ids_for_goal(goal) + if _zcode_host_compatible(agent_profile_for_goal(goal, agent_id))] + + +def validate_zcode_binding( + *, registry_path: Path, goal_id: str, agent_id: str, + project: str | Path | None = None, goal_ref: dict[str, str] | None = None, + require_active: bool = True, goal_creation_operation_id: object = _UNSET, +) -> dict[str, Any]: + """Read existing authority only; never create an instance or register an agent.""" + require_goal_id(goal_id) + _, canonical_project, route = resolve_goal_project_route( + registry_path=registry_path, goal_id=goal_id, project_override=project, + ) + source_registry = Path(route["source_registry"]).resolve() + registry = load_registry(source_registry) + matches = [item for item in registry_goals(registry) if item.get("id") == goal_id] + if len(matches) != 1: + raise ZCodeGoalBridgeError("Goal must be registered exactly once.", code="zcode_goal_authority_changed", status=409) + goal = matches[0] + if require_active and (goal_is_stopped(goal) or goal.get("status", "active") != "active"): + raise ZCodeGoalBridgeError("The LoopX Goal is no longer active.", code="zcode_goal_authority_changed", status=409) + # Generic Goal routing already rejects lifecycle-only source-session registries. + # Existing exact identities remain exact; first-party legacy aliases stay compatible. + instance_id = goal.get("goal_instance_id") + if instance_id is not None: + if not isinstance(instance_id, str): + raise ZCodeGoalBridgeError("Goal instance identifier is invalid.") + current_ref = exact_goal_ref(goal_id, instance_id) + identity_scope = "exact_goal_instance" + else: + current_ref = {"goal_id": goal_id} + identity_scope = "legacy_goal_alias" + if goal_ref is not None and goal_ref != current_ref: + raise ZCodeGoalBridgeError("Goal instance changed; inspect the current Goal before binding again.", code="zcode_goal_authority_changed", status=409) + creation_id = goal.get("creation_operation_id") + if creation_id is not None and (not isinstance(creation_id, str) or not creation_id): + raise ZCodeGoalBridgeError("Goal creation witness is invalid.") + if goal_creation_operation_id is not _UNSET and goal_creation_operation_id != creation_id: + raise ZCodeGoalBridgeError("Goal creation witness changed; inspect the current Goal before binding again.", code="zcode_goal_authority_changed", status=409) + normalized_agent = require_registered_agent_id( + registry_path=source_registry, goal_id=goal_id, agent_id=agent_id, field="agent_id", + ) + profile = agent_profile_for_goal(goal, normalized_agent) + if not _zcode_host_compatible(profile): + raise ZCodeGoalBridgeError("The registered Agent explicitly declares a different host.") + if not canonical_project.is_dir(): + raise ZCodeGoalBridgeError("The canonical Goal project is unavailable.") + return { + "ok": True, "goal_id": goal_id, "goal_ref": current_ref, "agent_id": normalized_agent, + "project": str(canonical_project), "registry": str(source_registry), + "runtime_root": str(route["source_runtime_root"]), + "identity_scope": identity_scope, "goal_creation_operation_id": creation_id, + } + + +def _node_command() -> str: + from ..control_plane.effect_runtime import _node_executable, EffectRuntimeStartupError + try: + return _node_executable() + except EffectRuntimeStartupError as exc: + raise ZCodeGoalBridgeError(str(exc), code="zcode_goal_runtime_unavailable", status=503) from exc + + +def _cli_command(cli_path: str | None, node: str) -> list[str]: + requested = cli_path if cli_path is not None else "zcode" + if not isinstance(requested, str) or not requested.strip() or len(requested) > 4096: + raise ZCodeGoalBridgeError("ZCode CLI path must be a nonempty string of at most 4096 characters.") + requested = requested.strip() + resolved = shutil.which(requested) or str(Path(requested).expanduser().resolve()) + path = Path(resolved) + if not path.is_file(): + raise ZCodeGoalBridgeError("ZCode CLI is unavailable. Supply its executable or JS bundle when binding.", code="zcode_cli_unavailable", status=503) + if (path.parent / "resources/glm/zcode.cjs").is_file() or (path.parent / "resources/app.asar").is_file(): + raise ZCodeGoalBridgeError("Select ZCode CLI; a Desktop executable cannot be bound to the managed CLI provider.") + if path.suffix.lower() in {".cmd", ".bat"}: + raise ZCodeGoalBridgeError("Windows shell shims cannot be used by the managed stdio transport. Supply the installed ZCode JS bundle for bind.") + if path.suffix.lower() in {".js", ".cjs", ".mjs"}: + return [node, str(path.resolve()), "app-server"] + return [str(path.resolve()), "app-server"] + + +def _run_json(command: list[str], *, project: str, request: dict[str, Any] | None = None) -> dict[str, Any]: + environment = {**os.environ, "PYTHONUTF8": "1", "PYTHONDONTWRITEBYTECODE": "1"} + try: + completed = subprocess.run( + command, input=json.dumps(request, ensure_ascii=False) if request is not None else None, + cwd=project, env=environment, capture_output=True, text=True, + encoding="utf-8", errors="strict", timeout=45, check=False, + ) + except (OSError, UnicodeError, subprocess.TimeoutExpired) as exc: + raise ZCodeGoalBridgeError("ZCode provider did not return a bounded response. Read status before retrying an operation.", code="zcode_goal_transport_unavailable", status=503) from exc + if len(completed.stdout.encode("utf-8")) > MAX_TRANSPORT_BYTES: + raise ZCodeGoalBridgeError("ZCode provider response exceeded the transport limit.", code="zcode_goal_invalid_readback", status=503) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise ZCodeGoalBridgeError("ZCode provider did not return a JSON readback.", code="zcode_goal_invalid_readback", status=503) from exc + if not isinstance(payload, dict) or not isinstance(payload.get("ok"), bool): + raise ZCodeGoalBridgeError("ZCode provider returned an invalid readback.", code="zcode_goal_invalid_readback", status=503) + if completed.returncode and payload.get("ok") is True: + raise ZCodeGoalBridgeError("ZCode provider failed despite a successful readback.", code="zcode_goal_invalid_readback", status=503) + return payload + + +def _heartbeat_task(binding: dict[str, Any], loopx_command: list[str]) -> str: + payload = _run_json([ + *loopx_command, "--registry", binding["registry"], "--format", "json", + "heartbeat-prompt", "--goal-id", binding["goal_id"], "--agent-id", binding["agent_id"], + "--runtime-profile", "generic_cli", "--thin", + ], project=binding["project"]) + body = payload.get("task_body") + if payload.get("ok") is not True or not isinstance(body, str) or not body.strip(): + raise ZCodeGoalBridgeError("Canonical heartbeat instructions are unavailable. Repair the Goal state before starting ZCode.") + return body + + +def zcode_goal_operation( + *, action: str, registry_path: Path, goal_id: str, agent_id: str, + project: str | Path | None = None, cli_path: str | None = None, + runtime_root: str | Path | None = None, model_selection: dict[str, Any] | None = None, + expected_binding: dict[str, Any] | None = None, +) -> dict[str, Any]: + if action not in ZCODE_GOAL_ACTIONS: + raise ZCodeGoalBridgeError("Unsupported ZCode Goal operation.") + if model_selection is not None: + if action != "select_model": + raise ZCodeGoalBridgeError("Model selection is supported only by select_model.") + if not isinstance(model_selection, dict) or set(model_selection) - {"providerId", "modelId", "options"}: + raise ZCodeGoalBridgeError("Model selection accepts providerId, modelId and optional reasoning options.") + if any(not isinstance(model_selection.get(key), str) or not model_selection[key].strip() or len(model_selection[key]) > 256 for key in ("providerId", "modelId")): + raise ZCodeGoalBridgeError("Model selection requires bounded providerId and modelId strings.") + options = model_selection.get("options", {}) + if not isinstance(options, dict) or set(options) - {"reasoningLevel"} or ("reasoningLevel" in options and (not isinstance(options["reasoningLevel"], str) or not options["reasoningLevel"].strip() or len(options["reasoningLevel"]) > 128)): + raise ZCodeGoalBridgeError("Model selection options accept only a bounded reasoningLevel.") + elif action == "select_model": + raise ZCodeGoalBridgeError("select_model requires an explicit model_selection.") + if cli_path is not None and action != "bind": + raise ZCodeGoalBridgeError("ZCode CLI selection is supported only by an explicit bind operation.") + expected_ref = None + expected_creation: object = _UNSET + if expected_binding is not None: + if not isinstance(expected_binding, dict) or set(expected_binding) != {"goal_ref", "goal_creation_operation_id"}: + raise ZCodeGoalBridgeError("expected_binding requires goal_ref and goal_creation_operation_id.") + expected_ref = expected_binding["goal_ref"] + if not isinstance(expected_ref, dict) or set(expected_ref) not in ({"goal_id"}, {"goal_id", "goal_instance_id"}): + raise ZCodeGoalBridgeError("expected_binding requires an exact supported Goal reference shape.") + if not isinstance(expected_ref.get("goal_id"), str): + raise ZCodeGoalBridgeError("Expected Goal id must be a string.") + require_goal_id(expected_ref["goal_id"]) + if "goal_instance_id" in expected_ref: + if not isinstance(expected_ref["goal_instance_id"], str): + raise ZCodeGoalBridgeError("Expected Goal instance must be a string.") + exact_goal_ref(expected_ref["goal_id"], expected_ref["goal_instance_id"]) + expected_creation = expected_binding["goal_creation_operation_id"] + if expected_creation is not None and (not isinstance(expected_creation, str) or not expected_creation or len(expected_creation) > 256): + raise ZCodeGoalBridgeError("Expected creation witness must be a bounded string or null.") + require_active = action not in {"status", "pause", "stop"} + binding = validate_zcode_binding( + registry_path=registry_path, goal_id=goal_id, agent_id=agent_id, project=project, + require_active=require_active, goal_ref=expected_ref, goal_creation_operation_id=expected_creation, + ) + node = _node_command() + loopx_command = [sys.executable, "-m", "loopx.cli"] + state_root = Path(runtime_root).expanduser().resolve() if runtime_root is not None else Path(binding["runtime_root"]) + state_key = hashlib.sha256(json.dumps( + [binding["registry"], binding["goal_ref"], binding["goal_creation_operation_id"], binding["agent_id"]], sort_keys=True, + ).encode("utf-8")).hexdigest() + request: dict[str, Any] = { + "action": action, **{key: binding[key] for key in ("project", "registry", "goal_id", "goal_ref", "goal_creation_operation_id", "identity_scope", "agent_id")}, + "state_path": str(state_root / "zcode-goal" / f"{state_key}.json"), + "loopx_command": loopx_command, + "validation_command": [sys.executable, "-m", "loopx.zcode_goal_mode.bridge", "--validate-binding"], + } + if model_selection is not None: + request["model_selection"] = model_selection + if action == "bind": + request["cli_command"] = _cli_command(cli_path, node) + request["cli_path"] = request["cli_command"][-2] + if action in {"bind", "start"}: + request["task_body"] = _heartbeat_task(binding, loopx_command) + payload = _run_json([node, "--experimental-strip-types", str(Path(__file__).with_name("cli.ts"))], project=binding["project"], request=request) + for key in ("goal_id", "goal_ref", "goal_creation_operation_id", "agent_id"): + if key not in payload or payload[key] != binding[key]: + raise ZCodeGoalBridgeError("ZCode readback did not match the exact Goal and Agent requested.", code="zcode_goal_invalid_readback", status=409) + observed = validate_zcode_binding( + registry_path=registry_path, goal_id=goal_id, agent_id=agent_id, + project=binding["project"], goal_ref=binding["goal_ref"], require_active=require_active, + goal_creation_operation_id=binding["goal_creation_operation_id"], + ) + if observed["registry"] != binding["registry"]: + raise ZCodeGoalBridgeError("Canonical Goal authority changed during the operation.", code="zcode_goal_authority_changed", status=409) + return payload + + +def _validate_main() -> int: + parser = argparse.ArgumentParser(description="Internal read-only ZCode binding admission.") + parser.add_argument("--validate-binding", required=True, action="store_true") + parser.parse_args() + try: + encoded = sys.stdin.buffer.read(MAX_TRANSPORT_BYTES + 1) + if len(encoded) > MAX_TRANSPORT_BYTES: + raise ValueError("binding request exceeded the transport limit") + request = json.loads(encoded) + if not isinstance(request, dict) or not isinstance(request.get("goal_ref"), dict): + raise ValueError("an exact Goal reference is required") + action = request.get("action") + if not isinstance(action, str) or action not in ZCODE_GOAL_ACTIONS: + raise ValueError("an explicit known ZCode operation is required for binding admission") + required = ("registry", "project", "goal_id", "agent_id") + if any(not isinstance(request.get(key), str) or not request[key] for key in required): + raise ValueError("binding identity fields must be nonempty strings") + result = validate_zcode_binding( + registry_path=Path(request["registry"]), project=request["project"], + goal_id=request["goal_id"], agent_id=request["agent_id"], goal_ref=request["goal_ref"], + goal_creation_operation_id=request.get("goal_creation_operation_id", _UNSET), + require_active=action not in {"status", "pause", "stop"}, + ) + payload = {key: result[key] for key in ("ok", "goal_id", "goal_ref", "goal_creation_operation_id", "identity_scope", "agent_id")} + except (ValueError, OSError) as exc: + payload = {"ok": False, "error": str(exc), "error_code": getattr(exc, "code", "invalid_zcode_goal_binding")} + print(json.dumps(payload, ensure_ascii=False)) + return 0 if payload["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(_validate_main()) diff --git a/loopx/zcode_goal_mode/cli.ts b/loopx/zcode_goal_mode/cli.ts new file mode 100644 index 0000000000..9f903f3312 --- /dev/null +++ b/loopx/zcode_goal_mode/cli.ts @@ -0,0 +1,296 @@ +/** Local broker for one managed ZCode app-server. It owns no LoopX work scheduler. */ +import {spawn, execFile} from "node:child_process"; +import {randomBytes} from "node:crypto"; +import {createServer, request as httpRequest} from "node:http"; +import {mkdir, readFile, unlink} from "node:fs/promises"; +import type {JsonObject} from "../control_plane/effect_program.ts"; +import {BARE_SHA256_PATTERN} from "../control_plane/content_digest.ts"; +import {dirname, isAbsolute, join} from "node:path"; +import {fileURLToPath} from "node:url"; +import {ZCODE_GOAL_ACTIONS, sameBinding, ZCodeGoalError, safeFailure, type NativeRequest, type ZCodeGoalReadback} from "./contract.ts"; +import {NativeGoalController, readState, atomicState, type BindingState} from "./runtime.ts"; +import {ZCodeAppServer} from "./app-server.ts"; +import {acquireFileMutationLock, releaseFileMutationLock, durableWriteJson} from "../control_plane/effect_runtime_io.ts"; + +const MAX_INPUT = 64 * 1024; +const MAX_RESPONSE = 1024 * 1024; +const MONITOR_MS = 2000; +const ADMISSION_TIMEOUT_MS = 10000; +type Endpoint = {port: number; token: string; pid: number}; +async function input(stream: NodeJS.ReadableStream, limit = MAX_INPUT): Promise { + let text = ""; + for await (const piece of stream) { + text += piece.toString(); + if (Buffer.byteLength(text) > limit) throw new ZCodeGoalError("ZCode operation exceeds its input limit"); + } + return text; +} +function parseRequest(raw: unknown): NativeRequest { + const r = raw as NativeRequest; + if (!r || !ZCODE_GOAL_ACTIONS.includes(r.action) || !r.goal_ref + || r.goal_ref.goal_id !== r.goal_id || !r.agent_id + || ![r.project, r.registry, r.state_path].every(p => typeof p === "string" && isAbsolute(p)) + || !Array.isArray(r.loopx_command) || !r.loopx_command.length + || !Array.isArray(r.validation_command) || !r.validation_command.length + || (r.cli_command !== undefined && (!Array.isArray(r.cli_command) || !r.cli_command.length)) + || ![...r.loopx_command, ...r.validation_command, ...(r.cli_command ?? [])].every(v => typeof v === "string" && v.length > 0)) { + throw new ZCodeGoalError("Invalid ZCode binding operation"); + } + return r; +} +function paths(request: NativeRequest) { + return {endpoint: request.state_path + ".endpoint", lock: request.state_path + ".owner"}; +} +async function runJSON(command: string[], args: string[], request: NativeRequest, stdin?: unknown): Promise> { + return new Promise((resolve, reject) => { + const child = execFile(command[0], [...command.slice(1), ...args], { + cwd: request.project, windowsHide: true, timeout: ADMISSION_TIMEOUT_MS, + maxBuffer: 1024 * 1024, encoding: "utf8", + env: {...process.env, LOOPX_USAGE_PING: "0"}, + }, (error, stdout) => { + if (error) return reject(new ZCodeGoalError("LoopX authority or quota check failed")); + try { + const value = JSON.parse(stdout); + if (!value || typeof value !== "object" || value.ok === false) throw new ZCodeGoalError(); + resolve(value); + } catch { reject(new ZCodeGoalError("LoopX returned no usable authority or quota response")); } + }); + if (stdin !== undefined) child.stdin?.end(JSON.stringify(stdin)); + else child.stdin?.end(); + }); +} +async function validate(request: NativeRequest, cleanupOnly = false): Promise { + const reply = await runJSON(request.validation_command, [], request, cleanupOnly ? {...request, action: "pause"} : {...request, action: "bind"}); + if (reply.goal_id !== request.goal_id || reply.agent_id !== request.agent_id + || (reply.goal_ref as NativeRequest["goal_ref"])?.goal_id !== request.goal_ref.goal_id + || (reply.goal_ref as NativeRequest["goal_ref"])?.goal_instance_id !== request.goal_ref.goal_instance_id + || reply.goal_creation_operation_id !== request.goal_creation_operation_id) { + throw new ZCodeGoalError("LoopX binding authority changed"); + } +} +async function quota(request: NativeRequest) { + const reply = await runJSON(request.loopx_command, ["--registry", request.registry, "--format", "json", + "quota", "should-run", "--goal-id", request.goal_id, "--agent-id", request.agent_id, + "--runtime-profile", "generic_cli", "--available-capability", "shell", + "--available-capability", "filesystem_write"], request); + if (typeof reply.should_run !== "boolean") throw new ZCodeGoalError("Quota response omitted its admission decision"); + return {should_run: reply.should_run, reason: typeof reply.reason === "string" ? reply.reason : undefined, + checked_at: new Date().toISOString()}; +} +function endpointCall(endpoint: Endpoint, operation: NativeRequest): Promise { + return new Promise((resolve, reject) => { + const encoded = JSON.stringify(operation); + const req = httpRequest({hostname: "127.0.0.1", port: endpoint.port, method: "POST", path: "/operation", + headers: {authorization: "Bearer " + endpoint.token, "content-type": "application/json", + "content-length": Buffer.byteLength(encoded)}, timeout: 40000}, async res => { + try { + const body = await input(res, MAX_RESPONSE); + if (res.statusCode !== 200) throw new ZCodeGoalError("ZCode controller rejected the operation"); + resolve(JSON.parse(body) as ZCodeGoalReadback); + } catch (error) { reject(error); } + }); + req.on("timeout", () => req.destroy(new ZCodeGoalError("ZCode controller timed out"))); + req.on("error", reject); + req.end(encoded); + }); +} +async function readEndpoint(request: NativeRequest): Promise { + try { + const value = JSON.parse(await readFile(paths(request).endpoint, "utf8")) as Endpoint; + if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535 + || typeof value.token !== "string" || !BARE_SHA256_PATTERN.test(value.token) || !Number.isInteger(value.pid)) { + throw new ZCodeGoalError("Malformed ZCode controller endpoint"); + } + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + return true; // Unknown ownership remains held. + } +} +async function claim(request: NativeRequest): Promise<() => Promise> { + const {lock, endpoint} = paths(request); + // Reuse Core's token/inode guarded stale-owner protocol, rather than deleting a PID lock. + const owner = await acquireFileMutationLock(lock, process.pid, 0); + await unlink(endpoint).catch(error => {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}); + return async () => { + await unlink(endpoint).catch(() => {}); + await releaseFileMutationLock(owner.targetPath, owner.token); + }; +} +async function serve(request: NativeRequest): Promise { + const release = await claim(request); + let controller: NativeGoalController | undefined; + let ending = false; + let monitor: ReturnType | undefined; + let idle: ReturnType | undefined; + let executionDeadline: ReturnType | undefined; + const server = createServer(); + const shutdown = async () => { + if (ending) return; + ending = true; + clearTimeout(monitor); clearTimeout(idle); clearTimeout(executionDeadline); + server.close(); + try { await controller?.close(); } finally { await release(); } + }; + try { + const state = await readState(request.state_path); + if (state && !sameBinding(request, state.request)) throw new ZCodeGoalError("Existing ZCode binding belongs to a different Goal instance"); + const selected = state?.request ?? request; + if (!selected.cli_command?.length) throw new ZCodeGoalError("Bind an available ZCode CLI before starting"); + const storage = request.state_path + ".host"; + await mkdir(storage, {recursive: true, mode: 0o700}); + const guardedCommand = [process.execPath, "--no-warnings", "--experimental-strip-types", + fileURLToPath(new URL("./guard.ts", import.meta.url)), "--", ...selected.cli_command]; + const host = new ZCodeAppServer(guardedCommand, selected.project, { + ...process.env, ZCODE_STORAGE_DIR: storage, ZCODE_DATA_BASE_DIR: storage, ZCODE_SESSION_DB_PATH: join(storage, "sessions.db"), + }, {onExit: () => {void shutdown();}}); + controller = new NativeGoalController(request, state, {host, + validate: cleanupOnly => validate(selected, cleanupOnly), quota: () => quota(selected), + persist: value => atomicState(request.state_path, value)}); + await controller.initialize(["pause", "stop"].includes(request.action)); + const token = randomBytes(32).toString("hex"); + const scheduleIdle = () => { + if (controller?.readback().native?.running) {clearTimeout(idle); idle = undefined; return;} + if (!idle) idle = setTimeout(() => {void shutdown();}, 5 * 60 * 1000); + }; + server.on("request", async (req, res) => { + try { + if (req.method !== "POST" || req.url !== "/operation" || req.headers.authorization !== "Bearer " + token + || req.headers.origin !== undefined) { + res.writeHead(403).end(); return; + } + const incoming = parseRequest(JSON.parse(await input(req))); + if (!sameBinding(incoming, selected) + || (incoming.cli_command && JSON.stringify(incoming.cli_command) !== JSON.stringify(selected.cli_command))) { + throw new ZCodeGoalError("ZCode controller binding changed"); + } + clearTimeout(idle); idle = undefined; + const reply = await controller!.operate(incoming.action, incoming.model_selection, incoming.task_body); + if (reply.ok && (incoming.action === "start" || incoming.action === "resume")) { + clearTimeout(executionDeadline); + if (reply.native?.running || reply.native?.status === "active") { + // Native Goal has no hard token budget. Bound the controller's execution lifetime instead. + executionDeadline = setTimeout(async () => { + await controller?.operate("pause"); scheduleIdle(); + }, 60 * 60 * 1000); + } + } + if (reply.ok && (incoming.action === "pause" || incoming.action === "stop")) clearTimeout(executionDeadline); + if (incoming.action === "stop" && reply.ok) { + await shutdown(); + reply.available = false; + if (reply.binding) reply.binding.connected = false; + reply.actions = disconnected(incoming, controller!.state).actions; + } else scheduleIdle(); + res.writeHead(200, {"content-type": "application/json"}).end(JSON.stringify(reply)); + } catch { + res.writeHead(400, {"content-type": "application/json"}).end(JSON.stringify({ok: false, reason: "Invalid or stale ZCode operation"})); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new ZCodeGoalError("ZCode controller failed to bind loopback"); + await durableWriteJson(paths(request).endpoint, {port: address.port, token, pid: process.pid} as JsonObject); + const check = async () => { + if (ending) return; + await controller!.check(); + scheduleIdle(); + if (!controller!.readback().available) {await shutdown(); return;} + monitor = setTimeout(check, MONITOR_MS); // Serial checks; no overlapping or scheduled work turns. + }; + monitor = setTimeout(check, MONITOR_MS); + scheduleIdle(); + process.once("SIGINT", () => {void shutdown();}); + process.once("SIGTERM", () => {void shutdown();}); + } catch (error) { + await shutdown(); + throw error; + } +} +function disconnected(request: NativeRequest, state: BindingState | null): ZCodeGoalReadback { + return {ok: true, available: false, reason: state ? "controller_disconnected" : "binding_missing", + goal_id: request.goal_id, goal_ref: request.goal_ref, agent_id: request.agent_id, + goal_creation_operation_id: request.goal_creation_operation_id ?? null, + identity_scope: request.goal_ref.goal_instance_id ? "exact_goal_instance" : "legacy_goal_alias", + binding: state ? {mode: "managed_cli", connected: false, cli_path: state.request.cli_path ?? "", protocol: "zcode-ndjson-session-goal"} : null, + native: null, quota: null, actions: state ? (state.target_id || state.start_pending ? ["status", "resume", "pause", "stop"] : ["bind", "start", "status"]) : ["bind", "status"]}; +} +async function dispatch(request: NativeRequest): Promise { + let endpoint = await readEndpoint(request); + let state = await readState(request.state_path); + if (state && !sameBinding(request, state.request)) throw new ZCodeGoalError("ZCode binding identity changed"); + const changingCLI = state && request.cli_command + && JSON.stringify(state.request.cli_command) !== JSON.stringify(request.cli_command); + if (changingCLI) { + if (request.action !== "bind") throw new ZCodeGoalError("CLI changes require an explicit bind"); + await validate(request); + if (endpoint && alive(endpoint.pid)) { + const current = await endpointCall(endpoint, {...request, cli_command: undefined, action: "status"}); + if (!current.ok || current.native?.target_id || current.native?.running) { + throw new ZCodeGoalError("Stop the current native Goal before changing the ZCode CLI"); + } + await endpointCall(endpoint, {...request, cli_command: undefined, action: "stop"}); + // Rebinding waits for the old owner to finish native process cleanup. + for (let attempt = 0; alive(endpoint.pid) && attempt < 100; attempt++) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (alive(endpoint.pid)) throw new ZCodeGoalError("The old ZCode controller is still closing; read status and retry bind"); + } + const owner = await acquireFileMutationLock(paths(request).lock, process.pid, 0); + try { + state = await readState(request.state_path); + if (!state || !sameBinding(request, state.request) || state.target_id || state.start_pending) { + throw new ZCodeGoalError("Stop and read back the current native Goal before changing the ZCode CLI"); + } + await unlink(request.state_path); + await unlink(paths(request).endpoint).catch(error => {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;}); + state = null; endpoint = null; + } finally {await releaseFileMutationLock(owner.targetPath, owner.token);} + } + if (endpoint) { + try { return await endpointCall(endpoint, request); } + catch (error) { if (alive(endpoint.pid)) throw error; } + } + if (request.action === "status") return disconnected(request, state); + if (!state && request.action !== "bind") throw new ZCodeGoalError("Explicitly bind the ZCode CLI first"); + if (!state && !request.cli_command) throw new ZCodeGoalError("ZCode CLI was not found"); + await validate(request, ["pause", "stop"].includes(request.action)); + const child = spawn(process.execPath, ["--no-warnings", "--experimental-strip-types", + fileURLToPath(import.meta.url), "--serve"], {cwd: request.project, detached: true, + windowsHide: true, stdio: ["pipe", "ignore", "ignore"], env: process.env}); + child.on("error", () => {}); + child.stdin!.end(JSON.stringify(request)); + child.unref(); + for (let attempt = 0; attempt < 350; attempt++) { + await new Promise(resolve => setTimeout(resolve, 100)); + endpoint = await readEndpoint(request); + if (endpoint) return endpointCall(endpoint, request); + if (child.exitCode !== null) throw new ZCodeGoalError("ZCode controller failed to initialize; check CLI availability and protocol compatibility"); + } + child.kill(); + throw new ZCodeGoalError("ZCode controller did not become ready; read status before retrying"); +} +async function main(): Promise { + const request = parseRequest(JSON.parse(await input(process.stdin))); + if (process.argv.includes("--serve")) {await serve(request); return;} + try {process.stdout.write(JSON.stringify(await dispatch(request)) + "\n");} + catch (error) { + const state = await readState(request.state_path).catch(() => null); + const known = state && sameBinding(request, state.request) ? state : null; + process.stdout.write(JSON.stringify({...disconnected(request, known), ok: false, reason: safeFailure(error)}) + "\n"); + process.exitCode = 1; + } +} +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch(() => {process.stdout.write(JSON.stringify({ok: false, reason: "Invalid ZCode operation"}) + "\n"); process.exitCode = 1;}); +} diff --git a/loopx/zcode_goal_mode/contract.json b/loopx/zcode_goal_mode/contract.json new file mode 100644 index 0000000000..38260bcfde --- /dev/null +++ b/loopx/zcode_goal_mode/contract.json @@ -0,0 +1,11 @@ +{ + "actions": { + "bind": true, + "select_model": true, + "start": true, + "pause": true, + "resume": true, + "stop": true, + "status": true + } +} diff --git a/loopx/zcode_goal_mode/contract.ts b/loopx/zcode_goal_mode/contract.ts new file mode 100644 index 0000000000..3d69183644 --- /dev/null +++ b/loopx/zcode_goal_mode/contract.ts @@ -0,0 +1,48 @@ +/** ZCode provider observations and operations; LoopX Goal/quota authority stays in Core. */ +import providerContract from "./contract.json" with {type: "json"}; +export type ZCodeGoalAction = keyof typeof providerContract.actions; +export const ZCODE_GOAL_ACTIONS = Object.freeze( + Object.keys(providerContract.actions) as [ZCodeGoalAction, ...ZCodeGoalAction[]], +); +export type GoalRef = {goal_id: string; goal_instance_id?: string}; +export type ZCodeModelSelection = {providerId: string; modelId: string; options?: {reasoningLevel: string}}; +export type ZCodeModelOption = {selection: ZCodeModelSelection; label: string; provider_label?: string; + reasoning_levels: string[]; default_reasoning_level: string | null; disabled: boolean}; +export type QuotaObservation = {should_run: boolean; reason?: string; checked_at: string}; +export const ZCODE_NATIVE_GOAL_STATUSES = ["active", "paused", "completed", "budget_limited"] as const; +export type NativeObservation = { + session_id: string; target_id: string | null; + status: typeof ZCODE_NATIVE_GOAL_STATUSES[number] | null; running: boolean; + raw_status?: string | null; session_status?: string; usage?: null; + objective_sha256?: string | null; selected_model?: ZCodeModelSelection | null; available_models?: ZCodeModelOption[]; +}; +export const ZCODE_IDENTITY_SCOPES = ["exact_goal_instance", "legacy_goal_alias"] as const; +export type ZCodeGoalReadback = { + ok: boolean; available: boolean; reason?: string; + goal_id: string; goal_ref: GoalRef; agent_id: string; + goal_creation_operation_id: string | null; + identity_scope?: typeof ZCODE_IDENTITY_SCOPES[number]; + binding: {mode: "managed_cli"; connected: boolean; cli_path: string; protocol: string} | null; + native: NativeObservation | null; quota: QuotaObservation | null; + actions: ZCodeGoalAction[]; +}; +export type NativeRequest = { + action: ZCodeGoalAction; project: string; registry: string; + goal_id: string; goal_ref: GoalRef; agent_id: string; + identity_scope?: typeof ZCODE_IDENTITY_SCOPES[number]; state_path: string; + cli_command?: string[]; cli_path?: string; loopx_command: string[]; task_body?: string; + validation_command: string[]; + goal_creation_operation_id?: string | null; model_selection?: ZCodeModelSelection; +}; +export function sameBinding(a: NativeRequest, b: NativeRequest): boolean { + return a.project === b.project && a.registry === b.registry && a.agent_id === b.agent_id + && a.goal_ref.goal_id === b.goal_ref.goal_id + && a.goal_ref.goal_instance_id === b.goal_ref.goal_instance_id + && a.goal_creation_operation_id === b.goal_creation_operation_id; +} + +/** Only these deliberately public provider failures cross the transport boundary. */ +export class ZCodeGoalError extends Error {} +export function safeFailure(error: unknown): string { + return error instanceof ZCodeGoalError ? error.message : "zcode_operation_failed"; +} diff --git a/loopx/zcode_goal_mode/diagnostics.py b/loopx/zcode_goal_mode/diagnostics.py new file mode 100644 index 0000000000..82101905c5 --- /dev/null +++ b/loopx/zcode_goal_mode/diagnostics.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +# Local host observations; no control-plane decisions or execution authority. +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile +import threading +import time +from typing import Any, Literal, TypedDict + +InterfaceStatus = Literal["advertised", "not_advertised", "unverified"] +ProbeStatus = Literal["observed", "failed", "timeout", "unreadable", "output_limit"] + + +class _ProbeReport(TypedDict): + status: ProbeStatus + exit_code: int | None + output: str + + +PROBE_TIMEOUT_SECONDS = 5 +MAX_METADATA_BYTES = 1_048_576 +INTERFACE_PATTERNS = { + "headless_prompt": r"(? dict[str, Any] | None: + try: + if path.stat().st_size > MAX_METADATA_BYTES: + return None + value = json.loads(path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else None + except (OSError, ValueError): + return None + + +def _run_probe(command: list[str], *, extra_env: dict[str, str] | None = None) -> _ProbeReport: + # Metadata/help only. The reader owns pipe close and temporary-directory + # cleanup: closing a pipe on the caller thread can wait indefinitely for + # a descendant's inherited stdout handle, even after the parent has exited. + disposable = None + try: + disposable = tempfile.TemporaryDirectory(prefix="loopx-zcode-doctor-", ignore_cleanup_errors=True) + env = dict(os.environ) + env.update(extra_env or {}) + for name in ("ZCODE_HOME", "ZCODE_STORAGE_DIR", "ZCODE_DATA_BASE_DIR"): + env[name] = disposable.name + process = subprocess.Popen( + command, cwd=disposable.name, env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, bufsize=0, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, + ) + except OSError: + if disposable is not None: + disposable.cleanup() + return {"status": "unreadable", "exit_code": None, "output": ""} + + deadline = time.monotonic() + PROBE_TIMEOUT_SECONDS + output: list[bytes] = [] + limited = threading.Event() + read_failed = threading.Event() + stream = process.stdout + assert stream is not None + + def capture() -> None: + size = 0 + try: + while chunk := stream.read(4096): + size += len(chunk) + if size > 65536: + limited.set() + try: + process.kill() + except OSError: + pass + break + output.append(chunk) + except (OSError, ValueError): + read_failed.set() + finally: + try: + stream.close() + disposable.cleanup() + except OSError: + pass + + reader = threading.Thread(target=capture, daemon=True) + reader.start() + try: + exit_code = process.wait(timeout=max(0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + reader.join(timeout=max(0, deadline - time.monotonic())) + return {"status": "timeout", "exit_code": None, "output": ""} + reader.join(timeout=max(0, deadline - time.monotonic())) + if reader.is_alive(): + return {"status": "timeout", "exit_code": exit_code, "output": ""} + status: ProbeStatus = ( + "output_limit" if limited.is_set() else + "unreadable" if read_failed.is_set() else + "observed" if exit_code == 0 else "failed" + ) + return { + "status": status, "exit_code": exit_code, + "output": b"".join(output).decode("utf-8", errors="replace") if status in {"observed", "failed"} else "", + } + + +def _public_probe(probe: _ProbeReport) -> dict[str, Any]: + # Raw output may include private configuration/errors; never project it. + return {"status": probe["status"], "exit_code": probe["exit_code"]} + + +def _version(output: str) -> str | None: + for line in output.splitlines()[:8]: + match = re.fullmatch( + r"\s*(?:ZCode(?: CLI)?(?: version)?[: ]+)?v?(\d+\.\d+\.\d+(?:\.\d+)?(?:[-+][\w.-]+)?)\s*", + line, re.IGNORECASE, + ) + if match: + return match.group(1) + return None + + +def _unknown_interfaces() -> dict[str, dict[str, str]]: + return {name: {"status": "unverified", "evidence": "none"} for name in (*INTERFACE_PATTERNS, "stream_json_syntax")} + + +def _inspect_cli(path: Path | None, *, discovery: str) -> dict[str, Any]: + observation: dict[str, Any] = { + "status": "not_found", "path": str(path) if path else None, + "discovery": discovery, "version": None, "interfaces": _unknown_interfaces(), + "runtime_verified": False, "version_evidence": "unavailable", + "next_action": "Install ZCode CLI or supply --zcode-cli with its executable or existing JS bundle.", + } + if path is None: + return observation + try: + path = path.resolve() + observation["path"] = str(path) + if not path.is_file(): + return observation + except OSError: + observation["status"] = "unreadable" + return observation + if (path.parent / "resources/glm/zcode.cjs").is_file() or (path.parent / "resources/app.asar").is_file(): + observation.update(status="invalid", next_action="This is a Desktop installation. Use --zcode-desktop; the GUI executable is never used for CLI probes.") + return observation + is_js = path.suffix.lower() in {".js", ".cjs", ".mjs"} + node = shutil.which("node") if is_js else None + if is_js and node is None: + observation.update(status="probe_failed", next_action="Make node available on PATH to inspect this JS bundle.") + return observation + command = [node, str(path)] if node else [str(path)] + version = _run_probe([*command, "--version"]) + help_probe = _run_probe([*command, "--help", "--locale", "en-US"]) + observation["probes"] = {"version": _public_probe(version), "help": _public_probe(help_probe)} + if version["status"] == "observed": + observation["version"] = _version(version["output"]) + if observation["version"]: + observation["version_evidence"] = "--version" + identity = help_probe["status"] == "observed" and re.search( + r"(?im)^\s*zcode(?:\s+v?\d+\.\d+\.\d+|\s+\[command\])", + help_probe["output"], + ) is not None + observation["identity_verified"] = identity + if identity: + for name, pattern in INTERFACE_PATTERNS.items(): + status: InterfaceStatus = "advertised" if re.search(pattern, help_probe["output"]) else "not_advertised" + observation["interfaces"][name] = {"status": status, "evidence": "--help --locale en-US"} + # Version short-circuits before sessions/models: parser recognition + # does not verify a runtime stream or execution readiness. + syntax = _run_probe([*command, "--version", "--output-format", "stream-json"]) + invalid_syntax = _run_probe([*command, "--version", "--output-format", "loopx-doctor-invalid"]) + recognized = invalid_syntax["status"] == "failed" and syntax["status"] == "observed" and _version(syntax["output"]) == observation["version"] and observation["version"] is not None + observation["probes"]["stream_json_syntax"] = _public_probe(syntax) + observation["probes"]["invalid_output_format_control"] = _public_probe(invalid_syntax) + observation["interfaces"]["stream_json_syntax"] = { + "status": "advertised" if recognized else "unverified", + "evidence": "--version --output-format stream-json" if recognized else "none", + } + observation["status"] = "available" if identity and observation["version"] else "probe_failed" + observation["next_action"] = ( + "Help/version observed; sessions, models, permissions and native protocol execution remain unverified." + if observation["status"] == "available" else + "Check the CLI path, Node runtime and permissions; failed probes do not prove interface absence." + ) + return observation + + +def _desktop_candidates() -> list[Path]: + if os.name == "nt": + return [ + Path(os.environ[variable]) / suffix + for variable, suffix in ( + ("LOCALAPPDATA", "Programs/ZCode/ZCode.exe"), + ("LOCALAPPDATA", "ZCode/ZCode.exe"), + ("ProgramFiles", "ZCode/ZCode.exe"), + ) if os.environ.get(variable) + ] + if sys.platform == "darwin": + return [Path("/Applications/ZCode.app"), Path.home() / "Applications/ZCode.app"] + return [Path("/opt/ZCode/zcode"), Path("/opt/zcode/zcode")] + + +def _resolve_desktop(path: Path) -> tuple[Path, Path]: + # Locate executable/resources without starting the GUI. + if path.suffix.lower() == ".app": + return path / "Contents/MacOS/ZCode", path / "Contents/Resources" + if path.is_dir(): + return path / ("ZCode.exe" if os.name == "nt" else "zcode"), path / "resources" + if path.parent.name == "MacOS": + return path, path.parent.parent / "Resources" + return path, path.parent / "resources" + + +def _desktop_version(executable: Path, resources: Path) -> dict[str, Any]: + if executable.suffix.lower() == ".exe" and os.name == "nt": + powershell = shutil.which("powershell") or shutil.which("pwsh") + if powershell: + script = "(Get-Item -LiteralPath $env:LOOPX_ZCODE_METADATA_PATH -ErrorAction Stop).VersionInfo | Select-Object ProductVersion,ProductName | ConvertTo-Json -Compress" + probe = _run_probe( + [powershell, "-NoProfile", "-NonInteractive", "-Command", script], + extra_env={"LOOPX_ZCODE_METADATA_PATH": str(executable)}, + ) + try: + metadata = json.loads(probe["output"]) if probe["status"] == "observed" else {} + value = metadata.get("ProductVersion") + if isinstance(value, str) and _version(value): + return {"value": value, "evidence": "executable ProductVersion", "identity_verified": str(metadata.get("ProductName", "")).casefold() == "zcode"} + except (ValueError, AttributeError): + pass + plist_path = resources.parent / "Info.plist" + if plist_path.is_file(): + try: + with plist_path.open("rb") as stream: + value = plistlib.load(stream).get("CFBundleShortVersionString") + if isinstance(value, str): + return {"value": value, "evidence": "Info.plist CFBundleShortVersionString", "identity_verified": resources.parent.parent.name.casefold() == "zcode.app"} + except (OSError, ValueError, plistlib.InvalidFileException): + pass + package = _read_json(resources / "app/package.json") + if package and isinstance(package.get("version"), str): + return {"value": package["version"], "evidence": "installed app/package.json", "identity_verified": str(package.get("name", "")).casefold() == "zcode"} + return {"value": None, "evidence": "unavailable", "identity_verified": False} + + +def _inspect_desktop(path_text: str | None) -> dict[str, Any]: + explicit = path_text or os.environ.get("ZCODE_DESKTOP_PATH") + candidates = [Path(explicit).expanduser()] if explicit else _desktop_candidates() + selected = resources = None + for candidate in candidates: + executable, root = _resolve_desktop(candidate.resolve()) + if executable.is_file(): + selected, resources = executable, root + break + observation: dict[str, Any] = { + "status": "available" if selected else "not_found", + "path": str(selected) if selected else (str(candidates[0]) if explicit else None), + "discovery": "explicit" if explicit else "standard_install_locations", + "version": None, "version_evidence": "unavailable", "runtime_verified": False, + "next_action": "Supply --zcode-desktop with the executable, install directory or .app bundle; inaccessible locations do not prove no installation.", + } + if selected is not None and resources is not None: + metadata = _desktop_version(selected, resources) + identity = metadata.get("identity_verified", False) or (resources / "glm/zcode.cjs").is_file() + observation.update(version=metadata["value"], version_evidence=metadata["evidence"], identity_verified=identity) + if not identity: + observation.update(status="unverified", next_action="The selected file exists but ZCode Desktop identity could not be verified. Check its product metadata or installation resources.") + return observation + observation["bundled_cli"] = _inspect_cli(resources / "glm/zcode.cjs", discovery="desktop_bundle") + observation["next_action"] = "Desktop metadata and bundled CLI are separate; the Desktop UI and Automations have not been exercised." + return observation + + +def _inspect_source(path_text: str | None) -> dict[str, Any]: + text = path_text or os.environ.get("ZCODE_SOURCE_ROOT") + if not text: + return {"status": "not_selected", "path": None, "package_version": None} + root = Path(text).expanduser().resolve() + package = _read_json(root / "package.json") + valid = package is not None and str(package.get("name", "")).lower() == "zcode" + observation: dict[str, Any] = { + "status": "available" if valid else "invalid", "path": str(root), + "package_version": package.get("version") if valid and package is not None else None, + "version_evidence": "source package.json; not an installed/runtime version", + } + if valid: + observation["built_cli"] = _inspect_cli(root / "apps/zcode-cli/packages/cli/dist/zcode.cjs", discovery="source_bundle") + else: + observation["next_action"] = "Supply --zcode-source with a ZCode checkout containing its root package.json." + return observation + + +def collect_zcode_host_diagnostics( + *, cli_path: str | None = None, desktop_path: str | None = None, + source_root: str | None = None, +) -> dict[str, Any]: + explicit_cli = cli_path or os.environ.get("ZCODE_CLI_PATH") + found = shutil.which("zcode") if not explicit_cli else None + resolved_cli = Path(explicit_cli).expanduser() if explicit_cli else (Path(found) if found else None) + return { + "cli": _inspect_cli(resolved_cli, discovery="explicit" if explicit_cli else "PATH"), + "desktop": _inspect_desktop(desktop_path), + "source_checkout": _inspect_source(source_root), + "loopx_binding": {"mode": "skill_facade", "native_goal": "opt_in_managed_cli", "automations": "not_integrated"}, + "probe_boundary": "Isolated help/version only. No Desktop launch, session, model, app-server handshake or credential read. Interface observations do not certify runtime readiness.", + } + + +def render_zcode_diagnostics_markdown(payload: dict[str, Any]) -> list[str]: + lines = ["", "## ZCode hosts", ""] + hosts = [("CLI", payload["cli"]), ("Desktop", payload["desktop"])] + if payload["desktop"].get("bundled_cli"): + hosts.append(("Desktop bundled CLI", payload["desktop"]["bundled_cli"])) + source = payload["source_checkout"] + lines.append(f"- Source checkout: **{source['status']}**; declared package version: {source.get('package_version') or 'unknown'} (not runtime version).") + if source.get("path"): + lines.append(f" Path: {source['path']}") + if source.get("built_cli"): + hosts.append(("Source built CLI", source["built_cli"])) + for label, host in hosts: + lines.append(f"- {label}: **{host['status']}**; version: {host.get('version') or 'unknown'}; path: {host.get('path') or 'not discovered'}.") + if host.get("version_evidence"): + lines.append(f" Version evidence: {host['version_evidence']}.") + if host.get("interfaces"): + interfaces = ", ".join(f"{name}={row['status']}" for name, row in host["interfaces"].items()) + lines.append(f" Interface observations: {interfaces}.") + if host.get("next_action"): + lines.append(f" {host['next_action']}") + lines.extend(["", payload["probe_boundary"], "LoopX binding: Skill facade by default; managed native CLI Goal requires explicit zcode-goal bind. Desktop attachment and Automations are not integrated."]) + return lines diff --git a/loopx/zcode_goal_mode/guard.ts b/loopx/zcode_goal_mode/guard.ts new file mode 100644 index 0000000000..05aca25a49 --- /dev/null +++ b/loopx/zcode_goal_mode/guard.ts @@ -0,0 +1,39 @@ +/** Pipe guardian: loss of its broker's stdin revokes the owned CLI process tree. */ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { terminateOwnedProcess } from "./app-server.ts"; + +export async function guard(command: readonly string[]): Promise { + if (!command.length || command.some((part) => !part)) throw new Error("Invalid guarded command"); + const child = spawn(command[0], command.slice(1), { stdio: "pipe", shell: false, windowsHide: true, detached: process.platform !== "win32" }); + let closing: Promise | undefined; + const close = (): Promise => { + if (closing) return closing; + process.stdin.unpipe(child.stdin); + child.stdout.unpipe(process.stdout); + process.stdin.pause(); + closing = terminateOwnedProcess(child).finally(() => { process.exitCode = 0; }); + return closing; + }; + const finish = () => { void close().then(() => process.exit(0), () => process.exit(1)); }; + child.stderr.on("data", () => {}); + child.on("error", () => { void close().finally(() => process.exit(1)); }); + child.stdin.on("error", finish); + child.stdout.on("error", finish); + process.stdin.on("end", finish); + process.stdin.on("error", finish); + process.stdout.on("error", finish); + process.once("SIGINT", finish); + process.once("SIGTERM", finish); + child.once("exit", (code) => { + if (!closing) process.exit(code === 0 ? 0 : 1); + }); + process.stdin.pipe(child.stdin); + child.stdout.pipe(process.stdout); + if (process.stdin.readableEnded) finish(); +} +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const command = process.argv.slice(2); + if (command[0] === "--") command.shift(); + guard(command).catch(() => { process.exitCode = 1; }); +} diff --git a/loopx/zcode_goal_mode/images/native-error-mobile.png b/loopx/zcode_goal_mode/images/native-error-mobile.png new file mode 100644 index 0000000000..f253b71ad0 Binary files /dev/null and b/loopx/zcode_goal_mode/images/native-error-mobile.png differ diff --git a/loopx/zcode_goal_mode/images/native-quota-desktop.png b/loopx/zcode_goal_mode/images/native-quota-desktop.png new file mode 100644 index 0000000000..912285e5fa Binary files /dev/null and b/loopx/zcode_goal_mode/images/native-quota-desktop.png differ diff --git a/loopx/zcode_goal_mode/runtime.ts b/loopx/zcode_goal_mode/runtime.ts new file mode 100644 index 0000000000..f42c575e5f --- /dev/null +++ b/loopx/zcode_goal_mode/runtime.ts @@ -0,0 +1,326 @@ +/** One native session owner. Quota admission and revocation reuse Core CLI decisions. */ +import {createHash} from "node:crypto"; +import {readFile} from "node:fs/promises"; +import {durableWriteJson} from "../control_plane/effect_runtime_io.ts"; +import {BARE_SHA256_PATTERN} from "../control_plane/content_digest.ts"; +import type {JsonObject} from "../control_plane/effect_program.ts"; +import type {NativeObservation, NativeRequest, QuotaObservation, ZCodeModelSelection, ZCodeGoalAction, ZCodeGoalReadback} from "./contract.ts"; +import {sameBinding, ZCodeGoalError, safeFailure} from "./contract.ts"; + +export interface NativeHost { + initialize(): Promise; + create(): Promise; + resumeSession(id: string): Promise; + readGoal(id: string): Promise; + setGoal(id: string, objective: string): Promise; + pauseGoal(id: string): Promise; + resumeGoal(id: string): Promise; + clearGoal(id: string): Promise; + selectModel(id: string, selection: ZCodeModelSelection): Promise; + close(): Promise; +} +export type BindingState = { + schema: "loopx_zcode_native_binding_v0"; + request: NativeRequest; + session_id: string; + target_id: string | null; + objective_sha256: string; + start_pending?: boolean; + last_execution_error?: "zcode_native_execution_failed"; +}; +export type ControllerDependencies = { + host: NativeHost; + validate: (cleanupOnly?: boolean) => Promise; + quota: () => Promise; + persist: (state: BindingState) => Promise; +}; + +export async function atomicState(path: string, state: BindingState): Promise { + await durableWriteJson(path, state as unknown as JsonObject); +} +export async function readState(path: string): Promise { + try { + const text = await readFile(path, "utf8"); + if (Buffer.byteLength(text) > 1024 * 1024) throw new ZCodeGoalError("ZCode binding exceeds its read limit"); + const state = JSON.parse(text) as BindingState; + if (state.schema !== "loopx_zcode_native_binding_v0" || typeof state.session_id !== "string" || !state.session_id + || !state.request || typeof state.request.goal_ref?.goal_id !== "string" + || typeof state.request.agent_id !== "string" || !Array.isArray(state.request.cli_command) + || (state.target_id !== null && (typeof state.target_id !== "string" || !state.target_id)) + || (state.last_execution_error !== undefined && state.last_execution_error !== "zcode_native_execution_failed") + || (state.start_pending !== undefined && typeof state.start_pending !== "boolean") + || typeof state.objective_sha256 !== "string" || !BARE_SHA256_PATTERN.test(state.objective_sha256)) { + throw new ZCodeGoalError("Unsupported ZCode binding; inspect or remove it before binding"); + } + return state; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} +function objectiveDigest(text: string): string { + return createHash("sha256").update(text.trim()).digest("hex"); +} + +/** Host status never completes a LoopX Goal or spends credits on the Agent's behalf. */ +export class NativeGoalController { + readonly state: BindingState; + private dependencies: ControllerDependencies; + private observed: NativeObservation | null = null; + private quotaObserved: QuotaObservation | null = null; + private reason: string | undefined; + private connected = false; + private executionAllowed = false; + private serial: Promise = Promise.resolve(); + + constructor(request: NativeRequest, state: BindingState | null, deps: ControllerDependencies) { + if (state && !sameBinding(request, state.request)) throw new ZCodeGoalError("ZCode binding identity changed"); + if (state && request.cli_command && JSON.stringify(request.cli_command) !== JSON.stringify(state.request.cli_command)) { + throw new ZCodeGoalError("Stop and explicitly rebind before changing the ZCode CLI"); + } + this.state = state ?? {schema: "loopx_zcode_native_binding_v0", request, + session_id: "", target_id: null, objective_sha256: objectiveDigest(request.task_body ?? "")}; + this.dependencies = deps; + } + + async initialize(cleanupOnly = false): Promise { + if (cleanupOnly && !this.state.session_id) throw new ZCodeGoalError("There is no bound native session to clean up"); + await this.dependencies.validate(cleanupOnly); + await this.dependencies.host.initialize(); + const observation = this.state.session_id + ? await this.dependencies.host.resumeSession(this.state.session_id) + : await this.dependencies.host.create(); + if (this.state.session_id && observation.session_id !== this.state.session_id) { + throw new ZCodeGoalError("ZCode restored a different session"); + } + if (!this.state.session_id && observation.target_id !== null) { + throw new ZCodeGoalError("A new ZCode session unexpectedly owns another goal"); + } + this.state.session_id = observation.session_id; + if (observation.target_id && !this.state.target_id) { + if (!this.state.start_pending || !observation.objective_sha256 + || observation.objective_sha256 !== this.state.objective_sha256) { + throw new ZCodeGoalError("ZCode has an unjournaled native Goal; inspect the session before rebinding"); + } + // Recover only an admitted, journaled start whose canonical objective matches exactly. + this.state.target_id = observation.target_id; + this.state.start_pending = false; + } + this.observe(observation); + // Cold recovery never resumes work. Pause a persisted active target before publishing ready. + if (observation.status === "active" || observation.running) { + this.observe(await this.dependencies.host.pauseGoal(this.state.session_id)); + if (this.observed?.status !== "paused" || this.observed.running) throw new ZCodeGoalError("ZCode recovery could not confirm pause"); + } + await this.dependencies.validate(cleanupOnly); + await this.dependencies.persist(this.state); + this.connected = true; + this.executionAllowed = !cleanupOnly; + } + + private observe(next: NativeObservation): void { + if (next.session_id !== this.state.session_id) throw new ZCodeGoalError("ZCode returned a different session"); + if (this.state.target_id && next.target_id !== this.state.target_id) { + throw new ZCodeGoalError("ZCode native Goal identity changed; refusing to mutate it"); + } + this.observed = next; + } + + private exclusive(fn: () => Promise): Promise { + const current = this.serial.then(fn, fn); + this.serial = current.catch(() => {}); + return current; + } + + private async admission(): Promise { + await this.dependencies.validate(); + const quota = await this.dependencies.quota(); + await this.dependencies.validate(); + if (typeof quota.should_run !== "boolean") throw new ZCodeGoalError("Quota returned no usable admission decision"); + this.executionAllowed = true; + this.quotaObserved = quota; + return quota; + } + + private async pauseOwned(): Promise { + if (!this.observed || (!this.observed.running && this.observed.status !== "active")) return; + this.observe(await this.dependencies.host.pauseGoal(this.state.session_id)); + // Pause receipt can precede cancellation draining: require fresh non-running readback. + for (let attempt = 0; this.observed.running && attempt < 20; attempt++) { + await new Promise(resolve => setTimeout(resolve, 50)); + this.observe(await this.dependencies.host.readGoal(this.state.session_id)); + } + if (this.observed.status !== "paused" || this.observed.running) { + throw new ZCodeGoalError("ZCode did not confirm that native execution paused"); + } + } + + private async nativeFailure(): Promise { + if (this.observed?.session_status !== "error") return false; + this.reason = "zcode_native_execution_failed"; + const firstFailure = !this.state.last_execution_error; + this.state.last_execution_error = "zcode_native_execution_failed"; + await this.pauseOwned(); + if (firstFailure) await this.dependencies.persist(this.state); + return true; + } + + async operate(action: ZCodeGoalAction, model?: ZCodeModelSelection, taskBody?: string): Promise { + return this.exclusive(async () => { + try { + // Read-only status may show revocation; cleanup of this exact native session remains allowed. + await this.dependencies.validate(["status", "pause", "stop"].includes(action)); + this.observe(await this.dependencies.host.readGoal(this.state.session_id)); + if (action === "status") { + try { + const decision = await this.admission(); + this.reason = decision.should_run ? undefined : decision.reason ?? "quota_denied"; + if (!decision.should_run) await this.pauseOwned(); + } catch { + this.executionAllowed = false; + this.reason = "authority_or_quota_unavailable"; + this.quotaObserved = {should_run: false, reason: this.reason, checked_at: new Date().toISOString()}; + await this.pauseOwned(); + } + } else if (!["pause", "stop"].includes(action)) this.executionAllowed = true; + if (action === "bind" && this.observed?.target_id) { + this.reason = "Stop the current native Goal before rebinding"; + return this.readback(false); + } + if (action === "select_model") { + if (!model || this.observed?.running) { + this.reason = "Select an available model while the session is idle or paused"; return this.readback(false); + } + this.observe(await this.dependencies.host.selectModel(this.state.session_id, model)); + this.state.request.model_selection = model; + await this.dependencies.persist(this.state); + this.reason = undefined; + } else if (action === "start" || action === "resume") { + if (this.observed?.running) { + this.reason = "ZCode is already executing this native Goal"; return this.readback(false); + } + if (action === "start" && this.observed?.target_id) { + this.reason = "A native Goal already exists; resume or stop it"; return this.readback(false); + } + if (action === "resume" && !this.observed?.target_id) { + this.reason = "There is no native Goal to resume"; return this.readback(false); + } + if (!this.observed?.selected_model) { + this.reason = "Select an available ZCode model before starting"; return this.readback(false); + } + const quota = await this.admission(); + if (!quota.should_run) { + this.reason = quota.reason ?? "quota_denied"; + await this.pauseOwned(); + return this.readback(); + } + delete this.state.last_execution_error; + if (action === "start") { + const objective = (taskBody ?? this.state.request.task_body)?.trim(); + if (!objective?.trim()) throw new ZCodeGoalError("A canonical LoopX task body is required"); + this.state.request.task_body = objective; + this.state.objective_sha256 = objectiveDigest(objective); + this.state.start_pending = true; + await this.dependencies.persist(this.state); + const next = await this.dependencies.host.setGoal(this.state.session_id, objective); + if (!next.target_id) throw new ZCodeGoalError("ZCode accepted no native Goal identity"); + this.state.target_id = next.target_id; + this.state.start_pending = false; + this.observe(next); + await this.dependencies.persist(this.state); + } else { + this.observe(await this.dependencies.host.resumeGoal(this.state.session_id)); + } + this.reason = undefined; + // A fresh observation proves whether execution started; a stored active target does not. + this.observe(await this.dependencies.host.readGoal(this.state.session_id)); + if (await this.nativeFailure()) return this.readback(false); + } else if (action === "pause") { + await this.pauseOwned(); + this.reason = "user_paused"; + } else if (action === "stop") { + await this.pauseOwned(); + if (this.observed?.target_id) { + const cleared = await this.dependencies.host.clearGoal(this.state.session_id); + if (cleared.session_id !== this.state.session_id || cleared.target_id || cleared.running) { + throw new ZCodeGoalError("ZCode did not confirm that the native Goal was cleared"); + } + this.state.target_id = null; + this.observed = cleared; + } + this.state.start_pending = false; + delete this.state.last_execution_error; + await this.dependencies.persist(this.state); + this.reason = "user_stopped"; + } + if (action === "status" && await this.nativeFailure()) return this.readback(false); + return this.readback(); + } catch (error) { + this.executionAllowed = false; + this.reason = safeFailure(error); + // Failed authority/admission never leaves this provider intentionally free-running. + try { await this.pauseOwned(); } + catch { this.connected = false; await this.dependencies.host.close(); } + return this.readback(false); + } + }); + } + + async check(): Promise { + return this.exclusive(async () => { + if (!this.connected) return; + try { + this.observe(await this.dependencies.host.readGoal(this.state.session_id)); + if (await this.nativeFailure()) return; + if (this.observed?.running || this.observed?.status === "active") { + const quota = await this.admission(); + if (!quota.should_run) { + this.reason = quota.reason ?? "quota_denied"; + await this.pauseOwned(); + } + } else { + await this.dependencies.validate(); + this.executionAllowed = true; + } + } catch { + this.executionAllowed = false; + this.reason = "authority_or_quota_unavailable"; + this.quotaObserved = {should_run: false, reason: this.reason, checked_at: new Date().toISOString()}; + try { await this.pauseOwned(); } + catch { this.connected = false; await this.dependencies.host.close(); } + } + }); + } + + readback(ok = true): ZCodeGoalReadback { + const actions: ZCodeGoalAction[] = ["status"]; + if (this.connected && this.observed) { + if (this.executionAllowed && !this.observed.running && this.observed.available_models?.some(model => !model.disabled)) actions.push("select_model"); + if (!this.observed.target_id) { + if (this.executionAllowed) actions.push("bind"); + if (this.executionAllowed && this.quotaObserved?.should_run !== false && this.observed.selected_model) actions.push("start"); + } + else { + actions.push("stop"); + if (this.observed.running || this.observed.status === "active") actions.push("pause"); + if (this.executionAllowed && this.quotaObserved?.should_run !== false && this.observed.selected_model && !this.observed.running && (this.observed.status === "paused" || this.observed.status === "active")) actions.push("resume"); + } + } + const request = this.state.request; + return {ok, available: this.connected, reason: this.reason ?? this.state.last_execution_error, + goal_id: request.goal_id, goal_ref: request.goal_ref, agent_id: request.agent_id, + goal_creation_operation_id: request.goal_creation_operation_id ?? null, + identity_scope: request.goal_ref.goal_instance_id ? "exact_goal_instance" : "legacy_goal_alias", + binding: {mode: "managed_cli", connected: this.connected, + cli_path: request.cli_path ?? request.cli_command?.[0] ?? "", protocol: "zcode-ndjson-session-goal"}, + native: this.connected ? this.observed : null, quota: this.quotaObserved, actions}; + } + async close(): Promise { + await this.exclusive(async () => { + try { await this.pauseOwned(); } finally { + this.connected = false; + await this.dependencies.host.close(); + } + }); + } +} diff --git a/pyproject.toml b/pyproject.toml index 97aed4f6a1..bf8acc0049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ include = ["loopx*"] "loopx.opencode2_goal_mode" = ["*.mjs", "README.md"] "loopx.pi_goal_mode" = ["*.ts", "*.mjs", "README.md"] "loopx.kunluncode_goal_mode" = ["README.md"] +"loopx.zcode_goal_mode" = ["README.md", "*.ts", "contract.json", "images/*.png"] [tool.setuptools.data-files] "share/loopx/skills/loopx-material" = [ diff --git a/scripts/ci/merge-dashboard-coverage.mjs b/scripts/ci/merge-dashboard-coverage.mjs new file mode 100644 index 0000000000..01b4914046 --- /dev/null +++ b/scripts/ci/merge-dashboard-coverage.mjs @@ -0,0 +1,34 @@ +import {readFile, mkdir, copyFile} from "node:fs/promises"; +import {createRequire} from "node:module"; +import {resolve} from "node:path"; +const require = createRequire(import.meta.url); +const {createCoverageMap} = require("istanbul-lib-coverage"); +const {createContext} = require("istanbul-lib-report"); +const reports = require("istanbul-reports"); +const [input, output] = process.argv.slice(2); +if (!input || !output) throw new Error("Expected input and output coverage directories"); +const coverage = createCoverageMap({}); +const selected = new Set(); +let catalogCount; +for (let index = 1; index <= 3; index += 1) { + const shard = resolve(input, `dashboard-coverage-${index}`); + const result = JSON.parse(await readFile(resolve(shard, "acceptance-results.json"), "utf8")); + if (result.shard !== `${index}/3` || !Number.isSafeInteger(result.catalog_count) || + result.catalog_count < 3 || (catalogCount !== undefined && catalogCount !== result.catalog_count)) { + throw new Error("Dashboard shard receipt does not match the planned catalog"); + } + catalogCount = result.catalog_count; + for (const id of result.selected) { + if (selected.has(id) || result.scenarios[id]?.status !== "PASS") { + throw new Error(`Incomplete or overlapping Dashboard acceptance: ${id}`); + } + selected.add(id); + } + coverage.merge(JSON.parse(await readFile(resolve(shard, "browser-coverage.json"), "utf8"))); +} +if (selected.size !== catalogCount) throw new Error("Dashboard acceptance omitted catalog scenarios"); +if (coverage.files().length === 0) throw new Error("No Dashboard browser coverage"); +await mkdir(output, {recursive:true}); +await copyFile(resolve(input, "dashboard-coverage-1/lcov.info"), resolve(output, "lcov.info")); +reports.create("lcovonly", {file:"browser-lcov.info"}).execute(createContext({dir:output, coverageMap:coverage})); +console.log(`Merged ${selected.size} accepted Dashboard scenarios`); diff --git a/scripts/ci/merge-dashboard-coverage.test.mjs b/scripts/ci/merge-dashboard-coverage.test.mjs new file mode 100644 index 0000000000..54a4df63b5 --- /dev/null +++ b/scripts/ci/merge-dashboard-coverage.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {mkdtemp, mkdir, writeFile, readFile, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {writeDashboardBrowserCoverage} from "../../examples/dashboard-browser-coverage.mjs"; +import {spawnSync} from "node:child_process"; +test("coverage merge preserves disjoint hits and rejects missing or duplicate acceptance", async () => { + const root = await mkdtemp(resolve(tmpdir(), "loopx-dashboard-coverage-")); + const source = "apps/presentation/dashboard/src/example.ts"; + const file = hit => ({path:source, statementMap:{0:{start:{line:1,column:0},end:{line:1,column:1}}},fnMap:{},branchMap:{},s:{0:hit},f:{},b:{}}); + const writeReceipt = async (index, id) => writeFile(resolve(root, `dashboard-coverage-${index}/acceptance-results.json`), JSON.stringify({shard:`${index}/3`,catalog_count:3,selected:[id],scenarios:{[id]:{status:"PASS"}}})); + const run = () => spawnSync(process.execPath, [resolve("scripts/ci/merge-dashboard-coverage.mjs"),root,resolve(root,"merged")],{encoding:"utf8"}); + try { + for (let index=1;index<=3;index+=1) { + const shard = resolve(root,`dashboard-coverage-${index}`); + await mkdir(shard); + await writeReceipt(index,`scenario-${index}`); + await writeFile(resolve(shard,"browser-coverage.json"),JSON.stringify({[source]:file(index===2?1:0)})); + await writeFile(resolve(shard,"lcov.info"),"unit-coverage-preserved"); + } + let result = run(); + assert.equal(result.status,0,result.stderr); + assert.match(await readFile(resolve(root,"merged/browser-lcov.info"),"utf8"),/DA:1,1/); + assert.equal(await readFile(resolve(root,"merged/lcov.info"),"utf8"),"unit-coverage-preserved"); + await writeReceipt(3,"scenario-2"); + result=run();assert.notEqual(result.status,0);assert.match(result.stderr,/overlapping/); + await rm(resolve(root,"dashboard-coverage-3"),{recursive:true}); + assert.notEqual(run().status,0); + } finally { await rm(root,{recursive:true,force:true}); } +}); + +test("browser coverage creates a fresh output directory for independent acceptance", async () => { + const root = await mkdtemp(resolve(tmpdir(), "loopx-browser-coverage-")); + try { + const outputDir = resolve(root,"coverage/dashboard"); + await writeDashboardBrowserCoverage([{url:"http://localhost/src/example.ts",source:"const a = 1;\n",functions:[{functionName:"",isBlockCoverage:true,ranges:[{startOffset:0,endOffset:13,count:1}]}]}],{ + repoRoot:root,dashboardDir:resolve(root,"apps/presentation/dashboard"),outputDir, + }); + const coverage = JSON.parse(await readFile(resolve(outputDir,"browser-coverage.json"),"utf8")); + assert.ok(coverage["apps/presentation/dashboard/src/example.ts"]); + assert.match(await readFile(resolve(outputDir,"browser-lcov.info"),"utf8"),/DA:1,1/); + } finally { await rm(root,{recursive:true,force:true}); } +}); diff --git a/tests/architecture/test_source_session_registry_denial.py b/tests/architecture/test_source_session_registry_denial.py index cef2ca0c7d..5655d13519 100644 --- a/tests/architecture/test_source_session_registry_denial.py +++ b/tests/architecture/test_source_session_registry_denial.py @@ -3,6 +3,8 @@ import ast from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[2] DIRECT_LOADER_ALLOWLIST = { @@ -32,6 +34,53 @@ } +# Storage-location readers grant no runtime action. Exceptions name the exact +# functions; another direct loader in either module must still fail. +METADATA_READER_FUNCTIONS = { + "loopx/capabilities/native_chat/project_context.py": {"coordination_runtime_root"}, + "loopx/control_plane/goals/source_session_recreation.py": {"_canonical_writer_guard_path"}, +} + + +def _assert_metadata_reader_functions(tree: ast.Module, expected: set[str]) -> None: + loader_functions = { + node.name + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and any( + isinstance(call, ast.Call) + and isinstance(call.func, ast.Name) + and call.func.id == "load_project_registry" + for call in ast.walk(node) + ) + } + assert loader_functions == expected + + +def _assert_recreation_reader_is_location_only(tree: ast.Module) -> None: + reader = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_canonical_writer_guard_path" + ) + calls = [node for node in ast.walk(reader) if isinstance(node, ast.Call)] + assert all(isinstance(node.func, (ast.Name, ast.Attribute)) for node in calls) + assert {node.func.id for node in calls if isinstance(node.func, ast.Name)} == { + "load_project_registry", "resolve_runtime_root", "shadow_maintenance_lock_target", + } + # Only normalize the observed path; no decision, transaction or write API. + assert all( + isinstance(node.func.value, ast.Name) + and node.func.value.id == "runtime_root" + and node.func.attr == "resolve" + for node in calls if isinstance(node.func, ast.Attribute) + ) + returned = [node.value for node in ast.walk(reader) if isinstance(node, ast.Return)] + assert len(returned) == 1 + assert isinstance(returned[0], ast.Call) + assert isinstance(returned[0].func, ast.Name) + assert returned[0].func.id == "shadow_maintenance_lock_target" + + def test_direct_project_registry_loaders_have_source_session_denial() -> None: callers: set[str] = set() for path in (REPO_ROOT / "loopx").rglob("*.py"): @@ -63,23 +112,12 @@ def test_direct_project_registry_loaders_have_source_session_denial() -> None: "loopx/control_plane/coordination/shadow_goal_scope.py", "loopx/control_plane/projects/registry.py", } - # Storage-location observation grants no runtime action. Keep this exception - # at its exact function; another direct loader in the module still fails. - metadata_reader = "loopx/capabilities/native_chat/project_context.py" - metadata_tree = ast.parse((REPO_ROOT / metadata_reader).read_text(encoding="utf-8")) - metadata_loader_functions = { - node.name - for node in ast.walk(metadata_tree) - if isinstance(node, ast.FunctionDef) - and any( - isinstance(call, ast.Call) - and isinstance(call.func, ast.Name) - and call.func.id == "load_project_registry" - for call in ast.walk(node) - ) - } - assert metadata_loader_functions == {"coordination_runtime_root"} - for relative in callers - source_session_owners - {metadata_reader}: + for relative, expected in METADATA_READER_FUNCTIONS.items(): + tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) + _assert_metadata_reader_functions(tree, expected) + if relative == "loopx/control_plane/goals/source_session_recreation.py": + _assert_recreation_reader_is_location_only(tree) + for relative in callers - source_session_owners - METADATA_READER_FUNCTIONS.keys(): source = (REPO_ROOT / relative).read_text(encoding="utf-8") assert "require_runtime_compatible_project_registry(" in source, relative attached_owner = (REPO_ROOT / "loopx/attached_session.py").read_text( @@ -89,6 +127,27 @@ def test_direct_project_registry_loaders_have_source_session_denial() -> None: assert "source_session_goal_lifetime" in attached_owner +def test_metadata_exception_rejects_another_direct_loader() -> None: + relative = "loopx/control_plane/goals/source_session_recreation.py" + source = (REPO_ROOT / relative).read_text(encoding="utf-8") + tree = ast.parse(source + "\n\ndef execute_goal(registry_path):\n return load_project_registry(registry_path)\n") + with pytest.raises(AssertionError): + _assert_metadata_reader_functions(tree, METADATA_READER_FUNCTIONS[relative]) + + +@pytest.mark.parametrize("authority_call", ["effect_runtime_result", "mutate_project_registry"]) +def test_recreation_metadata_exception_rejects_authority_calls(authority_call: str) -> None: + relative = "loopx/control_plane/goals/source_session_recreation.py" + tree = ast.parse((REPO_ROOT / relative).read_text(encoding="utf-8")) + reader = next( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_canonical_writer_guard_path" + ) + reader.body.insert(0, ast.parse(f"{authority_call}()").body[0]) + with pytest.raises(AssertionError): + _assert_recreation_reader_is_location_only(tree) + + def test_generic_registry_decoder_enforces_source_session_denial() -> None: source = (REPO_ROOT / "loopx/control_plane/projects/registry_codec.py").read_text( encoding="utf-8" diff --git a/tests/control_plane/test_causal_blocked_closeout_cli.py b/tests/control_plane/test_causal_blocked_closeout_cli.py index 9e5b57265c..d58c3d6b64 100644 --- a/tests/control_plane/test_causal_blocked_closeout_cli.py +++ b/tests/control_plane/test_causal_blocked_closeout_cli.py @@ -81,21 +81,26 @@ def cli(*args: str, cwd: Path = project) -> tuple[int, dict]: assert rc == 0, original digest = original["todo"]["completion_validation_sha256"] if defer: - # An unsatisfied todo_done wait fences execution. Acquire the lease - # before installing that wait, then defer and release it atomically. - rc, successor = cli("todo", "update", "--goal-id", GOAL_ID, - "--todo-id", TODO_ID, "--agent-id", AGENT_ID, - "--successor-todo-id", ALTERNATIVE_TODO_ID) - assert rc == 0, successor + # Admit the existing executable work before its dependency is installed. + # A new lease after the causal wait would violate the completion fence. rc, acquired = cli("task-lease", "acquire", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, "--owner", AGENT_ID, "--idempotency-key", "execution-wait", "--ttl-seconds", "900") assert rc == 0, acquired + rc, wait = cli("todo", "update", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, + "--agent-id", AGENT_ID, "--resume-when", f"{kind}:{MONITOR_ID}", + "--successor-todo-id", ALTERNATIVE_TODO_ID, + "--task-lease-idempotency-key", "execution-wait", + "--task-lease-expected-version", str(acquired["lease"]["version"])) + assert rc == 0, wait + # The atomic owner-deferral accepts only unchanged work and its wait. + # Link planning is a separate fenced edit, not part of lease retirement. rc, suspended = cli("todo", "update", "--goal-id", GOAL_ID, "--todo-id", TODO_ID, "--agent-id", AGENT_ID, "--status", "deferred", "--resume-when", f"{kind}:{MONITOR_ID}", "--reason", "Dependency pending", "--task-lease-idempotency-key", "execution-wait", "--task-lease-expected-version", str(acquired["lease"]["version"])) assert rc == 0, json.dumps(suspended, indent=2) + assert suspended["deferred_transition"]["lease_retirement"] == "released" rc, lease = cli("task-lease", "inspect", "--goal-id", GOAL_ID, "--todo-id", TODO_ID) assert rc == 0 and lease["lease"]["status"] == "released", lease else: diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index 5891024188..82ae29e463 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -513,8 +513,11 @@ def _assert_turn_plan_writeback_routes( # Budget compaction must not discard or redirect the writeback target. action = measurement["payload"]["turn_envelope"]["writeback"]["next_cli_actions"][0] argv = shlex.split(action) - assert argv[argv.index("--registry") + 1] == str(registry_path) - assert argv[argv.index("--runtime-root") + 1] == str(runtime) + assert argv.count("--registry") == argv.count("--runtime-root") == 1 + # Windows drive-rooted aliases are canonicalized by the runtime owner. + # Compare target identity while retaining each complete route exactly once. + assert Path(argv[argv.index("--registry") + 1]).resolve() == registry_path.resolve() + assert Path(argv[argv.index("--runtime-root") + 1]).resolve() == runtime.resolve() def _mode_variant_commands( @@ -1697,8 +1700,8 @@ def _assert_mode_variant_budgets(root: Path, *, only: str | None = None) -> None def test_brief_budget_retains_full_commands_on_real_long_paths() -> None: # A reproducible 128-character absolute root, independent of pytest's - # ever-growing temp/worker prefix. Do not shorten rendered paths or raise - # the absolute output ceiling to make this case pass. + # ever-growing temp/worker prefix. Keep full routes and this workload; + # presentation-budget changes require matched base/head cost evidence. # Reuse the other budget fixtures' short namespace. A canary's nested # TMPDIR can already exceed 128 characters before we create this root. parent = Path("/tmp").resolve() @@ -1929,14 +1932,12 @@ def test_turn_envelope_cli_preserves_codex_app_scheduler_binding( assert exit_code == 0, text payload = json.loads(text) full_decision = shlex.split(payload["detail_ref"]["full_decision"]) - # A cold read must preserve the originating source as well as the host - # profile, rather than silently switching to the operator's default Goal. - for option, value in ( - ("--registry", str(registry_path)), ("--runtime-root", str(runtime)), - ("--goal-id", GOAL_ID), ("--agent-id", AGENT_IDS[0]), ("--format", "json"), - ): - assert full_decision[full_decision.index(option) + 1] == value - assert full_decision[0] == "loopx" and "--codex-app" in full_decision + # A cold read must preserve the complete originating source and host profile. + assert full_decision == [ + "loopx", "--registry", str(registry_path), "--runtime-root", str(runtime), + "--format", "json", "quota", "should-run", "--goal-id", GOAL_ID, + "--agent-id", AGENT_IDS[0], "--codex-app", + ] read_rc, read_text = _invoke_cli(full_decision[1:]) assert read_rc == 0, read_text assert json.loads(read_text)["goal_id"] == GOAL_ID @@ -1980,9 +1981,9 @@ def test_quota_should_run_cli_actions_keep_explicit_runtime_root( def assert_selected_runtime_root(command: str) -> None: argv = shlex.split(command) assert argv[0] == "loopx" - assert "--runtime-root" in argv + assert argv.count("--runtime-root") == 1 assert argv[argv.index("--runtime-root") + 1] == str(runtime) - assert "--registry" in argv + assert argv.count("--registry") == 1 assert argv[argv.index("--registry") + 1] == str(registry_path) assert cli_channel["next_cli_actions"] diff --git a/tests/control_plane/test_cli_output_probe_runner.py b/tests/control_plane/test_cli_output_probe_runner.py index 1364b4d07c..7087192ad9 100644 --- a/tests/control_plane/test_cli_output_probe_runner.py +++ b/tests/control_plane/test_cli_output_probe_runner.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import runpy import re from pathlib import Path @@ -77,7 +78,8 @@ def capture_stdout(command): assert root.name.startswith("loopx-cli-budget-") assert len(root.name.removeprefix("loopx-cli-budget-")) == 12 assert not root.exists() # The shared context cleans up its alias. - assert str(root) in emitted[0] # Full CLI command paths are still emitted. + # Full paths remain in the untouched JSON wire, including escaped Windows backslashes. + assert json.dumps(str(root))[1:-1] in emitted[0] assert rows[0]["row_id"] == "surface/loopx_turn_plan/crowded/json" assert rows[0]["chars"] == len(emitted[0]) assert ( diff --git a/tests/control_plane/test_effect_runtime_integration.py b/tests/control_plane/test_effect_runtime_integration.py index fd295a2a15..6f66dc7c38 100644 --- a/tests/control_plane/test_effect_runtime_integration.py +++ b/tests/control_plane/test_effect_runtime_integration.py @@ -1213,9 +1213,11 @@ def start_runtime(*, fingerprint: str, info_path: Path): assert attempts["count"] == expected_attempts +@pytest.mark.parametrize("retirement_lock_delay", [0, 2.1], ids=["uncontended", "locator-contended"]) def test_managed_runtime_releases_memory_after_idle_timeout( tmp_path: Path, monkeypatch, + retirement_lock_delay: float, ) -> None: runtime_dir = tmp_path / "runtime" monkeypatch.setattr(effect_runtime, "_runtime_dir", lambda: runtime_dir) @@ -1228,7 +1230,20 @@ def test_managed_runtime_releases_memory_after_idle_timeout( original = effect_runtime.effect_runtime_result("runtime.ping", {}) original_pid = int(original["pid"]) - deadline = time.monotonic() + 2 + # Retirement acquires the shared locator lock, whose legal wait is 5 s. + # The previous 2 s deadline was shorter than that contract. This bounds + # eventual cleanup without changing the 150 ms server idle policy. + deadline = time.monotonic() + 6 + if retirement_lock_delay: + info_path = effect_runtime._runtime_info_path(fingerprint) + lock_path = Path(f"{info_path}.ts-effect.lock") + lock_path.write_text(json.dumps({"pid": os.getpid(), "token": "retirement-holder"})) + try: + time.sleep(retirement_lock_delay) + assert info_path.exists(), "retirement must respect the locator writer fence" + assert effect_runtime._pid_is_alive(original_pid) + finally: + lock_path.unlink(missing_ok=True) while list(runtime_dir.glob("runtime-*.json")) and time.monotonic() < deadline: time.sleep(0.025) @@ -1313,7 +1328,8 @@ def write() -> dict[str, object]: if not disconnect: result = pending.result(timeout=3) assert result["appended"] is True and result["replayed"] is False - deadline = time.monotonic() + 3 + # Allow the existing 5 s locator-lock budget, plus the idle window. + deadline = time.monotonic() + 6 while info_path.exists() and time.monotonic() < deadline: time.sleep(0.025) assert not info_path.exists(), "settled runtime must still retire when idle or stopped" diff --git a/tests/control_plane/test_native_child_replan_guard_cli.py b/tests/control_plane/test_native_child_replan_guard_cli.py index d7c49adc07..7c4572f696 100644 --- a/tests/control_plane/test_native_child_replan_guard_cli.py +++ b/tests/control_plane/test_native_child_replan_guard_cli.py @@ -67,6 +67,8 @@ def _fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, provider: str, tod "domain": "synthetic-replan", "adapter": {"kind": "fixture_connected_delivery_v0", "status": "connected-delivery"}, "quota": {"compute": 1.0, "window_hours": 24}, + # This history contains no effective-Turn settlement witnesses. + "execution_profile": {"replan_after_completed_todos": 5}, "spawn_policy": {"mode": "multi_subagent", "allowed": True, "max_children": 6}, "coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT]}, }]})) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index 4f2dfb8eba..ff0c0f6c7f 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1891,6 +1891,8 @@ def test_in_flight_progress_preserves_todo_across_heartbeat_settlements( "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", + str(project), "--agent-id", AGENT_ID, "--todo-id", @@ -1968,6 +1970,8 @@ def test_in_flight_progress_preserves_todo_across_heartbeat_settlements( "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", + str(project), "--agent-id", AGENT_ID, "--todo-id", @@ -2029,6 +2033,8 @@ def test_in_flight_progress_settles_while_completion_validation_todo_is_open( "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", + str(project), "--agent-id", AGENT_ID, "--todo-id", @@ -2875,12 +2881,30 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( TURN_ID, "--execute", ] + fresh_turn_rc, fresh_turn = _run_cli( + registry_path, + runtime, + "quota", + "should-run", + "--codex-app", + "--goal-id", + GOAL_ID, + "--agent-id", + AGENT_ID, + "--turn-instance-id", + "turn-settlement-cli-2", + "--scan-path", + str(project), + ) + assert fresh_turn_rc == 0, fresh_turn + assert fresh_turn["selected_todo"]["todo_id"] == successor_id + ack_rc, ack = _run_cli( registry_path, runtime, *original_ack_hint["cli_args"], ) - # The intervening fresh_guard superseded this Turn for host writeback, + # The newer receipt-bound fresh_turn superseded this Turn for host writeback, # even though its original delivery settlement still replays correctly. assert ack_rc == 1, ack assert ack["error_code"] == "SCHEDULER_FOLLOWUP_HEARTBEAT_RECEIPT_STALE" @@ -2895,23 +2919,6 @@ def test_standard_codex_app_settlement_is_receipted_and_idempotent( ) is None assert _spend_run_count(runtime) == 1 - fresh_turn_rc, fresh_turn = _run_cli( - registry_path, - runtime, - "quota", - "should-run", - "--codex-app", - "--goal-id", - GOAL_ID, - "--agent-id", - AGENT_ID, - "--turn-instance-id", - "turn-settlement-cli-2", - "--scan-path", - str(project), - ) - assert fresh_turn_rc == 0, fresh_turn - assert fresh_turn["selected_todo"]["todo_id"] == successor_id fresh_ack_args = fresh_turn["scheduler_hint"]["app_automation"]["ack_hint"]["cli_args"] fresh_ack_rc, fresh_ack = _run_cli(registry_path, runtime, *fresh_ack_args) assert fresh_ack_rc == 0, fresh_ack @@ -4087,6 +4094,7 @@ def test_host_owned_turn_executes_projected_selection_and_replays_identity( "--turn-instance-id", TURN_ID, "--classification", "validated_progress", "--delivery-batch-scale", "implementation", "--delivery-outcome", "outcome_progress", "--delivery-boundary", "in_flight_continuation", + "--delivery-workspace-path", str(project), "--no-global-sync", "--suppress-external-sinks", ) assert refresh_rc == 0, refresh @@ -7116,6 +7124,8 @@ def test_settled_turn_defers_prior_unsettled_history_to_fresh_turn( "single_surface", "--delivery-outcome", "outcome_progress", + "--delivery-workspace-path", + str(project), *binding, "--no-global-sync", "--suppress-external-sinks", diff --git a/tests/control_plane/test_quota_spend_commit_runtime.py b/tests/control_plane/test_quota_spend_commit_runtime.py index af9726c61d..a84f496517 100644 --- a/tests/control_plane/test_quota_spend_commit_runtime.py +++ b/tests/control_plane/test_quota_spend_commit_runtime.py @@ -229,7 +229,9 @@ def test_source_spend_rejects_stale_goal_before_any_write_and_stamps_successor( runs_dir = runtime_root / "goals" / GOAL_ID / "runs" assert not (runs_dir / "index.jsonl").exists() assert not (runs_dir / ".transactions").exists() - assert not list(runs_dir.glob("*.json")) + # Windows lock-holder metadata is not a committed quota run. + assert not [path for path in runs_dir.glob("*.json") + if not path.name.endswith(".lock.holder.json")] assert not list(runs_dir.glob("*.md")) assert not list(tmp_path.rglob("*.ts-effect.lock")) diff --git a/tests/control_plane/test_remote_location_shape_owner.py b/tests/control_plane/test_remote_location_shape_owner.py index 7aa7987b68..692418b580 100644 --- a/tests/control_plane/test_remote_location_shape_owner.py +++ b/tests/control_plane/test_remote_location_shape_owner.py @@ -115,7 +115,8 @@ def test_each_site_rejects_a_raw_location_through_its_own_entry_point( ): with pytest.raises(ValueError) as caught: call(value) - assert message in str(caught.value), (label, value) + expected = "must not contain a local path" if value.startswith("file:") and label != "ml_experiment" else message + assert expected in str(caught.value), (label, value) @pytest.mark.parametrize("label,call,_remote_message,_file_url_message", SITES) diff --git a/tests/control_plane/test_replan_semantic_action_behavior.py b/tests/control_plane/test_replan_semantic_action_behavior.py index bc168bf7dd..0312184c70 100644 --- a/tests/control_plane/test_replan_semantic_action_behavior.py +++ b/tests/control_plane/test_replan_semantic_action_behavior.py @@ -123,6 +123,21 @@ def _inline_explore_context_action( return ScriptedExecToolAction(command="cat replan-frontier.json") +def _explore_context_action( + request: Mapping[str, object], +) -> ScriptedExecToolAction: + channel = _latest_quota_packet(request)["interaction_contract"]["agent_channel"] + context = channel["work_context"] + assert context["complete"] is True + read, = [source for source in context["sources"] + if source["kind"] == "explore_turn_context"] + assert read["ordering"] == "before_work" + assert read["content"]["goal_id"] == "replan-semantic-action-fixture" + assert read["content"]["agent_id"] == "codex-replan-semantic-action" + return ScriptedExecToolAction(command=read["command"]) + + + def _composition_successor_action( request: Mapping[str, object], ) -> ScriptedExecToolAction: @@ -493,6 +508,14 @@ def test_real_tool_loop_selects_composition_gap_and_creates_bound_successor( assert successor_reentry["composition_status"] == "scheduled" quota_packet = json.loads(transport.requests[1]["messages"][-1]["content"]) + # The canonical context owner has already delivered the scoped hook body. + # Displayed source commands are provenance, not another required tool call. + _explore_context_action(transport.requests[1]) + assert quota_packet.get("required_reads") in (None, []) + assert not any( + read.get("kind") == "explore_turn_context" + for read in quota_packet["interaction_contract"]["agent_channel"]["required_reads"] + ) selected_gap = quota_packet["bounded_research_frontier"]["selected_gap"] assert selected_gap["experiment_node_ref"] == ( "experiment-permission-composition" @@ -502,6 +525,35 @@ def test_real_tool_loop_selects_composition_gap_and_creates_bound_successor( ] == selected_gap["experiment_node_ref"] +@pytest.mark.parametrize("attempt", ["repeat", "wrong_scope"]) +def test_delivered_composition_context_cannot_be_replayed_or_retargeted( + tmp_path: Path, attempt: str, +) -> None: + fixture = _build_fixture(tmp_path / "oracle", composition_frontier=True) + actions = [ScriptedExecToolAction(command=fixture.quota_guard_command)] + if attempt == "repeat": + actions.extend([_explore_context_action, _explore_context_action]) + else: + def wrong_scope(request: Mapping[str, object]) -> ScriptedExecToolAction: + action = _explore_context_action(request) + return ScriptedExecToolAction( + command=action.command.replace( + "--goal-id replan-semantic-action-fixture", "--goal-id another-goal" + ) + ) + actions.append(wrong_scope) + receipt = DoubaoReplanSemanticActionBehaviorActor( + api_key="test-only-placeholder", transport=ScriptedDoubaoExecTransport(actions), + ).qualify( + qualification_id=f"composition-read-{attempt}", + fixture_root=tmp_path / "actor", composition_frontier=True, + ) + assert receipt["qualification_passed"] is False + assert receipt["failure_code"] == "unexpected_command" + assert receipt["semantic_action_accepted"] is False + assert "replan_successor_create" not in receipt["observed_tool_sequence"] + + def test_required_explore_read_uses_nested_interaction_contract() -> None: command = ( "loopx --format json explore turn-context --goal-id " diff --git a/tests/control_plane/test_replan_successor_durable_ack.py b/tests/control_plane/test_replan_successor_durable_ack.py index 703b15b7a6..9a2e3598ce 100644 --- a/tests/control_plane/test_replan_successor_durable_ack.py +++ b/tests/control_plane/test_replan_successor_durable_ack.py @@ -39,7 +39,7 @@ def successor_state(obligation_id: str, *, owner: str = AGENT) -> str: f"updated_at={quote('2026-08-01T01:00:00Z', safe='')} -->\n") -def test_cli_successor_refresh_resets_periodic_window(tmp_path: Path, capsys) -> None: +def test_cli_completed_todo_successor_refresh_resets_periodic_window(tmp_path: Path, capsys) -> None: project = tmp_path / "project" project.mkdir() state = project / "ACTIVE_GOAL_STATE.md" @@ -55,6 +55,7 @@ def test_cli_successor_refresh_resets_periodic_window(tmp_path: Path, capsys) -> # Exercise the periodic-history window instead of the live machine default. "execution_profile": {"replan_after_completed_todos": 1}, "coordination": {"agent_model": "peer_v1", "registered_agents": [AGENT]}, + "execution_profile": {"replan_after_completed_todos": 5}, }]})) obligation = autonomous_replan_obligation_from_runs(runs, agent_todos={}, agent_id=AGENT) assert obligation is not None diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index dbd7d0c60e..c604f99d2b 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import ts from "typescript"; @@ -13,7 +14,7 @@ import { delegationInventoryQuery } from "../../loopx/control_plane/collaboratio import { normalizeCollaborationBrief } from "../../loopx/control_plane/collaboration/semantic_request.ts"; import { decodeOutboxCursor } from "../../loopx/control_plane/coordination/local_authority_shadow_outbox.ts"; -const PACKAGE_ROOT = new URL("../../loopx", import.meta.url).pathname; +const PACKAGE_ROOT = fileURLToPath(new URL("../../loopx", import.meta.url)); const OWNER_FILE = "control_plane/content_digest.ts"; const CANONICAL_EXPORTS = ["BARE_SHA256_PATTERN", "ENVELOPED_SHA256_PATTERN"]; const HEX_CHARS = [..."0123456789abcdef"]; @@ -141,6 +142,8 @@ const CANONICAL_CONSUMERS = [ "control_plane/work_items/task_lease_lifecycle.ts", "control_plane/work_items/task_lease_lifecycle_request.ts", "control_plane/work_items/task_lease_workspace.ts", + "zcode_goal_mode/cli.ts", + "zcode_goal_mode/runtime.ts", ]; function packageFiles(dir: string, base = ""): string[] { diff --git a/tests/control_plane_ts/zcode_app_server.test.ts b/tests/control_plane_ts/zcode_app_server.test.ts new file mode 100644 index 0000000000..2ffdc053d7 --- /dev/null +++ b/tests/control_plane_ts/zcode_app_server.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createHash } from "node:crypto"; +import { ZCodeAppServer, ZCodeProtocolError } from "../../loopx/zcode_goal_mode/app-server.ts"; + +// An independent native host fixture, with strict CAS and a self-running Goal. +const HOST = String.raw` +const readline = require('node:readline'); +const mode = process.argv[1]; let target = null, revision = 0, running = false, selectedModel=null, durable=false; +const snapshot = () => ({protocol:{name:'ZCode Protocol',version:1},session:{sessionId:'sess_fixture'},settings:{model:{current:selectedModel,available:[{ref:{providerId:'synthetic',modelId:'synthetic'},label:'Synthetic',reasoning:{levels:[{value:'disabled',label:'disabled'}],defaultLevel:'disabled'}}]}},projection:{sessionId:'unknown',status:running?'running':'idle',target:target&&{sessionId:'sess_fixture',targetId:target.id,objective:target.objective||'Synthetic goal',status:target.status}},runtime:{stateRevision:revision}}); +const send = value => process.stdout.write(JSON.stringify(value)+'\n'); +readline.createInterface({input:process.stdin}).on('line', line => { + const r=JSON.parse(line); + if (r.result || r.error) { if (r.id==='permission') {if(r.result?.decision!=='deny')process.exit(8);send({method:'fixture/permissionDenied'});} if(r.id==='unknown'){if(r.error?.code!==-32601)process.exit(9);send({method:'fixture/unknownDenied'});} return; } + if (mode==='timeout') return; + if (mode==='garbage') {process.stdout.write('private-key-secret\n');return;} + if (mode==='exit') {process.stderr.write('private-key-secret');process.exit(5);} + if (mode==='limit') {process.stdout.write('x'.repeat(4*1024*1024+1));return;} + if (r.method==='runtime/capabilities') { + if(mode==='reverse') {send({id:'permission',method:'interaction/requestPermission',params:{sessionId:'sess_fixture'}});send({id:'unknown',method:'interaction/requestProviderRuntimeHeaders',params:{secret:'private'}});send({id:r.id,result:{independentPlanState:true}});return;} + if(mode==='split') { const wire=JSON.stringify({id:r.id,result:{independentPlanState:true}})+'\n';process.stdout.write(wire.slice(0,13));setTimeout(()=>process.stdout.write(wire.slice(13)),8);return; } + if(mode==='reject') {send({id:r.id,error:{code:-32009,message:'private-key-secret',data:{credential:'secret'}}});return;} + send({id:r.id,result:{independentPlanState:true}});return; + } + if(r.method==='session/create') { + if(r.params.titleGenerationEnabled!==false||r.params.mode!=='build'||r.params.sessionId) throw Error('unsafe create'); + const s=snapshot();if(mode==='wrong-protocol') s.protocol.version=9;send({id:r.id,result:s});return; + } + if(r.method==='session/resume') {target={id:'goal-existing',status:'paused'};send({id:r.id,result:snapshot()});return;} + if(r.method==='session/read') { + if(r.params.messageLimit!==1) throw Error('invalid message limit'); + if(mode==='replace-target'&&target) target.id='goal-foreign'; + if(mode==='budget-limited'&&target){target.status='budget_limited';running=false;}const s=snapshot();if(mode==='stale-cas'&&durable)revision++;if(mode==='wrong-session') s.session.sessionId='foreign';if(mode==='wrong-target'&&target)s.projection.target.sessionId='foreign';if(mode==='unknown-status'&&target)s.projection.target.status='future';send({id:r.id,result:s});return; + } + if(r.method==='session/setModel'){if(r.params.expectedRevision!==revision)throw Error('missing CAS');selectedModel=r.params.model;revision++;send({id:r.id,result:snapshot()});return;} + if(r.method==='session/goal') { + if(r.params.expectedRevision!==revision){send({id:r.id,error:{code:-32009,message:'CAS rejected'}});return;} + if(r.params.action==='set'){if(mode==='require-durable'&&!durable)throw Error('unpersisted session');target={id:'goal-fixture',objective:r.params.objective.trim(),status:'active'};running=true;} + if(r.params.action==='resume'){target.status='active';running=true;} + if(r.params.action==='pause'){if(!target&&mode==='empty-pause-rejected'){send({id:r.id,error:{code:-32004,message:'persistence rejected'}});return;}if(!target&&mode==='require-durable')send({method:'fixture/sessionDurable'});durable=true;if(target)target.status='paused';running=false;} + if(r.params.action==='clear'){if(running||target?.status==='active')throw Error('clear before pause');target=null;} + revision++; const startedTurn = r.params.action==='pause'&&mode==='empty-pause-started' ? true : r.params.action==='pause'&&mode==='empty-pause-missing' ? undefined : r.params.action==='set'||r.params.action==='resume'; send({id:r.id,result:{response:'',snapshot:snapshot(),startedTurn}});return; + } + send({id:r.id,error:{code:-32601,message:'unsupported'}}); +}); +`; +function host(mode = "normal", options: ConstructorParameters[3] = {}) { + return new ZCodeAppServer([process.execPath, "-e", HOST, mode], process.cwd(), { ...process.env }, { timeoutMs: 1000, ...options }); +} +function safeError(code: string) { + return (error: unknown) => error instanceof ZCodeProtocolError && error.code === code && !String(error).includes("private-key-secret"); +} + +test("native lifecycle retains one server and returns while its Goal runs", async (t) => { + const server=host();t.after(()=>server.close()); + assert.equal((await server.initialize()).protocol,"zcode-ndjson-session-goal"); + const created=await server.create({providerId:"synthetic",modelId:"synthetic"}); + assert.equal(created.target_id,null);assert.equal(created.running,false); + const started=await server.setGoal(created.session_id,"Synthetic goal"); + assert.equal(started.started_turn,true);assert.equal(started.running,true);assert.equal(started.target_id,"goal-fixture"); + const paused=await server.pauseGoal(created.session_id); + assert.equal(paused.status,"paused");assert.equal(paused.running,false);assert.equal(paused.target_id,started.target_id); + assert.equal((await server.resumeGoal(created.session_id)).started_turn,true); + assert.equal((await server.clearGoal(created.session_id)).target_id,null); +}); +test("binding confirms an empty native session is durable without starting a Goal", async (t) => { + const events: string[] = []; + const server = host("require-durable", {onEvent: event => events.push(event.method)}); + t.after(() => server.close()); + const bound = await server.create(); + assert.equal(bound.target_id, null); + assert.equal(bound.running, false); + assert.equal(bound.selected_model, null); + assert.deepEqual(events, ["fixture/sessionDurable"]); +}); +test("an unconfirmed empty persistence boundary cannot be reported as bound", async (t) => { + const server = host("empty-pause-rejected"); + t.after(() => server.close()); + await assert.rejects(server.create(), (error: unknown) => safeError("request_rejected")(error) + && (error as ZCodeProtocolError).protocolCode === -32004); +}); +test("metadata-only pause requires explicit confirmation that execution did not start", async (t) => { + for (const mode of ["empty-pause-started", "empty-pause-missing"]) { + const server = host(mode); + t.after(() => server.close()); + await assert.rejects(server.create(), safeError("unexpected_execution")); + } +}); +test("resumeSession binds existing Goal without starting it",async(t)=>{ + const server=host();t.after(()=>server.close()); + const resumed=await server.resumeSession("sess_fixture");assert.equal(resumed.target_id,"goal-existing");assert.equal(resumed.status,"paused"); + assert.equal((await server.resumeGoal(resumed.session_id)).running,true); +}); +test("NDJSON fragmented frame is assembled and concurrent reads are correlated",async(t)=>{ + const server=host("split");t.after(()=>server.close());await server.initialize();await server.create(); + const result=await Promise.all([server.readGoal("sess_fixture"),server.readGoal("sess_fixture")]);assert.equal(result.length,2); +}); +test("unbound and foreign Goal identity never receives mutation",async(t)=>{ + const server=host("replace-target");t.after(()=>server.close());await server.create();await server.setGoal("sess_fixture","Synthetic"); + await assert.rejects(server.pauseGoal("sess_fixture"),safeError("goal_identity_changed")); +}); +test("protocol and session identities are checked before accepting readback",async(t)=>{ + const protocol=host("wrong-protocol");const session=host("wrong-session");t.after(()=>Promise.all([protocol.close(),session.close()])); + await assert.rejects(protocol.create(),safeError("unsupported_protocol"));await assert.rejects(session.create(),safeError("session_identity_mismatch")); +}); +test("target identity and new status vocabulary fail closed",async(t)=>{ + for(const mode of ["wrong-target","unknown-status"]){const server=host(mode);t.after(()=>server.close());await server.create();await server.setGoal("sess_fixture","Synthetic");await assert.rejects(server.readGoal("sess_fixture"),safeError(mode==='wrong-target'?"target_identity_mismatch":"unsupported_goal_status"));} +}); +test("upstream error messages and stderr remain private",async(t)=>{ + const rejected=host("reject"),exited=host("exit");t.after(()=>Promise.all([rejected.close(),exited.close()])); + await assert.rejects(rejected.initialize(),(error: unknown)=>safeError("request_rejected")(error)&&(error as ZCodeProtocolError).protocolCode===-32009); + await assert.rejects(exited.initialize(),safeError("process_exited")); +}); +test("malformed and oversized frames terminate the owned host",async(t)=>{ + for(const [mode,code] of [["garbage","invalid_frame"],["limit","frame_limit"]]){const server=host(mode);t.after(()=>server.close());await assert.rejects(server.initialize(),safeError(code));await assert.rejects(server.create(),safeError(code));} +}); +test("request timeout and close reject pending calls within bounded time",async(t)=>{ + const timed=host("timeout",{timeoutMs:80});t.after(()=>timed.close());await assert.rejects(timed.initialize(),safeError("request_timeout")); + const closed=host("timeout");const request=closed.initialize();const rejected=assert.rejects(request,safeError("closed"));await closed.close();await rejected; +}); +test("reverse permissions are denied and unknown host interactions are rejected",async(t)=>{ + const events: string[]=[]; + const server=host("reverse",{onEvent:event=>events.push(event.method)});t.after(()=>server.close()); + await server.initialize();await server.create();await new Promise(resolve=>setTimeout(resolve,30)); + assert.deepEqual(events.sort(),["fixture/permissionDenied","fixture/unknownDenied"].sort()); +}); + + +test("model catalogue requires explicit available selection and preserves its reasoning",async(t)=>{ + const server=host();t.after(()=>server.close());const created=await server.create(); + assert.equal(created.selected_model,null);assert.equal(created.available_models[0].selection.providerId,"synthetic"); + await assert.rejects(server.selectModel(created.session_id,{providerId:"foreign",modelId:"synthetic",options:{reasoningLevel:"disabled"}}),safeError("model_unavailable")); + await assert.rejects(server.selectModel(created.session_id,{providerId:"synthetic",modelId:"synthetic"}),safeError("reasoning_unavailable")); + const selection={providerId:"synthetic",modelId:"synthetic",options:{reasoningLevel:"disabled"}}; + assert.deepEqual((await server.selectModel(created.session_id,selection)).selected_model,selection); + await server.setGoal(created.session_id,"Synthetic");await assert.rejects(server.selectModel(created.session_id,selection),safeError("model_change_requires_pause")); +}); + +test("stale native state revision rejects mutation without starting a Goal",async(t)=>{ + const server=host("stale-cas");t.after(()=>server.close());const created=await server.create(); + await assert.rejects(server.setGoal(created.session_id,"Synthetic"),(error:unknown)=>safeError("request_rejected")(error)&&(error as ZCodeProtocolError).protocolCode===-32009); + assert.equal((await server.readGoal(created.session_id)).target_id,null); +}); +test("budget-limited native status remains distinct and objective readback exposes only its hash",async(t)=>{ + const server=host("budget-limited");t.after(()=>server.close());const created=await server.create();const objective="Private synthetic objective"; + const receipt=await server.setGoal(created.session_id,` ${objective} `); + assert.equal(receipt.objective_sha256,createHash("sha256").update(objective).digest("hex"));assert.equal(JSON.stringify(receipt).includes(objective),false); + const read=await server.readGoal(created.session_id);assert.equal(read.status,"budget_limited");assert.equal(read.raw_status,"budget_limited");assert.equal(read.running,false); +}); diff --git a/tests/control_plane_ts/zcode_guard.test.ts b/tests/control_plane_ts/zcode_guard.test.ts new file mode 100644 index 0000000000..b95e6cf7f3 --- /dev/null +++ b/tests/control_plane_ts/zcode_guard.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process"; +import {mkdtemp, readFile, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join, resolve, sep} from "node:path"; +import {fileURLToPath} from "node:url"; +const guardian = fileURLToPath(new URL("../../loopx/zcode_goal_mode/guard.ts",import.meta.url)); +const writer = String.raw`const fs=require('node:fs');const path=process.argv[1];let n=0;setInterval(()=>fs.writeFileSync(path,String(++n)),20);`; +const cli = String.raw`const cp=require('node:child_process'),fs=require('node:fs');const path=process.argv[1];let n=0;const owned=cp.spawn(process.execPath,['-e',process.argv[2],path+'.child'],{stdio:'ignore',windowsHide:true});setInterval(()=>fs.writeFileSync(path,String(++n)),20);process.stdout.write(JSON.stringify({childPid:owned.pid})+'\n');process.stdin.resume();`; +async function pulse(path:string):Promise{try{return await readFile(path,"utf8");}catch{return null;}} +async function waitFor(fn:()=>Promise,timeout=5000):Promise{const end=Date.now()+timeout;while(!(await fn())){if(Date.now()>end)throw Error("Owned process did not reach its expected state");await new Promise(r=>setTimeout(r,20));}} +async function temporary(t:test.TestContext):Promise{const dir=await mkdtemp(join(tmpdir(),"loopx-zcode-guard-test-"));t.after(async()=>{assert.ok(resolve(dir).startsWith(resolve(tmpdir())+sep));await rm(dir,{recursive:true,force:true});});return join(dir,"pulse");} +async function stops(path:string):Promise{await new Promise(r=>setTimeout(r,300));const before=[await pulse(path),await pulse(path+".child")];assert.ok(before.every(v=>v!==null));await new Promise(r=>setTimeout(r,150));assert.deepEqual([await pulse(path),await pulse(path+".child")],before);} +function collect(process:ChildProcessWithoutNullStreams){let output="";process.stdout.on("data",data=>output+=data.toString());process.stderr.on("data",()=>{});return ()=>output;} + +test("guardian stdin EOF terminates both directly owned CLI and descendant",async(t)=>{ + const path=await temporary(t);const guard=spawn(process.execPath,["--no-warnings","--experimental-strip-types",guardian,"--",process.execPath,"-e",cli,path,writer],{stdio:"pipe",windowsHide:true});const output=collect(guard);t.after(()=>{guard.kill();}); + await waitFor(async()=>!!(await pulse(path))&&!!(await pulse(path+".child"))&&output().includes("childPid")); + guard.stdin.end();await waitFor(async()=>guard.exitCode!==null||guard.signalCode!==null);await stops(path);assert.equal(guard.exitCode,0); +}); +test("hard-killed broker revokes its pipe guardian and complete owned CLI tree",async(t)=>{ + const path=await temporary(t); + const brokerScript=String.raw`const cp=require('node:child_process');const child=cp.spawn(process.execPath,JSON.parse(process.argv[1]),{stdio:['pipe','pipe','ignore'],windowsHide:true});child.stdout.pipe(process.stdout);setInterval(()=>{},1000);`; + const argv=["--no-warnings","--experimental-strip-types",guardian,"--",process.execPath,"-e",cli,path,writer]; + const broker=spawn(process.execPath,["-e",brokerScript,JSON.stringify(argv)],{stdio:"pipe",windowsHide:true});const output=collect(broker);t.after(()=>{broker.kill();}); + await waitFor(async()=>!!(await pulse(path))&&!!(await pulse(path+".child"))&&output().includes("childPid")); + broker.kill("SIGKILL");await waitFor(async()=>broker.exitCode!==null||broker.signalCode!==null);await stops(path); +}); diff --git a/tests/control_plane_ts/zcode_native_goal.test.ts b/tests/control_plane_ts/zcode_native_goal.test.ts new file mode 100644 index 0000000000..00df16f630 --- /dev/null +++ b/tests/control_plane_ts/zcode_native_goal.test.ts @@ -0,0 +1,239 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {createHash} from "node:crypto"; +import {NativeGoalController, type BindingState, type NativeHost} from "../../loopx/zcode_goal_mode/runtime.ts"; +import type {NativeObservation, NativeRequest} from "../../loopx/zcode_goal_mode/contract.ts"; + +function fixture() { + const request: NativeRequest = {action: "bind", project: "/project", registry: "/registry", + state_path: "/state", goal_id: "goal_alpha", goal_ref: {goal_id: "goal_alpha", goal_instance_id: "ginst_alpha"}, + agent_id: "zcode", cli_command: ["zcode", "app-server"], loopx_command: ["loopx"], + validation_command: ["validate"], task_body: "Advance the registered Goal through the LoopX lifecycle"}; + let observation: NativeObservation = {session_id: "session-a", target_id: null, status: null, running: false, selected_model: {providerId: "local", modelId: "test"}}; + let permitted = true; + let valid = true; + let quotaFailure = false; + let journalFailure = false; + let journalFailureAfterSet = false; + const calls: string[] = []; + const journals: BindingState[] = []; + const host: NativeHost = { + initialize: async () => {calls.push("initialize");}, + create: async () => {calls.push("create"); return {...observation};}, + resumeSession: async () => {calls.push("restore"); return {...observation};}, + readGoal: async () => ({...observation}), + setGoal: async (_id, objective) => {calls.push("set"); observation = {...observation, target_id: "target-a", status: "active", running: true, objective_sha256: createHash("sha256").update(objective.trim()).digest("hex")}; return {...observation};}, + pauseGoal: async () => {calls.push("pause"); observation = {...observation, status: "paused", running: false}; return {...observation};}, + resumeGoal: async () => {calls.push("resume"); observation = {...observation, status: "active", running: true}; return {...observation};}, + selectModel: async (_id, selected_model) => {observation = {...observation, selected_model}; return {...observation};}, + clearGoal: async () => {calls.push("clear"); observation = {...observation, target_id: null, status: null, running: false, selected_model: {providerId: "local", modelId: "test"}}; return {...observation};}, + close: async () => {calls.push("close");}, + }; + const deps = {host, validate: async (cleanupOnly = false) => {if (!valid && !cleanupOnly) throw new Error("stale_goal_instance");}, + quota: async () => {if (quotaFailure) throw new Error("quota unavailable"); return {should_run: permitted, reason: permitted ? "work_available" : "goal_stopped", checked_at: "2026-01-01T00:00:00Z"};}, + persist: async (state: BindingState) => {if (journalFailure || (journalFailureAfterSet && calls.includes("set"))) throw new Error("journal unavailable"); journals.push(structuredClone(state));}}; + return {request, deps, calls, journals, observation: () => observation, + setPermitted: (value: boolean) => {permitted = value;}, + invalidate: () => {valid = false;}, failQuota: () => {quotaFailure = true;}, + failJournal: () => {journalFailure = true;}, failJournalAfterSet: () => {journalFailureAfterSet = true;}, + setObservation: (value: Partial) => {observation = {...observation, ...value};}, + complete: () => {observation = {...observation, status: "completed", running: false};}}; +} +async function connected() { + const f = fixture(); + const controller = new NativeGoalController(f.request, null, f.deps); + await controller.initialize(); + return {...f, controller}; +} +test("binding creates a real host session without starting a native Goal", async () => { + const f = await connected(); + assert.deepEqual(f.calls, ["initialize", "create"]); + assert.equal(f.controller.readback().native?.session_id, "session-a"); + assert.deepEqual(f.controller.readback().actions, ["status", "bind", "start"]); +}); +test("quota denial starts no native Goal and preserves the decision", async () => { + const f = await connected(); f.setPermitted(false); + const result = await f.controller.operate("start"); + assert.equal(result.quota?.should_run, false); + assert.equal(result.native?.target_id, null); + assert.equal(f.calls.includes("set"), false); +}); +test("pause and resume preserve exact native session and target", async () => { + const f = await connected(); + const start = await f.controller.operate("start"); + assert.equal(start.native?.running, true); + const paused = await f.controller.operate("pause"); + assert.equal(paused.native?.status, "paused"); + assert.equal(paused.native?.running, false); + const resumed = await f.controller.operate("resume"); + assert.equal(resumed.native?.session_id, start.native?.session_id); + assert.equal(resumed.native?.target_id, start.native?.target_id); + assert.equal(f.calls.filter(c => c === "set").length, 1); +}); +test("concurrent starts are serialized and never replace an existing native Goal", async () => { + const f = await connected(); + const results = await Promise.all([f.controller.operate("start"), f.controller.operate("start")]); + assert.equal(results[0].ok, true); + assert.equal(results[1].ok, false); + assert.equal(f.calls.filter(c => c === "set").length, 1); + assert.equal(f.observation().running, true); +}); +test("revoked quota pauses an executing target and blocks explicit resume", async () => { + const f = await connected(); + await f.controller.operate("start"); f.setPermitted(false); + await f.controller.check(); + assert.equal(f.observation().status, "paused"); + assert.equal(f.observation().running, false); + await f.controller.operate("resume"); + assert.equal(f.calls.includes("resume"), false); +}); +test("quota transport failure fails closed and is not a successful empty decision", async () => { + const f = await connected(); + await f.controller.operate("start"); f.failQuota(); + await f.controller.check(); + assert.equal(f.controller.readback().quota?.should_run, false); + assert.equal(f.observation().status, "paused"); +}); +test("stale authority pauses owned execution and never grants resume", async () => { + const f = await connected(); + await f.controller.operate("start"); f.invalidate(); + await f.controller.check(); + assert.equal(f.observation().status, "paused"); + const resume = await f.controller.operate("resume"); + assert.equal(resume.ok, false); + assert.equal(f.calls.includes("resume"), false); +}); +test("native completion is an observation and invokes no LoopX completion or credit write", async () => { + const f = await connected(); + await f.controller.operate("start"); f.complete(); + const result = await f.controller.operate("status"); + assert.equal(result.native?.status, "completed"); + assert.deepEqual(result.actions, ["status", "stop"]); + assert.deepEqual(f.calls, ["initialize", "create", "set"]); +}); +test("stop pauses before clearing and retains the same session for a later goal", async () => { + const f = await connected(); + await f.controller.operate("start"); + const result = await f.controller.operate("stop"); + assert.deepEqual(f.calls.slice(-2), ["pause", "clear"]); + assert.equal(result.native?.target_id, null); + assert.equal(result.native?.session_id, "session-a"); +}); +test("cold recovery reads and pauses the same target without automatically executing it", async () => { + const f = await connected(); + await f.controller.operate("start"); + const persisted = f.journals.at(-1)!; + const recovered = new NativeGoalController(f.request, persisted, f.deps); + await recovered.initialize(); + assert.equal(recovered.readback().native?.target_id, "target-a"); + assert.equal(recovered.readback().native?.status, "paused"); + assert.equal(f.calls.includes("restore"), true); + assert.equal(f.calls.includes("resume"), false); +}); +test("a different Goal instance cannot reuse the previous provider journal", () => { + const f = fixture(); + const state: BindingState = {schema: "loopx_zcode_native_binding_v0", request: f.request, + session_id: "session-a", target_id: "target-a", objective_sha256: "hash"}; + assert.throws(() => new NativeGoalController({...f.request, + goal_ref: {...f.request.goal_ref, goal_instance_id: "ginst_replacement"}}, state, f.deps), /identity changed/); + assert.equal(f.calls.length, 0); +}); +test("failed persistence after starting pauses the native side effect", async () => { + const f = await connected(); f.failJournalAfterSet(); + const result = await f.controller.operate("start"); + assert.equal(result.ok, false); + assert.equal(f.observation().running, false); + assert.equal(f.observation().status, "paused"); +}); +test("durability failure before start issues no native set command", async () => { + const f = await connected(); f.failJournal(); + const result = await f.controller.operate("start"); + assert.equal(result.ok, false); + assert.equal(f.calls.includes("set"), false); + assert.equal(f.observation().target_id, null); +}); + +test("legacy alias binding also fences the immutable creation witness", () => { + const f = fixture(); + const request = {...f.request, goal_ref: {goal_id: f.request.goal_id}, goal_creation_operation_id: "create-a"}; + const state: BindingState = {schema: "loopx_zcode_native_binding_v0", request, + session_id: "session-a", target_id: null, objective_sha256: "a".repeat(64)}; + assert.throws(() => new NativeGoalController({...request, goal_creation_operation_id: "create-b"}, state, f.deps), /identity changed/); +}); +test("lost set receipt recovers only its durable canonical objective and pauses", async () => { + const f = await connected(); + const text = " Canonical updated objective "; + await f.controller.operate("start", undefined, text); + const intent = f.journals.at(-2)!; + assert.equal(intent.start_pending, true); + assert.equal(intent.target_id, null); + assert.equal(intent.request.task_body, text.trim()); + const recovered = new NativeGoalController(f.request, intent, f.deps); + await recovered.initialize(); + assert.equal(recovered.state.start_pending, false); + assert.equal(recovered.readback().native?.target_id, "target-a"); + assert.equal(recovered.readback().native?.running, false); +}); +test("an unjournaled or different native objective cannot be adopted", async () => { + const f = await connected(); + const state = structuredClone(f.controller.state); + state.start_pending = true; + f.setObservation({target_id: "foreign-target", objective_sha256: "b".repeat(64), status: "paused"}); + const recovered = new NativeGoalController(f.request, state, f.deps); + await assert.rejects(recovered.initialize(), /unjournaled native Goal/); + assert.equal(f.calls.includes("set"), false); + assert.equal(f.calls.includes("clear"), false); +}); +test("offline cleanup can restore an inactive binding but cannot execute", async () => { + const f = await connected(); + await f.controller.operate("start"); + const state = structuredClone(f.controller.state); + f.invalidate(); + const cleanup = new NativeGoalController(f.request, state, f.deps); + await cleanup.initialize(true); + assert.equal((await cleanup.operate("status")).ok, true); + assert.equal((await cleanup.operate("resume")).ok, false); + assert.equal((await cleanup.operate("stop")).native?.target_id, null); + assert.equal(f.calls.includes("resume"), false); +}); +test("an unconfigured model blocks start until explicit session model selection", async () => { + const f = await connected(); f.setObservation({selected_model: null}); + const blocked = await f.controller.operate("start"); + assert.equal(blocked.ok, false); + assert.equal(f.calls.includes("set"), false); + const selection = {providerId: "configured", modelId: "explicit"}; + assert.equal((await f.controller.operate("select_model", selection)).native?.selected_model?.modelId, "explicit"); + assert.equal((await f.controller.operate("start")).native?.running, true); +}); + +test("unknown provider exceptions never expose arbitrary error text", async () => { + const f = await connected(); + f.deps.host.readGoal = async () => {throw new Error("marker-do-not-echo");}; + const result = await f.controller.operate("status"); + assert.equal(result.ok, false); + assert.equal(result.reason, "zcode_operation_failed"); + assert.equal(JSON.stringify(result).includes("marker-do-not-echo"), false); +}); + +test("status refreshes quota, hides denied execution and re-enables it after admission", async () => { + const f = await connected(); f.setPermitted(false); + let result = await f.controller.operate("status"); + assert.equal(result.quota?.should_run, false); + assert.equal(result.actions.includes("start"), false); + f.setPermitted(true); + result = await f.controller.operate("status"); + assert.equal(result.quota?.should_run, true); + assert.equal(result.actions.includes("start"), true); +}); + +test("a native background model failure is observed, paused and retained", async () => { + const f = await connected(); await f.controller.operate("start"); + f.setObservation({session_status: "error", running: false}); + await f.controller.check(); + assert.equal(f.observation().status, "paused"); + assert.equal(f.controller.readback().reason, "zcode_native_execution_failed"); + assert.equal(f.journals.at(-1)?.last_execution_error, "zcode_native_execution_failed"); + const status = await f.controller.operate("status"); + assert.equal(status.ok, false); + assert.equal(status.reason, "zcode_native_execution_failed"); +}); diff --git a/tests/extensions/test_process_runtime.py b/tests/extensions/test_process_runtime.py index 760b6b2ff8..bfb90839f1 100644 --- a/tests/extensions/test_process_runtime.py +++ b/tests/extensions/test_process_runtime.py @@ -11,6 +11,7 @@ import pytest +import loopx.extensions.process_runtime as process_runtime from loopx.extensions.process_runtime import run_capped_process, terminate_process_tree @@ -27,9 +28,12 @@ def killpg(pid: int, sig: int) -> None: signals.append((pid, sig)) monkeypatch.setattr(os, "killpg", killpg) + stopped = Mock() + monkeypatch.setattr(process_runtime, "_wait_for_posix_process_group_stop", stopped) terminate_process_tree(process, grace_seconds=0) assert signals == [(process.pid, signal.SIGKILL)] + stopped.assert_called_once_with(process.pid) process.kill.assert_called_once_with() process.wait.assert_called_once_with() @@ -68,7 +72,7 @@ def killpg(pid: int, sig: int) -> None: monkeypatch.setattr(sys, "platform", "darwin") monkeypatch.setattr(os, "killpg", killpg) - snapshot = Mock(return_value=subprocess.CompletedProcess([], 0, "1\n6789\n", "")) + snapshot = Mock(return_value=subprocess.CompletedProcess([], 0, "1 S\n6789 Z\n", "")) monkeypatch.setattr(subprocess, "run", snapshot) terminate_process_tree(process, grace_seconds) assert signals[-1] == signal.SIGKILL @@ -78,12 +82,12 @@ def killpg(pid: int, sig: int) -> None: @pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") @pytest.mark.parametrize("platform,leader_status,returncode,groups", [ - ("darwin", None, 0, "1\n"), - ("darwin", 0, 0, "1\n12345\n"), - ("darwin", 0, 1, "1\n"), + ("darwin", None, 0, "1 S\n"), + ("darwin", 0, 0, "1 S\n12345 S\n"), + ("darwin", 0, 1, "1 S\n"), ("darwin", 0, 0, ""), ("darwin", 0, 0, "invalid\n"), - ("linux", 0, 0, "1\n"), + ("linux", 0, 0, "1 S\n"), ]) def test_permission_error_stays_failure_when_owned_group_exit_is_unproven( monkeypatch: pytest.MonkeyPatch, platform: str, @@ -104,6 +108,133 @@ def denied(_pid: int, _sig: int) -> None: terminate_process_tree(process, 0) +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +def test_zero_grace_waits_for_live_descendants_after_leader_exit(monkeypatch: pytest.MonkeyPatch) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.poll.return_value = 0 + signals = [] + monkeypatch.setattr(os, "killpg", lambda pid, sig: signals.append((pid, sig))) + snapshots = Mock(side_effect=[ + subprocess.CompletedProcess([], 0, "1 S\n12345 S\n", ""), + subprocess.CompletedProcess([], 0, "1 S\n12345 Z\n", ""), + ]) + monkeypatch.setattr(subprocess, "run", snapshots) + pause = Mock() + monkeypatch.setattr(time, "sleep", pause) + terminate_process_tree(process, 0) + assert signals == [(12345, signal.SIGKILL), (12345, 0), (12345, 0)] + assert snapshots.call_count == 2 + pause.assert_called_once() + process.kill.assert_not_called() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +@pytest.mark.parametrize("state", ["T", "?", "S"]) +def test_stopped_or_unknown_group_state_does_not_certify_cleanup( + monkeypatch: pytest.MonkeyPatch, state: str, +) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.poll.return_value = 0 + monkeypatch.setattr(os, "killpg", lambda _pid, _sig: None) + monkeypatch.setattr(subprocess, "run", Mock(return_value= + subprocess.CompletedProcess([], 0, f"1 S\n12345 {state}\n", ""))) + clock = iter([0, .25, .5, 1.1]) + monkeypatch.setattr(time, "monotonic", lambda: next(clock)) + monkeypatch.setattr(time, "sleep", Mock()) + with pytest.raises(TimeoutError, match="cleanup deadline"): + terminate_process_tree(process, 0) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +@pytest.mark.parametrize("returncode,output", [(1, "1 S\n"), (0, ""), (0, "12345\n"), (0, "bad S\n")]) +def test_failed_or_malformed_observation_does_not_certify_cleanup( + monkeypatch: pytest.MonkeyPatch, returncode: int, output: str, +) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.poll.return_value = 0 + monkeypatch.setattr(os, "killpg", lambda _pid, _sig: None) + monkeypatch.setattr(subprocess, "run", Mock(return_value= + subprocess.CompletedProcess([], returncode, output, ""))) + with pytest.raises(RuntimeError, match="process-group observation"): + terminate_process_tree(process, 0) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +@pytest.mark.parametrize("error", [subprocess.TimeoutExpired(["ps"], 1), FileNotFoundError("ps absent")]) +def test_unavailable_observation_is_an_explicit_cleanup_failure( + monkeypatch: pytest.MonkeyPatch, error: Exception, +) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.poll.return_value = 0 + monkeypatch.setattr(os, "killpg", lambda _pid, _sig: None) + monkeypatch.setattr(subprocess, "run", Mock(side_effect=error)) + with pytest.raises(RuntimeError, match="process-group observation failed") as raised: + terminate_process_tree(process, 0) + assert raised.value.__cause__ is error + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +def test_signal_zero_permission_requires_fresh_zombie_observation(monkeypatch: pytest.MonkeyPatch) -> None: + process = Mock(spec=subprocess.Popen) + process.pid = 12345 + process.poll.return_value = 0 + + def signal_group(_pid: int, sig: int) -> None: + if sig == 0: + raise PermissionError("group observation denied") + + monkeypatch.setattr(os, "killpg", signal_group) + snapshot = Mock(return_value=subprocess.CompletedProcess([], 0, "1 S\n12345 Z+\n", "")) + monkeypatch.setattr(subprocess, "run", snapshot) + terminate_process_tree(process, 0) + snapshot.assert_called_once() + process.kill.assert_not_called() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") +@pytest.mark.parametrize("grace_seconds", [0, .1]) +def test_return_waits_until_child_with_closed_pipes_cannot_execute( + tmp_path: Path, grace_seconds: float, +) -> None: + ready = tmp_path / "child-pid" + heartbeat = tmp_path / "heartbeat" + child_code = ( + "import os,signal,time; from pathlib import Path; " + "signal.signal(signal.SIGTERM,signal.SIG_IGN); " + f"Path({str(ready)!r}).write_text(str(os.getpid()))\n" + f"while True: Path({str(heartbeat)!r}).touch(); time.sleep(.005)\n" + ) + provider_code = ( + "import subprocess,sys,time; " + f"subprocess.Popen([sys.executable,'-c',{child_code!r}], " + "stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL); " + "time.sleep(30)" + ) + process = subprocess.Popen([sys.executable, "-c", provider_code], start_new_session=True, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + deadline = time.monotonic() + 5 + while not heartbeat.exists() and time.monotonic() < deadline: + time.sleep(.005) + assert heartbeat.exists() + child_pid = int(ready.read_text()) + terminate_process_tree(process, grace_seconds) + assert process.poll() is not None + # Independently observe the child immediately after return, without a + # quiet-period delay that could hide asynchronous KILL delivery. + snapshot = subprocess.run(["ps", "-A", "-o", "pid=", "-o", "stat="], + check=True, capture_output=True, text=True, timeout=1) + states = [line.split()[1] for line in snapshot.stdout.splitlines() + if line.split() and line.split()[0] == str(child_pid)] + assert all(state.startswith("Z") for state in states), states + finally: + terminate_process_tree(process, 0) + + @pytest.mark.skipif(os.name != "posix", reason="POSIX process-group regression") def test_timeout_force_kills_descendant_that_ignores_term(tmp_path: Path) -> None: ready = tmp_path / "ready" diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index 42a9ce7a54..88c9895a1e 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -245,3 +245,15 @@ def test_frontend_source_inputs_ignore_placeholder_checkout_line_endings(tmp_pat asset.write_bytes(b"\n") assert builder.contract.source_inputs(tmp_path) != original + + +@pytest.mark.parametrize("name", ["contract.ts", "contract.json"]) +def test_provider_contract_update_invalidates_packaged_frontend(source, tmp_path, name): + # A changed caller contract must not ship with previously built controls. + contract = tmp_path / "loopx/zcode_goal_mode" / name + contract.parent.mkdir(parents=True) + contract.write_text("original contract", encoding="utf-8") + builder.build(None) + contract.write_text("changed contract", encoding="utf-8") + with pytest.raises(RuntimeError, match="source changed"): + validate_bundle(builder.OUTPUT, source_root=tmp_path) diff --git a/tests/test_chat_operation_actions.py b/tests/test_chat_operation_actions.py index fae0b153b2..8bc4687483 100644 --- a/tests/test_chat_operation_actions.py +++ b/tests/test_chat_operation_actions.py @@ -669,7 +669,7 @@ def consume(index: int) -> dict: @pytest.mark.parametrize("change", ["expires", "rebound", "stopped", "archived", "payload"]) def test_agent_handoff_fails_closed_on_expiry_binding_activation_or_terms_drift( - tmp_path: Path, change: str + tmp_path: Path, change: str, ) -> None: service, store = _service(tmp_path) proposal = _claim_agent_operation(service, store) @@ -969,11 +969,12 @@ def test_lifecycle_only_source_profile_cannot_acquire_new_operation_authority( assert (store.path.read_bytes() if store.path.exists() else None) == before -@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize("historical,expected_error", [(False, ActionConflictError), (True, ValueError)]) def test_replacement_session_reconciles_under_its_current_binding_without_reconsumption( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, historical: bool, + expected_error: type[Exception], ) -> None: from loopx.thread_agent_binding import ( bind_thread_agent_in_registry, diff --git a/tests/test_chat_todo_detail.py b/tests/test_chat_todo_detail.py index 82d38d58ff..c11d0fbfc2 100644 --- a/tests/test_chat_todo_detail.py +++ b/tests/test_chat_todo_detail.py @@ -40,11 +40,11 @@ def test_exact_task_cli_http_preserve_full_current_and_retained_request(tmp_path {"id": "reading-goal", "repo": str(tmp_path), "state_file": "state.md"}]})) before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="reading-goal") - def cli(*args, expected_exit=0): + def cli(*args, expected_code=0): process = subprocess.run([sys.executable, "-c", "from loopx.cli import main; raise SystemExit(main())", "--registry", str(registry), "--runtime-root", str(runtime), "--format", "json", "todo", "list", "--goal-id", "reading-goal", *args], capture_output=True, text=True, timeout=60) - assert process.returncode == expected_exit, process.stdout + process.stderr + assert process.returncode == expected_code, process.stdout + process.stderr return json.loads(process.stdout) hot = cli() @@ -54,8 +54,11 @@ def cli(*args, expected_exit=0): exact = cli("--todo-id", todo_id) assert exact["matched"] and exact["todo"]["text"] == text assert tail in exact["todo"]["text"] - thin_exact = cli("--todo-id", todo_id, "--thin", expected_exit=1) - assert "remove --thin" in thin_exact["error"] + rejected = cli("--todo-id", todo_id, "--thin", expected_code=1) + assert rejected["ok"] is False + assert "Exact Todo reads return full requirements" in rejected["error"] + assert "remove --thin" in rejected["error"] + assert "FINAL_ACCEPTANCE" not in rejected["error"] server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.registry_path, server.runtime_root_override, server.verbose = registry, str(runtime), False diff --git a/tests/test_history_artifact_observation.py b/tests/test_history_artifact_observation.py index 4f67c8d6d3..e7dd35acd8 100644 --- a/tests/test_history_artifact_observation.py +++ b/tests/test_history_artifact_observation.py @@ -149,7 +149,7 @@ def test_public_history_and_status_keep_artifacts_with_canonical_todos(history_c from loopx.control_plane.testing.canary_harness import write_fixture_registry isolate_sqlite_runtime(tmp_path, monkeypatch) - registry, runtime, _, _ = history_case + registry, runtime, index, _ = history_case state = tmp_path / "state.md" state.write_text("---\nstatus: active\n---\n# Example\n\n## Agent Todo\n") write_fixture_registry(project=tmp_path, registry_path=registry, runtime_root=runtime, @@ -160,6 +160,19 @@ def test_public_history_and_status_keep_artifacts_with_canonical_todos(history_c state_path=state, provider=provider, ) try: + # History reduction tolerates malformed audit rows; status also reads + # authoritative settlement evidence and must fail closed on corruption. + corrupted = subprocess.run( + [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), + "--runtime-root", str(runtime), "--format", "json", "status", + "--goal-id", "example", "--limit", "1"], + capture_output=True, text=True, timeout=60, + ) + assert corrupted.returncode == 1, corrupted.stdout + corrupted.stderr + assert "settlement readback" in json.loads(corrupted.stdout)["error"] + lines = index.read_text().splitlines() + assert lines[-3:] == ["not-json", "[]", ""] + index.write_text("\n".join(lines[:-3]) + "\n") for command in ("history", "status"): result = subprocess.run( [sys.executable, "-m", "loopx.entrypoint", "--registry", str(registry), diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index 6c804a4c39..f0241f52ee 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -1184,9 +1184,11 @@ def wait(_identity: object) -> dict[str, object]: assert denied["admission"]["next_eligible_at_ms"] == 9000 assert denied["effects"]["host_invoked"] is False assert calls == {"host": 0, "admit": 1, "writeback": 0, "spend": 0, "scheduler": 0} - assert not list( - (tmp_path / "runtime" / "goals" / "fixture-goal" / "turns").glob("*.json") - ) + # Lock-holder metadata is not a committed Turn journal on Windows. + assert not turn_journal_path( + tmp_path / "runtime", goal_id="fixture-goal", + turn_key=str(plan["transaction"]["turn_key"]), + ).exists() def allow(_identity: object) -> dict[str, object]: calls["admit"] += 1 @@ -1318,7 +1320,9 @@ def die_after_reservation(identity: Mapping[str, object]) -> dict[str, object]: assert [ (row["state"], row["request_id"]) for row in _cadence_starts(runtime_root) ] == [("reserved", f"{turn_key}:1")] - assert not list((runtime_root / "goals" / "fixture-goal" / "turns").glob("*.json")) + assert not turn_journal_path( + runtime_root, goal_id="fixture-goal", turn_key=turn_key, + ).exists() assert calls == {"host": 0, "writeback": 0, "spend": 0, "scheduler": 0} started_at_ms = int(_cadence_starts(runtime_root)[0]["started_at_ms"]) diff --git a/tests/test_manager_context_handoff.py b/tests/test_manager_context_handoff.py index c43b1d91a2..6756fb00fd 100644 --- a/tests/test_manager_context_handoff.py +++ b/tests/test_manager_context_handoff.py @@ -161,8 +161,10 @@ def test_stopped_goal_is_not_a_context_recipient_and_revokes_replay(fixture): assert authority(root, registry, session, turn)["targets"] == [ {"goal_id": "other", "agent_id": "peer"} ] + before = {path: path.read_bytes() for path in _root(root).rglob("*.json")} with pytest.raises(ValueError, match="stopped or archived"): deliver(root, registry, session=session, turn=turn, request=request) + assert before == {path: path.read_bytes() for path in _root(root).rglob("*.json")} assert len(pending(root, "research", "worker")["items"]) == 1 data["goals"][0]["activation_state"] = "active" @@ -183,8 +185,10 @@ def test_stopped_or_invalid_goal_is_excluded_from_lark_and_goal_chat(fixture, lo goal_session = {**session, "channel_id": "goal.research", "goal_id": "research"} assert authority(root, registry, goal_session, turn)["targets"] == [] + before = {path: path.read_bytes() for path in _root(root).rglob("*.json")} with pytest.raises(ValueError, match="stopped or archived"): deliver(root, registry, session=goal_session, turn=turn, request=request) + assert before == {path: path.read_bytes() for path in _root(root).rglob("*.json")} lark_session = {**session, "channel_id": "manager.external.group"} lark_turn = {**turn, "origin": "lark"} @@ -200,8 +204,10 @@ def test_stopped_or_invalid_goal_is_excluded_from_lark_and_goal_chat(fixture, lo message=turn["message"], source_id="lark:original") expected = [] if local_scope == "selected" else [{"goal_id": "other", "agent_id": "peer"}] assert authority(root, registry, lark_session, lark_turn)["targets"] == expected + before = {path: path.read_bytes() for path in _root(root).rglob("*.json")} with pytest.raises(ValueError, match="stopped or archived"): deliver(root, registry, session=lark_session, turn=lark_turn, request=request) + assert before == {path: path.read_bytes() for path in _root(root).rglob("*.json")} data["goals"][0]["activation"]["state"] = "unreadable" registry.write_text(json.dumps(data)) diff --git a/tests/test_python_ci_workflow.py b/tests/test_python_ci_workflow.py index b8484204b1..5f2fd93416 100644 --- a/tests/test_python_ci_workflow.py +++ b/tests/test_python_ci_workflow.py @@ -17,9 +17,12 @@ def test_dashboard_acceptance_and_kernel_checks_run_independently() -> None: kernel = WORKFLOW.split(" kernel-static-checks:\n", 1)[1].split( + " dashboard-browser:\n", 1, + )[0] + dashboard = WORKFLOW.split(" dashboard-browser:\n", 1)[1].split( " dashboard-acceptance:\n", 1, )[0] - dashboard = WORKFLOW.split(" dashboard-acceptance:\n", 1)[1].split( + coverage = WORKFLOW.split(" dashboard-acceptance:\n", 1)[1].split( " checks:\n", 1, )[0] aggregate = WORKFLOW.split(" checks:\n", 1)[1].split( @@ -39,6 +42,11 @@ def test_dashboard_acceptance_and_kernel_checks_run_independently() -> None: assert int(dashboard_timeout.group(1)) >= 25 assert "python -m ruff check" not in dashboard assert "python -m mypy" not in dashboard + assert "shard: [1, 2, 3]" in dashboard + assert "LOOPX_PERSONAL_WORKSPACE_SHARD" in dashboard + assert "needs: [changes, dashboard-browser]" in coverage + assert "merge-dashboard-coverage.mjs" in coverage + assert "name: dashboard-coverage\n" in coverage assert "if: always() && needs.changes.outputs.core_tests == 'true'" in aggregate assert "NEEDS_JSON: ${{ toJSON(needs) }}" in aggregate @@ -259,7 +267,7 @@ def test_presentation_exemption_retains_real_frontend_checks_and_force_full() -> assert "workflow_dispatch:" in WORKFLOW assert "--force-full" in WORKFLOW assert "impact-shadow" not in WORKFLOW - assert "matrix:\n shard: [1, 2, 3, 4]" in WORKFLOW + assert "matrix:\n shard: [1, 2, 3, 4, 5, 6]" in WORKFLOW def test_windows_lane_rebuilds_the_frontend_without_a_usable_python3() -> None: @@ -294,10 +302,10 @@ def test_windows_lifecycle_suite_references_existing_tests() -> None: ] == [] -def test_four_shards_execute_each_test_once_and_merge_portable_coverage( +def test_six_shards_execute_each_test_once_and_merge_portable_coverage( tmp_path: Path, ) -> None: - # Real pytest-split + xdist + coverage, in four distinct checkout roots. + # Real pytest-split + xdist + coverage, in six distinct checkout roots. # Each shard alone misses a function; their union must cover the whole file. shard_step = WORKFLOW.split("name: Run test shard", 1)[1] template = shard_step.split("run: >-", 1)[1].split(" - name:", 1)[0] @@ -306,7 +314,7 @@ def test_four_shards_execute_each_test_once_and_merge_portable_coverage( if not key.startswith(("COVERAGE", "COV_CORE", "PYTEST")) } seen: list[set[str]] = [] - for shard in (1, 2, 3, 4): + for shard in (1, 2, 3, 4, 5, 6): root = tmp_path / f"checkout-{shard}" root.mkdir() (root / "ci_subject.py").write_text( @@ -318,7 +326,9 @@ def test_four_shards_execute_each_test_once_and_merge_portable_coverage( "def test_first_one():\n assert first() == 1\n" "def test_first_two():\n assert first() == 1\n" "def test_second_one():\n assert second() == 2\n" - "def test_second_two():\n assert second() == 2\n", + "def test_second_two():\n assert second() == 2\n" + "def test_first_three():\n assert first() == 1\n" + "def test_second_three():\n assert second() == 2\n", encoding="utf-8", ) (root / "pyproject.toml").write_text( @@ -345,8 +355,8 @@ def test_four_shards_execute_each_test_once_and_merge_portable_coverage( (root / ".coverage").rename(destination / ".coverage") assert all(seen) - assert sum(map(len, seen)) == len(set.union(*seen)) == 4 - assert set.union(*seen) == {"test_first_one", "test_first_two", "test_second_one", "test_second_two"} + assert sum(map(len, seen)) == len(set.union(*seen)) == 6 + assert set.union(*seen) == {"test_first_one", "test_first_two", "test_second_one", "test_second_two", "test_first_three", "test_second_three"} # Reuse the real aggregate shell commands, with a 100% synthetic oracle. step = WORKFLOW.split("name: Combine complete coverage", 1)[1] script = step.split("run: |", 1)[1].split(" - uses:", 1)[0] @@ -354,7 +364,7 @@ def test_four_shards_execute_each_test_once_and_merge_portable_coverage( script = script.replace("--fail-under=19.6", "--fail-under=100") root = tmp_path / "checkout-1" (tmp_path / "coverage-shards").rename(root / "coverage-shards") - for shard in (1, 2, 3, 4): + for shard in (1, 2, 3, 4, 5, 6): data = root / "coverage-shards" / f"python-coverage-{shard}" / ".coverage" held = data.with_name("held") data.rename(held) @@ -377,7 +387,7 @@ def test_four_shards_execute_each_test_once_and_merge_portable_coverage( capture_output=True, check=False, ) assert missing.returncode != 0 - assert re.search(r"shard: \[1, 2, 3, 4\]", WORKFLOW) + assert re.search(r"shard: \[1, 2, 3, 4, 5, 6\]", WORKFLOW) assert "include-hidden-files: true" in WORKFLOW assert "--cov-fail-under" not in template @@ -387,7 +397,7 @@ def test_backend_and_mixed_prs_require_the_browser_qualified_artifact() -> None: assert "needs.changes.outputs.core_tests == 'true'" in producer aggregate = WORKFLOW.split(" checks:\n", 1)[1].split(" steps:", 1)[0] assert "chat-bundle-browser" in aggregate - for name in ("chat-bundle-browser", "kernel-static-checks", "typescript-core", "dashboard-acceptance", "test-shard", "stage2c-suite", "windows-powershell", "presentation"): + for name in ("chat-bundle-browser", "kernel-static-checks", "typescript-core", "dashboard-browser", "test-shard", "stage2c-suite", "windows-powershell", "presentation"): job = WORKFLOW.split(f" {name}:\n", 1)[1].split(" - uses: actions/setup-", 1)[0] assert "needs: [changes, chat-bundle]" in job assert "name: chat-bundle-${{ github.sha }}" in job @@ -417,7 +427,7 @@ def test_typescript_shards_select_every_test_file_exactly_once() -> None: def test_typescript_core_shards_feed_one_complete_coverage_report() -> None: core = WORKFLOW.split(" typescript-core:\n", 1)[1].split(" typescript-coverage:\n", 1)[0] - report = WORKFLOW.split(" typescript-coverage:\n", 1)[1].split(" dashboard-acceptance:\n", 1)[0] + report = WORKFLOW.split(" typescript-coverage:\n", 1)[1].split(" dashboard-browser:\n", 1)[0] assert "shard: [1, 2, 3]" in core assert "fail-fast: false" in core assert 'node-version: "22.22.3"' in core diff --git a/tests/test_workspace_story_demo.py b/tests/test_workspace_story_demo.py index 3bf173c179..95af4d2338 100644 --- a/tests/test_workspace_story_demo.py +++ b/tests/test_workspace_story_demo.py @@ -10,6 +10,7 @@ from demo.workspace.__main__ import prepare from loopx.control_plane.todos.handoff_mode import show_goal_handoff_mode from loopx.todos import list_goal_todos +from loopx.control_plane.work_items.task_lease import inspect_task_lease def test_refuses_existing_work_and_symlink(tmp_path): @@ -69,11 +70,18 @@ def todos(goal_id): )["todos"] before = {g["id"]: todos(g["id"]) for g in manifest["goals"]} - for rows in before.values(): + for goal_id, rows in before.items(): agents = [t for t in rows if t["role"] == "agent"] assert len(agents) == 20 assert len({t["claimed_by"] for t in agents}) == 4 assert sum(t["status"] == "done" for t in agents) == 7 + completed = next(t for t in agents if t["status"] == "done") + lease = inspect_task_lease( + registry_path=root / "registry.json", runtime_root=root / "runtime", + goal_id=goal_id, todo_id=completed["todo_id"], + ) + assert lease["handoff_mode"] == "soft_claim" + assert lease["lease"] is None, "authored replay must not create execution leases" assert sum(t["status"] == "deferred" for t in agents) == 2 assert sum(t["status"] == "blocked" for t in agents) == 4 assert len([t for t in rows if t["role"] == "user" and not t["done"]]) == 2 diff --git a/tests/test_zcode_doctor_skill_delivery.py b/tests/test_zcode_doctor_skill_delivery.py new file mode 100644 index 0000000000..2dadeb964e --- /dev/null +++ b/tests/test_zcode_doctor_skill_delivery.py @@ -0,0 +1,428 @@ +"""ZCode facade diagnostics inspect the installer-owned files without mutation.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from loopx import doctor +from loopx.slash_command_files import MANAGED_MARKER_PREFIX +from loopx.slash_command_install import inspect_skill_facades, install_slash_commands + + +@pytest.fixture +def doctor_context(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Keep general doctor infrastructure and all host state in synthetic fixtures.""" + home = tmp_path / "home" + zcode_home = home / ".zcode" + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("ZCODE_HOME", str(zcode_home)) + monkeypatch.delenv("ZCODE_AGENTS_HOME", raising=False) + monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) + monkeypatch.delenv("LOOPX_RELEASE_ROOT", raising=False) + command = tmp_path / "bin" / "loopx" + command.parent.mkdir() + command.write_text("synthetic command; never executed", encoding="utf-8") + runtime = tmp_path / "runtime" + monkeypatch.setattr(doctor, "default_runtime_route", lambda: { + "selected_runtime_root": str(runtime), "status": "configured", "recommended_action": None, + }) + monkeypatch.setattr(doctor, "resolve_command_path", lambda name: command if name == "loopx" else None) + monkeypatch.setattr(doctor, "current_script_invocation_path", lambda: None) + monkeypatch.setattr(doctor, "python_distribution_install", lambda _: {"available": False}) + monkeypatch.setattr(doctor.doctor_git, "trusted_release_ref_for_root", lambda *args, **kwargs: None) + monkeypatch.setattr(doctor, "latest_promotion_readiness_event", lambda _: {"available": False}) + monkeypatch.setattr(doctor, "probe_registry_write_path", lambda *args, **kwargs: {"ok": True}) + monkeypatch.setattr( + "loopx.control_plane.effect_runtime.collect_effect_runtime_readiness", + lambda *, deep: {"ready": True, "status": "ready"}, + ) + monkeypatch.setattr( + "loopx.capabilities.decision_context.freshness.collect_capture_host_diagnostics", + lambda _: {"healthy": True, "hosts": []}, + ) + monkeypatch.setattr( + "loopx.desktop_installation.desktop_installation_status", + lambda _: {"apps": [], "status": "not_installed"}, + ) + monkeypatch.setattr( + "loopx.zcode_goal_mode.diagnostics.collect_zcode_host_diagnostics", + lambda **kwargs: { + "cli": {"status": "not_discovered"}, + "desktop": {"status": "not_discovered"}, + "source_checkout": {"status": "not_selected"}, + "probe_boundary": "Synthetic host diagnostics; no host was executed.", + }, + ) + return zcode_home + + +def _install(home: Path, *, cli_bin: str = "loopx") -> None: + install_slash_commands(execute=True, surfaces=["zcode"], zcode_home=str(home), cli_bin=cli_bin) + + +def _forbid_codex_readback(monkeypatch: pytest.MonkeyPatch) -> None: + def forbidden(*args, **kwargs): + raise AssertionError("ZCode diagnostics must not read Codex skill roots") + monkeypatch.setattr(doctor, "codex_skill_roots", forbidden) + monkeypatch.setattr(doctor, "installed_skill_summary", forbidden) + + +def test_zcode_only_install_reports_ready_without_codex_dependency( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _install(doctor_context) + _forbid_codex_readback(monkeypatch) + before = {path: path.read_bytes() for path in doctor_context.rglob("SKILL.md")} + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["skill_delivery"]["status"] == "ready" + assert payload["skill_delivery"]["codex_skills_root_applicable"] is False + assert payload["skill_delivery"]["skill_roots"] == [str(doctor_context / "skills")] + assert payload["skill"]["path"] == str(doctor_context / "skills/loopx/SKILL.md") + assert "loopx-project" not in payload["skills"] + assert all(skill["readback_status"] == "ready" for skill in payload["skills"].values()) + assert payload["install_freshness"]["requires_upgrade"] is False + assert before == {path: path.read_bytes() for path in doctor_context.rglob("SKILL.md")} + + +def test_codex_only_install_does_not_satisfy_zcode_surface( + doctor_context: Path, +) -> None: + codex_skills = doctor_context.parent / ".codex/skills" + for name, phrases in doctor.REQUIRED_INSTALLED_SKILL_PHRASES.items(): + path = codex_skills / name / "SKILL.md" + path.parent.mkdir(parents=True) + path.write_text("\n".join(phrases), encoding="utf-8") + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["skill_delivery"]["status"] == "repair_recommended" + assert all(skill["readback_status"] == "missing" for skill in payload["skills"].values()) + assert payload["install_freshness"]["requires_upgrade"] is True + assert "--surface zcode" in payload["install_freshness"]["upgrade_command"] + assert "--zcode-home" in payload["fix"] + # Skill delivery remains an optional integration check, not CLI installation failure. + skill_checks = [check for check in payload["checks"] if check["id"].startswith("installed_")] + assert skill_checks and all(check["required"] is False for check in skill_checks) + assert payload["ok"] is True + + +@pytest.mark.parametrize("damage,status", [ + ("remove_continuation_rule", "stale"), ("user_file", "user_owned"), ("invalid_utf8", "unreadable"), +]) +def test_damaged_entry_reports_the_problem_and_preserves_files( + doctor_context: Path, damage: str, status: str, +) -> None: + _install(doctor_context) + entry = doctor_context / "skills/loopx/SKILL.md" + if damage == "remove_continuation_rule": + text = entry.read_text(encoding="utf-8") + assert "stop/gate rules" in text + entry.write_text(text.replace("stop/gate rules", "ignore stopping conditions"), encoding="utf-8") + elif damage == "user_file": + entry.write_text("# My own loopx skill\n", encoding="utf-8") + else: + entry.write_bytes(b"invalid UTF-8: \xff") + before = entry.read_bytes() + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["skills"]["loopx"]["readback_status"] == status + assert payload["skill_delivery"]["status"] == "repair_recommended" + assert payload["install_freshness"]["requires_upgrade"] is True + assert entry.read_bytes() == before + markdown = doctor.render_doctor_markdown(payload) + assert f"`{status}`" in markdown and str(entry) in markdown + if damage == "user_file": + assert "name collision" in markdown + _install(doctor_context) + assert entry.read_bytes() == before + + +def test_missing_global_facade_is_reported_when_task_entry_is_current(doctor_context: Path) -> None: + _install(doctor_context) + missing = doctor_context / "skills/loopx-global-summary/SKILL.md" + missing.unlink() + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["skills"]["loopx"]["readback_status"] == "ready" + assert payload["skills"]["loopx-global-summary"]["readback_status"] == "missing" + assert payload["skill_delivery"]["status"] == "repair_recommended" + + +@pytest.mark.parametrize("cli_bin", ["loopx-alt", "uv run loopx", '"C:/tool directory/loopx.exe"']) +def test_current_custom_cli_invocation_is_not_misclassified(tmp_path: Path, cli_bin: str) -> None: + _install(tmp_path, cli_bin=cli_bin) + summary = inspect_skill_facades(tmp_path / "skills") + assert all(skill["readback_status"] == "ready" for skill in summary.values()) + assert summary["loopx"]["cli_bin"] == cli_bin + + +def test_inconsistent_custom_cli_invocation_is_stale(tmp_path: Path) -> None: + _install(tmp_path, cli_bin="loopx-alt") + entry = tmp_path / "skills/loopx/SKILL.md" + text = entry.read_text(encoding="utf-8") + assert text.count("loopx-alt") > 1 + entry.write_text(text.replace("loopx-alt", "different-command", 1), encoding="utf-8") + assert inspect_skill_facades(tmp_path / "skills")["loopx"]["readback_status"] == "stale" + + + +@pytest.mark.parametrize("prefix", [b"# User-owned facade\n", MANAGED_MARKER_PREFIX.encode("utf-8")]) +def test_oversized_facade_is_preserved_without_matching( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, prefix: bytes, +) -> None: + entry = tmp_path / "skills/loopx/SKILL.md" + entry.parent.mkdir(parents=True) + content = prefix + b"x" * (1024 * 1024) + entry.write_bytes(content) + def forbidden(*args, **kwargs): + raise AssertionError("An oversized facade must not enter the template matcher") + monkeypatch.setattr("loopx.slash_command_install.re.fullmatch", forbidden) + summary = inspect_skill_facades(tmp_path / "skills")["loopx"] + assert summary["readback_status"] == "unreadable" + assert summary["required_phrases"] is False + assert "1 MiB diagnostic read limit" in summary["reason"] + assert "file is preserved" in summary["reason"] + assert entry.read_bytes() == content + + +def test_doctor_uses_legacy_home_and_current_home_precedence( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + legacy = tmp_path / "legacy-zcode" + _install(legacy) + monkeypatch.setenv("ZCODE_AGENTS_HOME", str(legacy)) + monkeypatch.delenv("ZCODE_HOME") + assert doctor.collect_doctor(agent_type="zcode")["skill_delivery"]["status"] == "ready" + monkeypatch.setenv("ZCODE_HOME", str(doctor_context)) + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["skill_delivery"]["status"] == "repair_recommended" + assert payload["skill_delivery"]["skill_roots"] == [str(doctor_context / "skills")] + + +def test_non_zcode_diagnostics_keep_existing_skill_owner_and_required_semantics(doctor_context: Path) -> None: + _install(doctor_context) + default = doctor.collect_doctor() + codex = doctor.collect_doctor(agent_type="codex-cli") + assert "zcode" not in default and "zcode" not in codex + assert default["skills"] == codex["skills"] + assert default["skill"] == codex["skill"] + assert default["checks"] == codex["checks"] + assert default["skill_delivery"]["codex_skills_root_applicable"] is True + assert codex["skill_delivery"]["status"] == "repair_recommended" + assert "--surface zcode" not in default["fix"] + assert "skill_repair_command" not in default["install_freshness"] + + +@pytest.mark.parametrize("agent_type,installation_only", [(None, False), ("codex-cli", False), ("zcode", True)]) +def test_zcode_paths_require_explicit_host_scope_before_inspection( + monkeypatch: pytest.MonkeyPatch, agent_type: str | None, installation_only: bool, +) -> None: + def forbidden(*args, **kwargs): + raise AssertionError("Invalid host options must fail before installation or user-state inspection") + monkeypatch.setattr(doctor, "default_runtime_route", forbidden) + monkeypatch.setattr("loopx.release_candidate.collect_installation_doctor", forbidden) + with pytest.raises(ValueError, match="--agent-type zcode"): + doctor.collect_doctor(agent_type=agent_type, installation_only=installation_only, zcode_cli="fixture-cli") + + +def test_selected_host_paths_reach_only_the_zcode_diagnostic_owner( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + captured = {} + def collect(**kwargs): + captured.update(kwargs) + return {"synthetic_host_probe": True} + monkeypatch.setattr("loopx.zcode_goal_mode.diagnostics.collect_zcode_host_diagnostics", collect) + payload = doctor.collect_doctor( + agent_type="z-code", zcode_cli="fixture-cli", zcode_desktop="fixture-desktop", zcode_source="fixture-source", + ) + assert captured == {"cli_path": "fixture-cli", "desktop_path": "fixture-desktop", "source_root": "fixture-source"} + assert payload["zcode"] == {"synthetic_host_probe": True} + + +def test_doctor_cli_parses_and_forwards_explicit_zcode_paths( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + from loopx.cli import build_parser + from loopx.cli_commands import doctor as command + registry = tmp_path / "registry.json" + runtime = tmp_path / "runtime" + cli, desktop, source = (str(tmp_path / name) for name in ("cli", "desktop", "source")) + args = build_parser().parse_args([ + "--runtime-root", str(runtime), "--format", "json", "doctor", "--agent-type", "zcode", + "--zcode-cli", cli, "--zcode-desktop", desktop, "--zcode-source", source, + ]) + captured = {} + printed = [] + def collect(**kwargs): + captured.update(kwargs) + return {"ok": True} + monkeypatch.setattr(command, "collect_doctor", collect) + assert command.handle_doctor_command( + args, lambda *values: printed.append(values), registry_path=registry, + ) == 0 + assert captured == { + "deep": False, "agent_type": "zcode", "installation_only": False, + "registry_path": registry, "runtime_root_override": str(runtime), + "zcode_cli": cli, "zcode_desktop": desktop, "zcode_source": source, + } + assert printed == [({"ok": True}, "json", doctor.render_doctor_markdown)] + + +@pytest.mark.parametrize("scope", [[], ["--agent-type", "codex-cli"], ["--installation-only"]]) +def test_doctor_cli_rejects_zcode_paths_before_restart_or_collection( + monkeypatch: pytest.MonkeyPatch, scope: list[str], +) -> None: + from loopx.cli import build_parser + from loopx.cli_commands import doctor as command + args = build_parser().parse_args([ + "doctor", *scope, "--zcode-cli", "synthetic-cli", "--restart-runtime", + ]) + def forbidden(*args, **kwargs): + raise AssertionError("Wrong-scope options must fail before any restart, collection or output") + monkeypatch.setattr(command, "collect_doctor", forbidden) + monkeypatch.setattr("loopx.control_plane.effect_runtime.restart_effect_runtime", forbidden) + with pytest.raises(ValueError, match="--agent-type zcode"): + command.handle_doctor_command(args, forbidden) + + +def test_missing_loopx_command_preserves_install_recovery_before_zcode_skills( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(doctor, "resolve_command_path", lambda _: None) + monkeypatch.setattr(doctor, "current_script_invocation_path", lambda: None) + generic = doctor.collect_doctor() + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["ok"] is False + assert payload["install_freshness"]["status"] == "missing" + assert payload["install_freshness"]["upgrade_command"] == doctor.no_clone_upgrade_command( + doctor_agent_type="zcode", + ) + assert payload["fix"].startswith(generic["fix"] + "\nAfter restoring the LoopX installation/runtime, ") + assert "--surface zcode" not in generic["fix"] + assert payload["skill_delivery"]["repair_command"] in payload["fix"] + assert payload["install_freshness"]["upgrade_command"] != payload["skill_delivery"]["repair_command"] + + +def test_unready_effect_runtime_preserves_general_recovery_before_zcode_skills( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + _install(doctor_context) + monkeypatch.setattr( + "loopx.control_plane.effect_runtime.collect_effect_runtime_readiness", + lambda *, deep: {"ready": False, "status": "missing"}, + ) + generic = doctor.collect_doctor() + payload = doctor.collect_doctor(agent_type="zcode") + assert payload["ok"] is False + assert payload["skill_delivery"]["status"] == "ready" + runtime_check = next( + check for check in payload["checks"] if check["id"] == "typescript_effect_runtime_ready" + ) + assert runtime_check["required"] is True and runtime_check["ok"] is False + assert payload["fix"].startswith(generic["fix"] + "\nAfter restoring the LoopX installation/runtime, ") + assert "--surface zcode" not in generic["fix"] + assert payload["skill_delivery"]["repair_command"] in payload["fix"] + + +def test_current_zcode_facades_do_not_hide_release_package_version_mismatch( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + from loopx import __version__ + from loopx.release_manifest import RELEASE_MANIFEST_FILENAME + _install(doctor_context) + release_root = tmp_path / "synthetic-release" + release_root.mkdir() + manifest_version = "0.0.0" if __version__ != "0.0.0" else "0.0.1" + (release_root / RELEASE_MANIFEST_FILENAME).write_text( + json.dumps({"package": {"version": manifest_version}, "source": {"ref": "stable"}}), + encoding="utf-8", + ) + monkeypatch.setenv("LOOPX_RELEASE_ROOT", str(release_root)) + generic = doctor.collect_doctor() + payload = doctor.collect_doctor(agent_type="zcode") + freshness = payload["install_freshness"] + assert payload["ok"] is True + assert payload["skill_delivery"]["status"] == "ready" + assert all(skill["readback_status"] == "ready" for skill in payload["skills"].values()) + assert freshness["status"] == "repair_recommended" + assert freshness["requires_upgrade"] is True + assert freshness["reason"] == "release manifest package version differs from runtime package version" + assert freshness["manifest_package_version_matches_runtime"] is False + assert freshness["upgrade_command"] == doctor.no_clone_upgrade_command( + "stable", doctor_agent_type="zcode", + ) + assert "--surface zcode" not in freshness["upgrade_command"] + assert payload["fix"].startswith(generic["fix"]) + assert payload["skill_delivery"]["repair_command"] in payload["fix"][len(generic["fix"]):] + + +@pytest.mark.parametrize("installation_problem", ["package_mismatch", "aged_release"]) +@pytest.mark.parametrize("facade_problem", ["missing", "stale"]) +def test_mixed_installation_and_facade_problems_keep_both_recovery_owners( + doctor_context: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + installation_problem: str, facade_problem: str, +) -> None: + from loopx import __version__ + from loopx.release_manifest import RELEASE_MANIFEST_FILENAME + _install(doctor_context) + entry = doctor_context / "skills/loopx/SKILL.md" + if facade_problem == "missing": + entry.unlink() + else: + text = entry.read_text(encoding="utf-8") + entry.write_text(text.replace("stop/gate rules", "ignore stopping conditions"), encoding="utf-8") + before = {path: path.read_bytes() for path in doctor_context.rglob("SKILL.md")} + release_root = ( + tmp_path / "releases/20000101T000000Z" + if installation_problem == "aged_release" else tmp_path / "synthetic-release" + ) + release_root.mkdir(parents=True) + manifest_version = __version__ + if installation_problem == "package_mismatch": + manifest_version = "0.0.0" if __version__ != "0.0.0" else "0.0.1" + (release_root / RELEASE_MANIFEST_FILENAME).write_text( + json.dumps({"package": {"version": manifest_version}, "source": {"ref": "stable"}}), + encoding="utf-8", + ) + monkeypatch.setenv("LOOPX_RELEASE_ROOT", str(release_root)) + generic = doctor.collect_doctor() + payload = doctor.collect_doctor(agent_type="zcode") + freshness = payload["install_freshness"] + assert payload["ok"] is True + assert payload["skills"]["loopx"]["readback_status"] == facade_problem + assert freshness["status"] == "repair_recommended" + assert freshness["reason"] == "installed LoopX skills are missing or stale" + assert freshness["requires_upgrade"] is True + if installation_problem == "package_mismatch": + assert freshness["manifest_package_version_matches_runtime"] is False + else: + assert freshness["release_age_hours"] > doctor.INSTALL_FRESHNESS_STALE_HOURS + owner_command = doctor.no_clone_upgrade_command("stable", doctor_agent_type="zcode") + repair_command = payload["skill_delivery"]["repair_command"] + assert freshness["upgrade_command"].startswith(owner_command + "\n") + assert repair_command in freshness["upgrade_command"][len(owner_command):] + assert payload["fix"].startswith(generic["fix"] + "\nAfter restoring the LoopX installation/runtime, ") + assert repair_command in payload["fix"][len(generic["fix"]):] + markdown = doctor.render_doctor_markdown(payload) + assert owner_command in markdown and repair_command in markdown + assert before == {path: path.read_bytes() for path in doctor_context.rglob("SKILL.md")} + + # Skill repair cannot settle an independent installation problem. + _install(doctor_context) + skills_repaired = doctor.collect_doctor(agent_type="zcode") + assert skills_repaired["skill_delivery"]["status"] == "ready" + assert skills_repaired["install_freshness"]["requires_upgrade"] is True + assert skills_repaired["install_freshness"]["upgrade_command"] == owner_command + + repaired_release = tmp_path / "repaired-release" + repaired_release.mkdir() + (repaired_release / RELEASE_MANIFEST_FILENAME).write_text( + json.dumps({"package": {"version": __version__}, "source": {"ref": "stable"}}), + encoding="utf-8", + ) + monkeypatch.setenv("LOOPX_RELEASE_ROOT", str(repaired_release)) + recovered = doctor.collect_doctor(agent_type="zcode") + assert recovered["ok"] is True + assert recovered["skill_delivery"]["status"] == "ready" + assert all(skill["readback_status"] == "ready" for skill in recovered["skills"].values()) + assert recovered["install_freshness"]["requires_upgrade"] is False + assert recovered["install_freshness"]["manifest_package_version_matches_runtime"] is True diff --git a/tests/test_zcode_goal_bridge.py b/tests/test_zcode_goal_bridge.py new file mode 100644 index 0000000000..d98f085de6 --- /dev/null +++ b/tests/test_zcode_goal_bridge.py @@ -0,0 +1,688 @@ +from __future__ import annotations + +import json +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from loopx.zcode_goal_mode import bridge +from loopx.zcode_goal_mode.api import ZCodeGoalRequestMixin, public_zcode_goal_readback + +INSTANCE_A = "ginst_" + "a" * 32 +INSTANCE_B = "ginst_" + "b" * 32 + + +@pytest.fixture +def authority(tmp_path: Path) -> tuple[Path, Path, dict]: + project = tmp_path / "project" + project.mkdir() + registry = project / ".loopx" / "registry.json" + registry.parent.mkdir() + payload = {"schema_version": "0.2", "common_runtime_root": str(tmp_path / "runtime"), "goals": [{ + "id": "delivery", "goal_instance_id": INSTANCE_A, "status": "active", "repo": str(project), + "coordination": {"registered_agents": ["zcode-worker"], "agent_profiles": {"zcode-worker": {"agent_type": "zcode"}}}, + }]} + registry.write_text(json.dumps(payload), encoding="utf-8") + return registry, project, payload + + +def _validate(authority, **extra): + registry, _, _ = authority + return bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id="zcode-worker", **extra) + + +def test_existing_exact_goal_and_zcode_agent_are_read_only(authority): + registry, project, _ = authority + before = registry.read_bytes() + result = _validate(authority, project=project) + assert result["goal_ref"] == {"goal_id": "delivery", "goal_instance_id": INSTANCE_A} + assert result["agent_id"] == "zcode-worker" + assert registry.read_bytes() == before + assert not (registry.parent / "zcode-goal").exists() + + +@pytest.mark.parametrize("mutation", ["unregistered", "other_host", "invalid_instance", "stopped", "archived", "duplicate"]) +def test_invalid_authority_cannot_launch_provider(authority, monkeypatch, mutation): + registry, _, payload = authority + goal = payload["goals"][0] + if mutation == "unregistered": + goal["coordination"]["registered_agents"] = [] + elif mutation == "other_host": + goal["coordination"]["agent_profiles"]["zcode-worker"]["agent_type"] = "codex-cli" + elif mutation == "invalid_instance": + goal["goal_instance_id"] = 123 + elif mutation == "stopped": + goal["activation_state"] = "stopped" + elif mutation == "archived": + goal["status"] = "archived" + else: + payload["goals"].append(dict(goal)) + registry.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("invalid authority must fail before provider discovery")) + with pytest.raises(ValueError): + bridge.zcode_goal_operation(action="start", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +@pytest.mark.parametrize("profiles_as_list", [False, True]) +def test_provider_eligibility_reuses_binding_rule_and_registration_fallbacks(authority, monkeypatch, profiles_as_list): + registry, _, payload = authority + goal = payload["goals"][0] + profiles = { + "zcode-looking": {"agent_type": "codex-cli"}, + "host-unset": {"scope_summary": "Advisory profile"}, + "build-worker": {"host_surface": "z-code"}, + "invalid-host": {"agent_type": 42}, + "unregistered": {"agent_type": "zcode"}, + } + goal["coordination"] = { + "registered_agents": ["zcode-looking", "host-unset"], + "agent_profiles": [{"agent_id": agent_id, **profile} for agent_id, profile in profiles.items()] + if profiles_as_list else profiles, + } + goal["registered_agents"] = ["build-worker", "invalid-host"] + goal["spawn_policy"] = {"registered_agents": ["host-unset"]} + registry.write_text(json.dumps(payload), encoding="utf-8") + before = registry.read_bytes() + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("metadata eligibility must not discover or launch a host")) + assert bridge.zcode_goal_eligible_agent_ids(goal) == ["host-unset", "build-worker"] + for agent_id in ("host-unset", "build-worker"): + assert bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id=agent_id)["agent_id"] == agent_id + for agent_id in ("zcode-looking", "invalid-host"): + with pytest.raises(bridge.ZCodeGoalBridgeError, match="different host"): + bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id=agent_id) + with pytest.raises(ValueError, match="not registered"): + bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id="unregistered") + assert registry.read_bytes() == before + + +def test_pure_other_host_goal_has_no_provider_candidates(authority): + _, _, payload = authority + goal = payload["goals"][0] + goal["coordination"]["agent_profiles"]["zcode-worker"]["agent_type"] = "codex-cli" + assert bridge.zcode_goal_eligible_agent_ids(goal) == [] + + +def test_canonical_source_observer_reuses_existing_read_and_runs_on_caller(authority, tmp_path, monkeypatch): + import threading + from loopx.control_plane.runtime import runtime_projection_route as routes + + registry, project, payload = authority + source_goal = payload["goals"][0] + source_goal["coordination"] = {"agent_profiles": {"plain-worker": {"scope_summary": "Advisory"}}} + source_goal["registered_agents"] = ["plain-worker"] + source_goal["spawn_policy"] = {"registered_agents": ["fallback-worker"]} + payload["goals"].append({**source_goal, "id": "second"}) + registry.write_text(json.dumps(payload), encoding="utf-8") + shared = {"registry_role": "global-local", "common_runtime_root": str(tmp_path / "shared-runtime"), "goals": [ + {"id": goal_id, "source_registry": str(registry), "repo": str(project), + "coordination": {"registered_agents": ["stale-other"], "agent_profiles": {"stale-other": {"agent_type": "codex-cli"}}}} + for goal_id in ("delivery", "second") + ]} + reads = [] + read_source = routes._read_source_registry_with_deadline + def read(path, **kwargs): + reads.append(path) + return read_source(path, **kwargs) + monkeypatch.setattr(routes, "_read_source_registry_with_deadline", read) + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("eligibility must not probe a host")) + kwargs = {"registry_path": tmp_path / "shared.json", "runtime_root": tmp_path / "shared-runtime", "goal_id": None, "registry": shared} + baseline = routes._source_routes_for_registry(**kwargs) + assert reads == [registry] + reads.clear() + observed = {} + caller = threading.get_ident() + def observe(goal_id, goal): + assert threading.get_ident() == caller + observed[goal_id] = bridge.zcode_goal_eligible_agent_ids(goal) if goal is not None else [] + projected = routes._source_routes_for_registry(**kwargs, source_goal_observer=observe) + assert projected == baseline + assert reads == [registry], "Chat eligibility must reuse the existing once-per-source read" + assert observed == {"delivery": ["plain-worker", "fallback-worker"], "second": ["plain-worker", "fallback-worker"]} + for agent_id in observed["delivery"]: + assert bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id=agent_id)["agent_id"] == agent_id + + +@pytest.mark.parametrize("failure", ["missing", "unreadable", "timeout", "duplicate", "absent"]) +def test_canonical_source_observer_never_uses_stale_projected_profiles(authority, tmp_path, monkeypatch, failure): + from loopx.control_plane.runtime import runtime_projection_route as routes + + registry, _, payload = authority + shared = {"registry_role": "global-local", "common_runtime_root": str(tmp_path / "shared-runtime"), "goals": [{ + "id": "delivery", "source_registry": str(registry), + "coordination": {"registered_agents": ["projected-worker"]}, + }]} + if failure in ("missing", "unreadable", "timeout"): + monkeypatch.setattr(routes, "_read_source_registry_with_deadline", lambda *args, **kwargs: (None, "source_registry_" + failure)) + else: + payload["goals"] = payload["goals"] * 2 if failure == "duplicate" else [] + registry.write_text(json.dumps(payload), encoding="utf-8") + seen = [] + routes._source_routes_for_registry( + registry_path=tmp_path / "shared.json", runtime_root=tmp_path / "shared-runtime", goal_id=None, + registry=shared, source_goal_observer=lambda goal_id, goal: seen.append((goal_id, goal)), + ) + assert seen == [("delivery", None)] + + +def test_actual_chat_status_projects_canonical_host_eligibility_without_extra_reads(authority, tmp_path, monkeypatch): + import threading + from http.server import ThreadingHTTPServer + from urllib.request import urlopen + from loopx.chat_server import ChatRequestHandler + from loopx.control_plane.runtime import runtime_projection_route as routes + from loopx.status import collect_status + + registry, project, payload = authority + goal = payload["goals"][0] + goal["coordination"]["agent_profiles"]["zcode-worker"]["agent_type"] = "codex-cli" + registry.write_text(json.dumps(payload), encoding="utf-8") + runtime = tmp_path / "runtime" + runtime.mkdir(exist_ok=True) + server = ThreadingHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + for key, value in {"registry_path": registry, "runtime_root_override": None, "runtime_root": runtime, + "scan_roots": [project], "limit": 8, "selected_goal_id": None, "verbose": False}.items(): + setattr(server, key, value) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("Chat metadata must not discover or launch ZCode")) + def read_candidates(): + with urlopen(f"http://127.0.0.1:{server.server_port}/status.json", timeout=30) as response: + status = json.load(response) + return next(row for row in status["run_history"]["goals"] if row["id"] == "delivery")["zcode_goal_eligible_agent_ids"] + try: + assert read_candidates() == [] + goal["registered_agents"] = ["native-worker", "host-unset"] + goal["coordination"]["agent_profiles"].update({ + "native-worker": {"host_surface": "z-code"}, "host-unset": {"scope_summary": "Advisory"}, + }) + registry.write_text(json.dumps(payload), encoding="utf-8") + assert read_candidates() == ["native-worker", "host-unset"] + for agent_id in ("native-worker", "host-unset"): + assert bridge.validate_zcode_binding(registry_path=registry, goal_id="delivery", agent_id=agent_id)["agent_id"] == agent_id + shared = tmp_path / "shared.json" + shared.write_text(json.dumps({"schema_version": "0.2", "registry_role": "global-local", + "common_runtime_root": str(tmp_path / "shared-runtime"), "goals": [{ + **goal, "source_registry": str(registry), "registered_agents": ["forged-native"], + "coordination": {"registered_agents": ["forged-native"], "agent_profiles": {"forged-native": {"agent_type": "zcode"}}}, + }]}), encoding="utf-8") + server.registry_path = shared + assert read_candidates() == ["native-worker", "host-unset"] + reads = [] + original_read = routes._read_source_registry_with_deadline + def counted(path, **kwargs): + reads.append(path) + return original_read(path, **kwargs) + monkeypatch.setattr(routes, "_read_source_registry_with_deadline", counted) + kwargs = {"registry_path": shared, "runtime_root_override": None, "scan_roots": [project], "limit": 8, "include_public_boundary_scan": False} + baseline = collect_status(**kwargs) + baseline_reads = list(reads) + assert all("zcode_goal_eligible_agent_ids" not in row for row in baseline["run_history"]["goals"]) + reads.clear() + current = collect_status(**kwargs, include_zcode_goal_eligibility=True) + assert reads == baseline_reads, "The Chat-only projection adds zero canonical source reads" + assert next(row for row in current["run_history"]["goals"] if row["id"] == "delivery")["zcode_goal_eligible_agent_ids"] == ["native-worker", "host-unset"] + registry.write_text("{invalid-json", encoding="utf-8") + assert read_candidates() == [] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + assert not thread.is_alive() + + +def test_project_and_goal_instance_are_consistency_assertions(authority, tmp_path): + with pytest.raises(ValueError, match="canonical"): + _validate(authority, project=tmp_path) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="instance changed"): + _validate(authority, goal_ref={"goal_id": "delivery", "goal_instance_id": INSTANCE_B}) + + +def test_shared_projection_follows_canonical_source_registry(authority, tmp_path): + registry, project, payload = authority + projection = tmp_path / "shared.json" + projection.write_text(json.dumps({"schema_version": "0.2", "registry_role": "global-local", "common_runtime_root": str(tmp_path / "shared-runtime"), "goals": [{**payload["goals"][0], "source_registry": str(registry), "repo": str(tmp_path / "wrong")}]}), encoding="utf-8") + result = bridge.validate_zcode_binding(registry_path=projection, goal_id="delivery", agent_id="zcode-worker", project=project) + assert result["registry"] == str(registry.resolve()) + assert result["project"] == str(project.resolve()) + assert result["runtime_root"] == payload["common_runtime_root"] + + +def _stub_operation(monkeypatch, *, callback=None): + requests = [] + monkeypatch.setattr(bridge, "_node_command", lambda: "qualified-node") + def run(command, *, project, request=None): + requests.append((command, request)) + if request is None: + return {"ok": True, "task_body": "Canonical thin heartbeat instructions"} + if callback: + callback(request) + return {"ok": True, "available": False, "goal_id": request["goal_id"], "goal_ref": request["goal_ref"], "goal_creation_operation_id": request["goal_creation_operation_id"], "agent_id": request["agent_id"], "actions": ["bind", "status"]} + monkeypatch.setattr(bridge, "_run_json", run) + return requests + + +def test_cli_bridge_pins_interpreter_and_scopes_runtime_state(authority, monkeypatch, tmp_path): + registry, project, _ = authority + cli = tmp_path / "zcode.mjs" + cli.write_text("", encoding="utf-8") + requests = _stub_operation(monkeypatch) + result = bridge.zcode_goal_operation(action="bind", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", project=project, cli_path=str(cli)) + heartbeat_command, _ = requests[0] + command, native_request = requests[1] + assert heartbeat_command[:3] == [sys.executable, "-m", "loopx.cli"] + assert "--thin" in heartbeat_command + assert heartbeat_command[heartbeat_command.index("--runtime-profile") + 1] == "generic_cli" + assert native_request["loopx_command"] == [sys.executable, "-m", "loopx.cli"] + assert native_request["validation_command"] == [sys.executable, "-m", "loopx.zcode_goal_mode.bridge", "--validate-binding"] + assert native_request["cli_command"] == ["qualified-node", str(cli.resolve()), "app-server"] + assert native_request["cli_path"] == str(cli.resolve()) + assert Path(native_request["state_path"]).parent == tmp_path / "runtime" / "zcode-goal" + assert native_request["task_body"] == "Canonical thin heartbeat instructions" + assert result["goal_ref"]["goal_instance_id"] == INSTANCE_A + + +def test_status_does_not_generate_prompt_or_select_cli(authority, monkeypatch): + registry, _, _ = authority + requests = _stub_operation(monkeypatch) + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert len(requests) == 1 + assert "task_body" not in requests[0][1] + assert "cli_command" not in requests[0][1] + with pytest.raises(ValueError, match="only.*bind"): + bridge.zcode_goal_operation(action="resume", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", cli_path="another-cli") + + +def test_replaced_goal_after_operation_is_not_reported_as_success(authority, monkeypatch): + registry, _, payload = authority + def replace(request): + payload["goals"][0]["goal_instance_id"] = INSTANCE_B + registry.write_text(json.dumps(payload), encoding="utf-8") + _stub_operation(monkeypatch, callback=replace) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="instance changed"): + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +def test_foreign_provider_readback_is_rejected(authority, monkeypatch): + registry, _, _ = authority + _stub_operation(monkeypatch) + monkeypatch.setattr(bridge, "_run_json", lambda *args, **kwargs: {"ok": True, "goal_id": "another", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "agent_id": "zcode-worker"}) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="did not match"): + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +def test_internal_binding_guard_runs_on_selected_interpreter(authority): + registry, project, payload = authority + request = {"action": "start", "registry": str(registry), "project": str(project), "goal_id": "delivery", "agent_id": "zcode-worker", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}} + command = [sys.executable, "-X", "utf8", "-B", "-m", "loopx.zcode_goal_mode.bridge", "--validate-binding"] + valid = subprocess.run(command, input=json.dumps(request), encoding="utf-8", capture_output=True, timeout=15) + assert valid.returncode == 0, valid.stderr + assert json.loads(valid.stdout)["goal_ref"] == request["goal_ref"] + payload["goals"][0]["goal_instance_id"] = INSTANCE_B + registry.write_text(json.dumps(payload), encoding="utf-8") + stale = subprocess.run(command, input=json.dumps(request), encoding="utf-8", capture_output=True, timeout=15) + assert stale.returncode == 1 + assert json.loads(stale.stdout)["error_code"] == "zcode_goal_authority_changed" + + +def test_cli_parser_and_forwarding(authority, monkeypatch, capsys): + from loopx import cli + from loopx.cli_commands import zcode_goal + registry, project, _ = authority + seen = [] + monkeypatch.setattr(zcode_goal, "zcode_goal_operation", lambda **kwargs: seen.append(kwargs) or {"ok": True, "actions": ["status"]}) + assert cli.main(["--registry", str(registry), "zcode-goal", "bind", "--goal-id", "delivery", "--agent-id", "zcode-worker", "--project", str(project), "--zcode-cli", "bundle.mjs", "--format", "json"]) == 0 + assert seen == [{"action": "bind", "registry_path": registry, "goal_id": "delivery", "agent_id": "zcode-worker", "project": str(project), "cli_path": "bundle.mjs", "runtime_root": None, "model_selection": None}] + assert json.loads(capsys.readouterr().out)["ok"] is True + + +class Handler(ZCodeGoalRequestMixin): + def __init__(self, registry, *, host="127.0.0.1", allowed=True, body=None): + self.server = SimpleNamespace(registry_path=registry, runtime_root_override=None, server_address=(host, 4321)) + self.path = "/api/goals/delivery/agents/zcode-worker/zcode-goal" + self.allowed = allowed + self.body = body or {} + self.sent = [] + def _require_loopback_origin(self): + if not self.allowed: + self._send_error("forbidden origin", status=403) + return self.allowed + def _read_json(self): + return self.body + def _send_json(self, payload, **kwargs): + self.sent.append((payload, kwargs)) + def _send_error(self, message, **kwargs): + self.sent.append(({"ok": False, "error": message}, kwargs)) + + +def test_api_public_projection_keeps_same_session_evidence(): + public = public_zcode_goal_readback({"ok": True, "goal_id": "delivery", "agent_id": "zcode-worker", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "project": "private", "state_path": "private", "binding": {"mode": "managed_cli", "connected": True, "cli_path": "private", "protocol": "zcode-ndjson-session-goal"}, "native": {"session_id": "native-session", "target_id": "native-target", "status": "paused", "running": False, "transcript": "private"}, "quota": {"should_run": False, "reason": "no slots"}, "actions": ["resume", "stop"]}) + assert public["native"]["session_id"] == "native-session" + assert public["native"]["target_id"] == "native-target" + assert public["binding"]["cli_path"] == "private" + assert "transcript" not in public["native"] + assert "project" not in public and "state_path" not in public + + +@pytest.mark.parametrize("case", ["remote_server", "foreign_origin", "project_injection", "missing_action", "invalid_cli", "query"]) +def test_api_rejects_outside_authority_before_provider(authority, monkeypatch, case): + from loopx.zcode_goal_mode import api + registry, _, _ = authority + handler = Handler(registry, host="0.0.0.0" if case == "remote_server" else "127.0.0.1", allowed=case != "foreign_origin", body={"action": "bind"}) + if case == "project_injection": + handler.body["project"] = "elsewhere" + if case == "missing_action": + handler.body = {} + if case == "invalid_cli": + handler.body["cli_path"] = None + if case == "query": + handler.path += "?registry=elsewhere" + monkeypatch.setattr(api, "zcode_goal_operation", lambda **kwargs: pytest.fail("rejected API request reached provider")) + assert handler._dispatch_zcode_goal(handler.path.split("?")[0], apply=True) + assert handler.sent[0][1]["status"] in {400, 403} + + +@pytest.mark.parametrize("injected", [ + {"project_ref": "ordinary-project-ref"}, + {"project_context": {"kind": "project_workspace", "grant": "workspace_read"}}, + {"conversation_binding_id": "ordinary-conversation-binding"}, + {"source_context": {"source_ref": "a" * 24, "sender_ref": "b" * 24, "private_human_message": True}}, +]) +def test_api_cannot_borrow_ordinary_project_chat_authority(authority, monkeypatch, injected): + registry, _, _ = authority + handler = Handler(registry, body={ + "action": "start", + "expected_binding": { + "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, + "goal_creation_operation_id": None, + }, + **injected, + }) + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("ordinary Chat authority reached native Node discovery")) + monkeypatch.setattr(bridge, "_run_json", lambda *args, **kwargs: pytest.fail("ordinary Chat authority reached native effects")) + assert handler._dispatch_zcode_goal(handler.path, apply=True) + assert handler.sent[0][1]["status"] == 400 + assert "ZCode Goal accepts" in handler.sent[0][0]["error"] + + +def test_api_get_and_post_share_canonical_bridge(authority, monkeypatch): + from loopx.zcode_goal_mode import api + registry, _, _ = authority + seen = [] + def operation(**kwargs): + seen.append(kwargs) + return {"ok": True, "available": False, "goal_id": "delivery", "agent_id": "zcode-worker", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "binding": None, "native": None, "quota": None, "actions": ["bind", "status"]} + monkeypatch.setattr(api, "zcode_goal_operation", operation) + get = Handler(registry) + assert get._dispatch_zcode_goal(get.path) + post = Handler(registry, body={"action": "bind", "cli_path": "bundle.mjs", "expected_binding": {"goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "goal_creation_operation_id": None}}) + assert post._dispatch_zcode_goal(post.path, apply=True) + assert seen[0]["action"] == "status" and seen[1]["action"] == "bind" + assert seen[1]["cli_path"] == "bundle.mjs" + assert all(item["registry_path"] == registry and "project" not in item for item in seen) + assert not get._dispatch_zcode_goal("/api/chat/status") + + +@pytest.mark.parametrize("kind", ["missing", "desktop", "windows_shim"]) +def test_cli_binding_does_not_launch_unsupported_surface(tmp_path, kind): + path = tmp_path / ("zcode.cmd" if kind == "windows_shim" else "zcode.exe") + if kind != "missing": + path.write_text("", encoding="utf-8") + if kind == "desktop": + bundle = tmp_path / "resources/glm/zcode.cjs" + bundle.parent.mkdir(parents=True) + bundle.write_text("", encoding="utf-8") + with pytest.raises(bridge.ZCodeGoalBridgeError): + bridge._cli_command(str(path), "node") + + +def test_real_chat_http_route_preserves_scoped_readback(authority, monkeypatch): + import threading + from http.server import ThreadingHTTPServer + from urllib.request import Request, urlopen + from urllib.error import HTTPError + from loopx.chat_server import ChatRequestHandler + from loopx.zcode_goal_mode import api + registry, _, _ = authority + seen = [] + def operation(**kwargs): + seen.append(kwargs) + return {"ok": True, "available": False, "goal_id": "delivery", "agent_id": "zcode-worker", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "binding": None, "native": None, "quota": None, "actions": ["bind", "status"]} + monkeypatch.setattr(api, "zcode_goal_operation", operation) + server = ThreadingHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = registry + server.runtime_root_override = None + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}/api/goals/delivery/agents/zcode-worker/zcode-goal" + try: + with urlopen(url, timeout=10) as response: + assert json.load(response)["goal_ref"]["goal_instance_id"] == INSTANCE_A + with urlopen(Request(url, data=json.dumps({"action": "bind", "cli_path": "bundle.mjs", "expected_binding": {"goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "goal_creation_operation_id": None}}).encode(), headers={"Content-Type": "application/json"}), timeout=10) as response: + assert json.load(response)["actions"] == ["bind", "status"] + with pytest.raises(HTTPError) as failure: + urlopen(Request(url, data=b'{"action":"start"}', headers={"Origin": "https://outside.example"}), timeout=10) + assert failure.value.code == 403 + assert [entry["action"] for entry in seen] == ["status", "bind"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + +def test_real_provider_status_is_read_only_with_exact_identity(authority): + registry, _, _ = authority + try: + bridge._node_command() + except bridge.ZCodeGoalBridgeError as exc: + pytest.skip(str(exc)) + result = bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert result["ok"] is True + assert result["available"] is False + assert result["binding"] is None + assert result["goal_ref"] == {"goal_id": "delivery", "goal_instance_id": INSTANCE_A} + assert not (Path(authority[2]["common_runtime_root"]) / "zcode-goal").exists() + + +@pytest.mark.parametrize("action", ["status", "pause", "stop"]) +def test_same_exact_inactive_binding_can_be_read_or_cleaned(authority, monkeypatch, action): + registry, _, payload = authority + payload["goals"][0]["activation_state"] = "stopped" + registry.write_text(json.dumps(payload), encoding="utf-8") + requests = _stub_operation(monkeypatch) + result = bridge.zcode_goal_operation(action=action, registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert result["goal_ref"]["goal_instance_id"] == INSTANCE_A + assert requests[0][1]["action"] == action + with pytest.raises(bridge.ZCodeGoalBridgeError, match="no longer active"): + _validate(authority) + + +@pytest.mark.parametrize("action", ["bind", "start", "resume"]) +def test_inactive_binding_cannot_admit_execution(authority, monkeypatch, action): + registry, _, payload = authority + payload["goals"][0]["activation_state"] = "stopped" + registry.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("inactive Goal reached provider")) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="no longer active"): + bridge.zcode_goal_operation(action=action, registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +def test_inactive_cleanup_still_rejects_changed_instance_or_unregistered_agent(authority, monkeypatch): + registry, _, payload = authority + payload["goals"][0]["activation_state"] = "stopped" + registry.write_text(json.dumps(payload), encoding="utf-8") + def replace(request): + payload["goals"][0]["goal_instance_id"] = INSTANCE_B + registry.write_text(json.dumps(payload), encoding="utf-8") + _stub_operation(monkeypatch, callback=replace) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="instance changed"): + bridge.zcode_goal_operation(action="pause", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + payload["goals"][0]["coordination"]["registered_agents"] = [] + registry.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(ValueError, match="Register"): + bridge.zcode_goal_operation(action="stop", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + + +def test_legacy_goal_retains_existing_alias_and_creation_witness(authority, monkeypatch): + registry, _, payload = authority + payload.pop("profile_id", None) + del payload["goals"][0]["goal_instance_id"] + payload["goals"][0]["creation_operation_id"] = "goal-create:first" + registry.write_text(json.dumps(payload), encoding="utf-8") + binding = _validate(authority) + assert binding["goal_ref"] == {"goal_id": "delivery"} + assert binding["identity_scope"] == "legacy_goal_alias" + assert binding["goal_creation_operation_id"] == "goal-create:first" + assert "goal_instance_id" not in json.loads(registry.read_text(encoding="utf-8"))["goals"][0] + requests = _stub_operation(monkeypatch) + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + assert requests[0][1]["goal_creation_operation_id"] == "goal-create:first" + with pytest.raises(bridge.ZCodeGoalBridgeError, match="creation witness changed"): + _validate(authority, goal_ref={"goal_id": "delivery"}, goal_creation_operation_id="goal-create:other") + + +def test_legacy_recreated_alias_cannot_return_old_binding_success(authority, monkeypatch): + registry, _, payload = authority + payload.pop("profile_id", None) + del payload["goals"][0]["goal_instance_id"] + payload["goals"][0]["creation_operation_id"] = "goal-create:first" + registry.write_text(json.dumps(payload), encoding="utf-8") + def replace(request): + payload["goals"][0]["creation_operation_id"] = "goal-create:second" + registry.write_text(json.dumps(payload), encoding="utf-8") + _stub_operation(monkeypatch, callback=replace) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="creation witness changed"): + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + + +def test_registered_actor_needs_no_advisory_host_field(authority): + registry, _, payload = authority + payload["goals"][0]["coordination"]["agent_profiles"] = {} + registry.write_text(json.dumps(payload), encoding="utf-8") + assert _validate(authority)["agent_id"] == "zcode-worker" + + +def test_lifecycle_only_source_profile_stays_outside_business_runtime(authority, monkeypatch): + registry, _, payload = authority + payload["profile_id"] = "source_session_v1" + registry.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("lifecycle-only profile reached runtime")) + with pytest.raises(ValueError, match="lifecycle-only"): + bridge.zcode_goal_operation(action="bind", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") + + +@pytest.mark.parametrize("selection", [None, {}, {"providerId": "local", "modelId": "m", "apiKey": "secret"}, {"providerId": "local", "modelId": "m", "options": {"unknown": "x"}}]) +def test_model_selection_validates_transport_without_credentials(authority, monkeypatch, selection): + registry, _, _ = authority + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("invalid selection reached runtime")) + with pytest.raises(ValueError): + bridge.zcode_goal_operation(action="select_model", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", model_selection=selection) + + +def test_model_selection_forwards_existing_model_only(authority, monkeypatch): + registry, _, _ = authority + requests = _stub_operation(monkeypatch) + selection = {"providerId": "local", "modelId": "synthetic", "options": {"reasoningLevel": "low"}} + bridge.zcode_goal_operation(action="select_model", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", model_selection=selection) + assert requests[0][1]["model_selection"] == selection + assert "task_body" not in requests[0][1] + with pytest.raises(ValueError, match="only.*select_model"): + bridge.zcode_goal_operation(action="start", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", model_selection=selection) + + + +def test_internal_guard_requires_known_action_and_validates_inactive_cleanup(authority): + registry, project, payload = authority + payload["goals"][0]["activation_state"] = "stopped" + registry.write_text(json.dumps(payload), encoding="utf-8") + request = {"registry": str(registry), "project": str(project), "goal_id": "delivery", "agent_id": "zcode-worker", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}} + command = [sys.executable, "-X", "utf8", "-B", "-m", "loopx.zcode_goal_mode.bridge", "--validate-binding"] + def invoke(action): + body = dict(request) + if action is not None: + body["action"] = action + result = subprocess.run(command, input=json.dumps(body), encoding="utf-8", capture_output=True, timeout=15) + return result.returncode, json.loads(result.stdout) + for action in (None, "unknown", "bind", "start", "resume", "select_model"): + code, result = invoke(action) + assert code == 1 and result["ok"] is False + for action in ("status", "pause", "stop"): + code, result = invoke(action) + assert code == 0 and result["goal_ref"] == request["goal_ref"] + payload["goals"][0]["goal_instance_id"] = INSTANCE_B + registry.write_text(json.dumps(payload), encoding="utf-8") + code, result = invoke("stop") + assert code == 1 and result["error_code"] == "zcode_goal_authority_changed" + + +def test_cli_select_model_maps_alias_and_preserves_selection(authority, monkeypatch, capsys): + from loopx import cli + from loopx.cli_commands import zcode_goal + registry, _, _ = authority + seen = [] + monkeypatch.setattr(zcode_goal, "zcode_goal_operation", lambda **kwargs: seen.append(kwargs) or {"ok": True}) + assert cli.main(["--registry", str(registry), "--format", "json", "zcode-goal", "select-model", "--goal-id", "delivery", "--agent-id", "zcode-worker", "--provider-id", "local", "--model-id", "synthetic", "--reasoning-level", "disabled"]) == 0 + assert seen[0]["action"] == "select_model" + assert seen[0]["model_selection"] == {"providerId": "local", "modelId": "synthetic", "options": {"reasoningLevel": "disabled"}} + assert json.loads(capsys.readouterr().out)["ok"] is True + + + +@pytest.mark.parametrize("identity", ["exact", "legacy_creation"]) +def test_stale_expected_binding_rejects_before_any_provider_effect(authority, monkeypatch, identity): + registry, _, payload = authority + expected = {"goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "goal_creation_operation_id": None} + if identity == "exact": + payload["goals"][0]["goal_instance_id"] = INSTANCE_B + else: + del payload["goals"][0]["goal_instance_id"] + payload["goals"][0]["creation_operation_id"] = "goal-create:new" + expected = {"goal_ref": {"goal_id": "delivery"}, "goal_creation_operation_id": "goal-create:old"} + registry.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("stale expectation reached Node discovery")) + monkeypatch.setattr(bridge, "_run_json", lambda *args, **kwargs: pytest.fail("stale expectation reached provider effect")) + with pytest.raises(bridge.ZCodeGoalBridgeError) as failure: + bridge.zcode_goal_operation(action="start", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", expected_binding=expected) + assert failure.value.status == 409 + + +def test_api_mutations_require_readback_expectation(authority, monkeypatch): + from loopx.zcode_goal_mode import api + registry, _, _ = authority + handler = Handler(registry, body={"action": "start"}) + monkeypatch.setattr(api, "zcode_goal_operation", lambda **kwargs: pytest.fail("missing expectation reached provider")) + assert handler._dispatch_zcode_goal(handler.path, apply=True) + assert handler.sent[0][1]["status"] == 400 + + +def test_api_creation_replacement_is_conflict_before_provider(authority, monkeypatch): + registry, _, payload = authority + del payload["goals"][0]["goal_instance_id"] + payload["goals"][0]["creation_operation_id"] = "goal-create:new" + registry.write_text(json.dumps(payload), encoding="utf-8") + handler = Handler(registry, body={"action": "start", "expected_binding": {"goal_ref": {"goal_id": "delivery"}, "goal_creation_operation_id": "goal-create:old"}}) + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("old panel reached Node")) + assert handler._dispatch_zcode_goal(handler.path, apply=True) + assert handler.sent[0][1]["status"] == 409 + + +@pytest.mark.parametrize("expected", [{}, {"goal_ref": {}}, {"goal_ref": {"goal_id": "delivery", "other": "x"}, "goal_creation_operation_id": None}, {"goal_ref": {"goal_id": "delivery"}, "goal_creation_operation_id": True}]) +def test_expected_binding_has_one_exact_transport_shape(authority, monkeypatch, expected): + registry, _, _ = authority + monkeypatch.setattr(bridge, "_node_command", lambda: pytest.fail("malformed expectation reached provider")) + with pytest.raises(ValueError): + bridge.zcode_goal_operation(action="bind", registry_path=registry, goal_id="delivery", agent_id="zcode-worker", expected_binding=expected) + + + +def test_provider_must_echo_creation_witness_even_when_null(authority, monkeypatch): + registry, _, _ = authority + _stub_operation(monkeypatch) + monkeypatch.setattr(bridge, "_run_json", lambda *args, **kwargs: {"ok": True, "goal_id": "delivery", "goal_ref": {"goal_id": "delivery", "goal_instance_id": INSTANCE_A}, "agent_id": "zcode-worker"}) + with pytest.raises(bridge.ZCodeGoalBridgeError, match="did not match"): + bridge.zcode_goal_operation(action="status", registry_path=registry, goal_id="delivery", agent_id="zcode-worker") diff --git a/tests/test_zcode_host_diagnostics.py b/tests/test_zcode_host_diagnostics.py new file mode 100644 index 0000000000..ecba158b3f --- /dev/null +++ b/tests/test_zcode_host_diagnostics.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import pytest + +from loopx.zcode_goal_mode import diagnostics + + +# Independent syntax examples, not a snapshot of one installed release. +HELP = """zcode 1.2.3 +Usage: + zcode [command] [options] +Commands: + app-server Run the stdio app server + agent-server Alias for the app server + plugins Manage plugins + skills List local skills +Options: + --prompt Run a single prompt + --target Set the session goal + --continue Resume the latest session + --resume Resume a session + --json Print JSON +Slash Commands: + /mcp [list|status] Show servers +""" + + +@pytest.fixture(autouse=True) +def no_ambient_zcode(monkeypatch): + for name in ("ZCODE_CLI_PATH", "ZCODE_DESKTOP_PATH", "ZCODE_SOURCE_ROOT"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(diagnostics, "_desktop_candidates", lambda: []) + monkeypatch.setattr(diagnostics.shutil, "which", lambda name: None) + + +def _file(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("synthetic fixture; never execute", encoding="utf-8") + return path + + +def _probe(output: str, status: str = "observed") -> dict: + return {"status": status, "exit_code": 0 if status == "observed" else 1, "output": output} + + +def test_cli_observations_use_only_help_and_version(monkeypatch, tmp_path): + executable = _file(tmp_path / "zcode") + calls = [] + + def probe(command, **kwargs): + calls.append(command) + if "loopx-doctor-invalid" in command: + return _probe("Invalid format", status="failed") + return _probe(HELP if "--help" in command else "ZCode CLI 1.2.3\n") + + monkeypatch.setattr(diagnostics, "_run_probe", probe) + cli = diagnostics._inspect_cli(executable, discovery="explicit") + + assert cli["version"] == "1.2.3" + assert cli["status"] == "available" + assert cli["runtime_verified"] is False + for name in ("stdio_app_server", "stdio_agent_server_alias", "headless_prompt", "native_goal", "resume", "continue", "json_result", "mcp", "plugins", "skills"): + assert cli["interfaces"][name]["status"] == "advertised" + assert cli["interfaces"]["stream_json_syntax"]["status"] == "advertised" + assert [command[1:] for command in calls] == [ + ["--version"], + ["--help", "--locale", "en-US"], + ["--version", "--output-format", "stream-json"], + ["--version", "--output-format", "loopx-doctor-invalid"], + ] + assert all(command[0] == str(executable) for command in calls) + + +def test_help_requires_exact_interface_tokens(monkeypatch, tmp_path): + executable = _file(tmp_path / "zcode") + near_matches = """zcode 1.2.3 +Usage: + zcode [command] [options] +Commands: + app-server-remote + agent-server-remote + plugins-extra + skills-extra +Options: + --prompt-cache --target-replace --continue-cache --resume-cache --jsonlines +Slash Commands: + /mcp-extra /skills-extra +""" + monkeypatch.setattr( + diagnostics, "_run_probe", + lambda command, **kwargs: _probe(near_matches if "--help" in command else "1.2.3"), + ) + + interfaces = diagnostics._inspect_cli(executable, discovery="explicit")["interfaces"] + + for name in ("stdio_app_server", "stdio_agent_server_alias", "headless_prompt", "native_goal", "resume", "continue", "json_result", "mcp", "plugins", "skills"): + assert interfaces[name]["status"] == "not_advertised" + + +def test_unidentified_executable_help_cannot_claim_zcode_interfaces(monkeypatch, tmp_path): + executable = _file(tmp_path / "other-tool") + unrelated_help = HELP[HELP.index("Commands:"):] + monkeypatch.setattr( + diagnostics, "_run_probe", + lambda command, **kwargs: _probe(unrelated_help if "--help" in command else "1.2.3"), + ) + + cli = diagnostics._inspect_cli(executable, discovery="explicit") + + assert cli["status"] == "probe_failed" + assert all(row["status"] == "unverified" for row in cli["interfaces"].values()) + + +def test_ignored_output_format_flag_is_not_evidence_of_stream_json(monkeypatch, tmp_path): + executable = _file(tmp_path / "zcode") + monkeypatch.setattr( + diagnostics, "_run_probe", + lambda command, **kwargs: _probe(HELP if "--help" in command else "1.2.3"), + ) + + cli = diagnostics._inspect_cli(executable, discovery="explicit") + + assert cli["status"] == "available" + assert cli["interfaces"]["stream_json_syntax"]["status"] == "unverified" + assert cli["runtime_verified"] is False + + +def test_explicit_missing_cli_never_falls_back_to_path(monkeypatch, tmp_path): + def unexpected_lookup(name): + pytest.fail(f"Explicit missing CLI must not discover an alternative: {name}") + + monkeypatch.setattr(diagnostics.shutil, "which", unexpected_lookup) + missing = tmp_path / "missing-zcode" + + payload = diagnostics.collect_zcode_host_diagnostics(cli_path=str(missing)) + + assert payload["cli"]["status"] == "not_found" + assert payload["cli"]["path"] == str(missing) + assert payload["cli"]["discovery"] == "explicit" + assert payload["cli"]["version"] is None + + +def test_failed_help_remains_unverified_and_does_not_export_raw_errors(monkeypatch, tmp_path): + executable = _file(tmp_path / "zcode") + secret = "private-configuration-error-marker" + calls = [] + + def probe(command, **kwargs): + calls.append(command) + return _probe(secret + HELP, status="failed") + + monkeypatch.setattr(diagnostics, "_run_probe", probe) + payload = diagnostics.collect_zcode_host_diagnostics(cli_path=str(executable)) + + assert payload["cli"]["status"] == "probe_failed" + assert all(row["status"] == "unverified" for row in payload["cli"]["interfaces"].values()) + assert len(calls) == 2 # Failed help cannot authorize another syntax probe. + assert secret not in json.dumps(payload) + markdown = "\n".join(diagnostics.render_zcode_diagnostics_markdown(payload)) + assert secret not in markdown + assert "unverified" in markdown + + +@pytest.mark.parametrize("status,output", [("failed", "8.1.0"), ("observed", "invalid-version")]) +def test_failed_or_unparseable_version_is_unknown(monkeypatch, tmp_path, status, output): + executable = _file(tmp_path / "zcode") + monkeypatch.setattr( + diagnostics, "_run_probe", + lambda command, **kwargs: _probe(HELP) if "--help" in command else _probe(output, status), + ) + + cli = diagnostics._inspect_cli(executable, discovery="explicit") + + assert cli["version"] is None + assert cli["status"] == "probe_failed" + assert cli["interfaces"]["stream_json_syntax"]["status"] == "unverified" + assert cli["runtime_verified"] is False + + +def test_source_package_version_cannot_stand_in_for_built_runtime(monkeypatch, tmp_path): + source = tmp_path / "source" + source.mkdir() + (source / "package.json").write_text(json.dumps({"name": "zcode", "version": "9.9.9"}), encoding="utf-8") + monkeypatch.setattr(diagnostics, "_run_probe", lambda *args, **kwargs: pytest.fail("No built CLI exists")) + + payload = diagnostics.collect_zcode_host_diagnostics(source_root=str(source)) + + assert payload["source_checkout"]["package_version"] == "9.9.9" + built = payload["source_checkout"]["built_cli"] + assert built["status"] == "not_found" + assert built["version"] is None + assert built["runtime_verified"] is False + assert payload["cli"]["version"] is None + assert payload["desktop"]["version"] is None + markdown = "\n".join(diagnostics.render_zcode_diagnostics_markdown(payload)) + assert "declared package version: 9.9.9 (not runtime version)" in markdown + + +def test_cli_desktop_and_source_bundle_versions_are_independent(monkeypatch, tmp_path): + executable = _file(tmp_path / "cli" / "zcode") + desktop = _file(tmp_path / "desktop" / "ZCode.exe") + desktop_bundle = _file(desktop.parent / "resources" / "glm" / "zcode.cjs") + source = tmp_path / "source" + source_bundle = _file(source / "apps" / "zcode-cli" / "packages" / "cli" / "dist" / "zcode.cjs") + (source / "package.json").write_text(json.dumps({"name": "zcode", "version": "9.9.9"}), encoding="utf-8") + versions = {str(executable): "1.2.3", str(desktop_bundle): "4.5.6", str(source_bundle): "7.8.9"} + monkeypatch.setattr(diagnostics.shutil, "which", lambda name: "synthetic-node" if name == "node" else None) + monkeypatch.setattr(diagnostics, "_desktop_version", lambda *args: {"value": "2.3.4", "evidence": "executable ProductVersion"}) + + def probe(command, **kwargs): + if "loopx-doctor-invalid" in command: + return _probe("Invalid format", status="failed") + target = command[1] if command[0] == "synthetic-node" else command[0] + return _probe(HELP if "--help" in command else versions[target]) + + monkeypatch.setattr(diagnostics, "_run_probe", probe) + payload = diagnostics.collect_zcode_host_diagnostics( + cli_path=str(executable), desktop_path=str(desktop), source_root=str(source), + ) + + assert payload["cli"]["version"] == "1.2.3" + assert payload["desktop"]["version"] == "2.3.4" + assert payload["desktop"]["bundled_cli"]["version"] == "4.5.6" + assert payload["source_checkout"]["package_version"] == "9.9.9" + assert payload["source_checkout"]["built_cli"]["version"] == "7.8.9" + for host in (payload["cli"], payload["desktop"], payload["desktop"]["bundled_cli"], payload["source_checkout"]["built_cli"]): + assert host["runtime_verified"] is False + markdown = "\n".join(diagnostics.render_zcode_diagnostics_markdown(payload)) + assert "Desktop bundled CLI:" in markdown + assert "Source built CLI:" in markdown + assert "managed native CLI Goal requires explicit zcode-goal bind" in markdown + assert "Desktop attachment and Automations are not integrated" in markdown + + + +def test_relative_cli_and_source_paths_are_resolved_before_probe(monkeypatch, tmp_path): + executable = _file(tmp_path / "zcode") + source = tmp_path / "source" + source.mkdir() + (source / "package.json").write_text(json.dumps({"name": "zcode", "version": "9.9.9"}), encoding="utf-8") + commands = [] + + def probe(command, **kwargs): + commands.append(command) + if "loopx-doctor-invalid" in command: + return _probe("Invalid format", status="failed") + return _probe(HELP if "--help" in command else "1.2.3") + + monkeypatch.setattr(diagnostics, "_run_probe", probe) + monkeypatch.chdir(tmp_path) + payload = diagnostics.collect_zcode_host_diagnostics(cli_path="zcode", source_root="source") + + assert payload["cli"]["path"] == str(executable.resolve()) + assert payload["source_checkout"]["path"] == str(source.resolve()) + assert all(command[0] == str(executable.resolve()) for command in commands) + + +def test_probe_uses_disposable_storage_and_preserves_parent_environment(monkeypatch): + monkeypatch.setenv("ZCODE_STORAGE_DIR", "existing-private-storage") + script = ( + "import json, os, sys; " + "names = ('ZCODE_HOME', 'ZCODE_STORAGE_DIR', 'ZCODE_DATA_BASE_DIR', 'SYNTHETIC_METADATA_PATH'); " + "print(json.dumps({'cwd': os.getcwd(), 'env': {name: os.environ.get(name) for name in names}, 'stdin': sys.stdin.read()}), flush=True); " + "print('private-stderr-marker', file=sys.stderr)" + ) + result = diagnostics._run_probe( + [sys.executable, "-c", script], + extra_env={"SYNTHETIC_METADATA_PATH": "safe-metadata", "ZCODE_HOME": "ignored-override"}, + ) + + assert result["status"] == "observed" + metadata = json.loads(next(line for line in result["output"].splitlines() if line.startswith("{"))) + disposable_root = Path(metadata["cwd"]).resolve() + for name in ("ZCODE_HOME", "ZCODE_STORAGE_DIR", "ZCODE_DATA_BASE_DIR"): + assert Path(metadata["env"][name]).resolve() == disposable_root + assert metadata["env"]["SYNTHETIC_METADATA_PATH"] == "safe-metadata" + assert not disposable_root.exists() + assert diagnostics.os.environ["ZCODE_STORAGE_DIR"] == "existing-private-storage" + assert metadata["stdin"] == "" + assert "private-stderr-marker" not in json.dumps(diagnostics._public_probe(result)) + + +def test_probe_timeout_drops_partial_output(monkeypatch): + monkeypatch.setattr(diagnostics, "PROBE_TIMEOUT_SECONDS", 0.1) + result = diagnostics._run_probe([ + sys.executable, "-c", + "import time; print('private-timeout-output', flush=True); time.sleep(30)", + ]) + + assert result["status"] == "timeout" + assert result["exit_code"] is None + assert result["output"] == "" + + +def test_missing_probe_executable_does_not_return_exception_content(tmp_path): + result = diagnostics._run_probe([str(tmp_path / "private-missing-executable")]) + + assert result["status"] == "unreadable" + assert result["exit_code"] is None + assert result["output"] == "" + assert "private-missing-executable" not in json.dumps(result) + + +def test_oversized_probe_output_is_not_projected(): + result = diagnostics._run_probe([sys.executable, "-c", "print('x' * 65537, flush=True)"]) + + assert result["status"] == "output_limit" + assert result["output"] == "" + + +@pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning") +def test_probe_deadline_survives_descendant_inheriting_stdout(monkeypatch, capsys): + monkeypatch.setattr(diagnostics, "PROBE_TIMEOUT_SECONDS", 0.1) + script = ( + "import subprocess, sys; " + "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(2)']); " + "print('private-descendant-output', flush=True)" + ) + started = time.monotonic() + result = diagnostics._run_probe([sys.executable, "-c", script]) + elapsed = time.monotonic() - started + + assert result["status"] == "timeout" + assert result["output"] == "" + assert elapsed < 1.5 + # The child exits shortly afterward; the asynchronous reader must finish + # without racing another thread that closes its pipe. + time.sleep(max(0, 2.2 - elapsed)) + assert capsys.readouterr().err == "" diff --git a/tests/test_zcode_host_surface.py b/tests/test_zcode_host_surface.py index 565578d215..a08633f938 100644 --- a/tests/test_zcode_host_surface.py +++ b/tests/test_zcode_host_surface.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import shutil from pathlib import Path import pytest @@ -57,6 +58,11 @@ def test_agent_onboarding_setup_command_installs_the_zcode_surface( "HOME": str(tmp_path / "home"), ZCODE_HOME_ENV: str(tmp_path / "zcode"), } + if os.name == "nt": + env["USERPROFILE"] = env["HOME"] + env["SYSTEMROOT"] = os.environ["SYSTEMROOT"] + env["PATH"] = str(Path(shutil.which("node") or "").parent) + env["PYTHONUTF8"] = "1" if "PYTHONPATH" in os.environ: # keep hermetic when run from a worktree env["PYTHONPATH"] = os.environ["PYTHONPATH"] @@ -73,6 +79,7 @@ def test_zcode_home_env_override_wins_over_default( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("USERPROFILE", str(tmp_path / "home")) monkeypatch.setenv(ZCODE_HOME_ENV, str(tmp_path / "custom-zcode")) assert zcode_home() == tmp_path / "custom-zcode" monkeypatch.delenv(ZCODE_HOME_ENV, raising=False) @@ -145,11 +152,8 @@ def test_agent_type_catalog_and_scheduler_binding() -> None: } -def test_activation_uses_skill_facade_loop_without_claiming_unintegrated_native_binding() -> None: - """ZCode integrates via the skill facade while native Goal Mode and - Automations bindings are not yet connected. The packet must accurately - describe the quota-gated agent turn loop without claiming unintegrated - native bindings.""" +def test_activation_keeps_skill_facade_default_and_exposes_explicit_native_opt_in() -> None: + """The existing skill loop stays default; a separate native CLI binding is opt-in.""" packet = build_host_loop_activation_packet( agent_type=HOST_SURFACE, goal_id="surface-goal", @@ -161,10 +165,41 @@ def test_activation_uses_skill_facade_loop_without_claiming_unintegrated_native_ assert packet["host_mutation"]["host_loop_primitive"] is None assert packet["host_mutation"]["loop_driver"] == "agent_cli_turn_loop" assert ( - "LoopX is currently integrated with ZCode via skill facade" + "The default ZCode entry remains the LoopX skill facade" in packet["host_mutation"]["missing_host_tool_gate"] ) assert packet["setup_command"] == _surface_install_command(HOST_SURFACE, "loopx", ".") assert "quota should-run" in " ".join(packet["activation_steps"]) assert "quota should-run" in _start_instruction(HOST_SURFACE) assert packet["entry_command_hint"] == "the LoopX skill installed in ZCODE_HOME/skills" + + native = packet["native_goal_provider"] + assert native["default_off"] is True + assert native["execution_mode"] == "managed_runtime" + assert native["requires_explicit_host_selection"] is True + assert set(native["commands"]) == {"bind", "start", "pause", "resume", "stop", "status"} + assert "zcode-goal bind --goal-id surface-goal --agent-id probe-agent" in native["commands"]["bind"] + assert "no per-model-call token limit" in native["quota_boundary"] + + +@pytest.mark.parametrize("agent_id", [None, "unregistered-agent"]) +def test_legacy_skill_activation_does_not_advertise_an_unregistered_native_actor(agent_id: str | None) -> None: + packet = build_host_loop_activation_packet( + agent_type=HOST_SURFACE, goal_id="surface-goal", agent_id=agent_id, + registered_agents=[], + ) + assert packet["activation_allowed"] is True + assert packet["activation_state"] == "legacy_unscoped" + assert packet["commands"]["heartbeat_prompt_json"] + assert packet["activation_method"] == "run_agent_cli_loop_gated_by_quota" + assert packet["native_goal_provider"]["commands"] == {} + + +def test_native_activation_uses_the_existing_normalized_actor_selection() -> None: + packet = build_host_loop_activation_packet( + agent_type=HOST_SURFACE, goal_id="surface-goal", + registered_agents=[" probe-agent ", "probe-agent"], + ) + assert packet["agent_id"] == "probe-agent" + assert packet["identity_contract"]["registered_agents"] == ["probe-agent"] + assert "--agent-id probe-agent" in packet["native_goal_provider"]["commands"]["bind"] diff --git a/tsconfig.control-plane.json b/tsconfig.control-plane.json index 6e84245ce3..23144e2d31 100644 --- a/tsconfig.control-plane.json +++ b/tsconfig.control-plane.json @@ -172,6 +172,8 @@ "tests/control_plane_ts/goal_amendment_proposal.test.ts", "tests/control_plane_ts/goal_instance_identity.test.ts", "tests/control_plane_ts/chat_session_lifecycle.test.ts", - "tests/control_plane_ts/test_python_runtime.test.ts" + "tests/control_plane_ts/test_python_runtime.test.ts", + "loopx/zcode_goal_mode/*.ts", + "tests/control_plane_ts/zcode*.test.ts" ] }