diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index e3951701b1..16a0508591 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 @@ -430,7 +465,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: @@ -476,7 +511,7 @@ jobs: # Each runner retains the measured two-worker pool. run: >- python -m pytest -v -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 @@ -526,7 +561,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 a2e8722828..912b5d7941 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"; @@ -2384,7 +2384,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(), @@ -2495,6 +2495,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 8ca9bfe6a1..45254e4b96 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)", @@ -1376,6 +1432,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..17eed82615 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 complete through the canonical Todo owner in an isolated soft-claim replay; they do not acquire an execution lease. 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 8af93936e1..76bf3bef1d 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 4723fb03b7..ea843b24a9 100644 --- a/docs/development/testing-and-quality.md +++ b/docs/development/testing-and-quality.md @@ -821,6 +821,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. @@ -871,37 +921,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 9bfeb40d8d..fba14d410f 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, @@ -456,6 +457,7 @@ def server_close(self) -> None: class ChatRequestHandler( + ZCodeGoalRequestMixin, PrivateConversationRequestMixin, CompletedTodoRequestMixin, ExploreResultsRequestMixin, @@ -1389,6 +1391,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, @@ -1483,6 +1487,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 68b2f83e49..1a18033202 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -174,6 +174,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 1d3f4d4436..348c604c81 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 45d59214e1..ce5ec26da5 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -176,22 +176,20 @@ 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. - # Paired fixed-path output with Agent vision: 16,735 -> 17,408; - # added objective/evidence advice preserves all prior obligations. - # 18,000 leaves bounded headroom; ordinary paths remain unchanged. - # Keep the line, per-Todo and fixed semantic-growth guards below. + "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": 14_500, "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, @@ -486,8 +484,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", @@ -540,7 +538,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 7f987680ac..12d7148e03 100644 --- a/loopx/extensions/process_runtime.py +++ b/loopx/extensions/process_runtime.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass import os import signal @@ -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] | subprocess.Popen[str], 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] | subprocess.Popen[str]) -> 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) @@ -128,6 +171,74 @@ def terminate_process_tree( process.wait() + +def prepare_owned_process_cleanup(process: subprocess.Popen[bytes]) -> Callable[[], None]: + """Retain diagnostic tree ownership even if its leader exits. + + Windows callers must create the child suspended (CREATE_SUSPENDED) so + it cannot spawn descendants before assignment to the private Job Object. + POSIX callers must start a new session, as for terminate_process_tree. + """ + if os.name != "nt": + return lambda: terminate_process_tree(process, 0) + import ctypes + from ctypes import wintypes + + kernel = ctypes.WinDLL("kernel32", use_last_error=True) + native = ctypes.WinDLL("ntdll") + kernel.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel.CreateJobObjectW.restype = wintypes.HANDLE + kernel.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel.QueryInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.c_void_p] + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + native.NtResumeProcess.argtypes = [wintypes.HANDLE] + native.NtResumeProcess.restype = ctypes.c_long + + class Accounting(ctypes.Structure): + _fields_ = [(name, ctypes.c_longlong) for name in ("user", "kernel", "period_user", "period_kernel")] + [ + (name, wintypes.DWORD) for name in ("faults", "total", "active", "terminated")] + + job = kernel.CreateJobObjectW(None, None) + if not job: + process.kill() + process.wait() + raise OSError("Owned process job creation failed") + try: + if not kernel.AssignProcessToJobObject(job, int(process._handle)): + raise OSError("Owned process job assignment failed") + if native.NtResumeProcess(int(process._handle)) != 0: + raise OSError("Owned process resume failed") + except BaseException: + process.kill() + process.wait() + kernel.CloseHandle(job) + raise + closed = False + + def cleanup() -> None: + nonlocal closed + if closed: + return + try: + if not kernel.TerminateJobObject(job, 1): + raise OSError("Owned process job termination failed") + deadline = time.monotonic() + 1 + while True: + accounting = Accounting() + if not kernel.QueryInformationJobObject(job, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None): + raise OSError("Owned process job observation failed") + if accounting.active == 0: + process.wait(timeout=max(.01, deadline - time.monotonic())) + return + if time.monotonic() >= deadline: + raise TimeoutError("Owned process job cleanup unconfirmed") + time.sleep(.01) + finally: + kernel.CloseHandle(job) + closed = True + return cleanup + def run_capped_process( argv: Sequence[str], *, 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 3ffca4c174..3441a9b55b 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -503,7 +503,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 1013, + "line": 1015, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -511,7 +511,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 540, + "line": 542, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1592, + "line": 1598, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -527,7 +527,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1703, + "line": 1709, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -543,7 +543,7 @@ }, { "site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#2", - "line": 185, + "line": 186, "column": 21, "kind": "codec_read", "api": "load_registry", @@ -567,7 +567,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", @@ -2348,6 +2348,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 97ca086f43..d5642c0e49 100644 --- a/loopx/status.py +++ b/loopx/status.py @@ -1359,6 +1359,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]: @@ -1376,6 +1377,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..99c4cb841c --- /dev/null +++ b/loopx/zcode_goal_mode/app-server.ts @@ -0,0 +1,325 @@ +/** Provider transport for ZCode's legacy NDJSON session/goal protocol. */ +import { spawn, execFile, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +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; + /** The local guardian owns a separate native process group and confirms its cleanup. */ + guardian?: boolean; + 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 termination?: Promise; + 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().catch(() => { /* close() retains and reports this cleanup rejection. */ }); + } + 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 terminate(): Promise { + if (this.termination) return this.termination; + const child = this.process; + if (!child) return Promise.resolve(); + this.termination = (async () => { + if (this.options.guardian) { + // Revocation must reach the guardian before its outer group is killed. + if (child.exitCode === null && child.signalCode === null) { + child.stdin.end(); + if (globalThis.process.platform !== "win32") child.kill("SIGTERM"); + try { await waitForOwnedExit(child, 3_000); } + catch { + try { await terminateOwnedProcess(child); } + finally { throw new ZCodeProtocolError("process_cleanup_unconfirmed"); } + } + } + if (child.exitCode !== 0) throw new ZCodeProtocolError("process_cleanup_unconfirmed"); + if (globalThis.process.platform === "win32") return; // Successful guardian taskkill confirmed its native tree. + } + await terminateOwnedProcess(child); + })(); + return this.termination; + } + async close(): Promise { + this.closed = true; + for (const request of this.pending.values()) { clearTimeout(request.timer); request.reject(new ZCodeProtocolError("closed")); } + this.pending.clear(); + await this.terminate(); + } +} + + +function waitForOwnedExit(child: ChildProcessWithoutNullStreams, timeout: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((done, reject) => { + const exited = () => { clearTimeout(timer); done(); }; + const timer = setTimeout(() => { + child.removeListener("exit", exited); + reject(new ZCodeProtocolError("process_cleanup_unconfirmed")); + }, timeout); + child.once("exit", exited); + }); +} +const observeProcesses = promisify(execFile); +async function ownedGroupHasExited(pid: number, timeout: number): Promise { + try { + const { stdout } = await observeProcesses("ps", ["-A", "-o", "pgid=", "-o", "stat="], {timeout, encoding: "utf8"}); + if (!stdout.trim()) throw new Error("Missing process observation"); + for (const line of stdout.split("\n")) { + if (!line.trim()) continue; + const fields = line.trim().split(/\s+/); + if (fields.length !== 2 || !/^\d+$/.test(fields[0])) throw new Error("Invalid process observation"); + if (Number(fields[0]) === pid && !fields[1].startsWith("Z")) return false; + } + return true; + } catch { throw new ZCodeProtocolError("process_cleanup_unconfirmed"); } +} +/** Terminate only a directly spawned child and its owned process tree. */ +export async function terminateOwnedProcess(child: ChildProcessWithoutNullStreams): Promise { + if (!child.pid) return; + if (globalThis.process.platform === "win32") { + // Once a Windows leader is gone, taskkill cannot prove descendant ownership. + if (child.exitCode !== null || child.signalCode !== null) throw new ZCodeProtocolError("process_cleanup_unconfirmed"); + await new Promise((done, reject) => { + const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {stdio: "ignore", windowsHide: true}); + const timer = setTimeout(() => { killer.kill(); reject(new ZCodeProtocolError("process_cleanup_unconfirmed")); }, 2_000); + killer.once("error", () => { clearTimeout(timer); reject(new ZCodeProtocolError("process_cleanup_unconfirmed")); }); + killer.once("exit", code => { clearTimeout(timer); code === 0 ? done() : reject(new ZCodeProtocolError("process_cleanup_unconfirmed")); }); + }); + await waitForOwnedExit(child, 2_000); + return; + } + const deadline = Date.now() + 2_000; + // A reaped leader retains its group identity while executable descendants live. + if (!await ownedGroupHasExited(child.pid, 2_000)) { + try { globalThis.process.kill(-child.pid, "SIGKILL"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw new ZCodeProtocolError("process_cleanup_unconfirmed"); + } + } + await waitForOwnedExit(child, Math.max(1, deadline - Date.now())); + while (!await ownedGroupHasExited(child.pid, Math.max(1, deadline - Date.now()))) { + if (Date.now() >= deadline) throw new ZCodeProtocolError("process_cleanup_unconfirmed"); + await new Promise(resolve => setTimeout(resolve, 10)); + } +} 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..33e0785c02 --- /dev/null +++ b/loopx/zcode_goal_mode/cli.ts @@ -0,0 +1,300 @@ +/** 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(); + let shutdownPromise: Promise | undefined; + const shutdown = (): Promise => { + if (shutdownPromise) return shutdownPromise; + ending = true; + clearTimeout(monitor); clearTimeout(idle); clearTimeout(executionDeadline); + server.close(); + shutdownPromise = (async () => { + try { await controller?.close(); } finally { await release(); } + })(); + return shutdownPromise; + }; + 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"), + }, {guardian: true, 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..8135ece9ee --- /dev/null +++ b/loopx/zcode_goal_mode/diagnostics.py @@ -0,0 +1,358 @@ +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 + +from loopx.extensions.process_runtime import prepare_owned_process_cleanup + +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. Shared transport owns the complete isolated tree; + # the reader owns pipe close, which may otherwise wait on a descendant. + 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, + start_new_session=os.name == "posix", + creationflags=(subprocess.CREATE_NO_WINDOW | 4) if os.name == "nt" else 0, + ) + cleanup = prepare_owned_process_cleanup(process) + 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() + break + output.append(chunk) + except (OSError, ValueError): + read_failed.set() + finally: + try: + stream.close() + except OSError: + pass + + reader = threading.Thread(target=capture, daemon=True) + reader.start() + status: ProbeStatus + exit_code = None + while True: + exit_code = process.poll() + if limited.is_set(): + status = "output_limit" + break + if exit_code is not None and not reader.is_alive(): + status = "unreadable" if read_failed.is_set() else "observed" if exit_code == 0 else "failed" + break + if time.monotonic() >= deadline: + status = "timeout" + break + limited.wait(min(.01, max(0, deadline - time.monotonic()))) + try: + # Ownership survives leader exit; the shared transport confirms the + # isolated group is no longer executable before returning a report. + cleanup() + except (OSError, RuntimeError, subprocess.TimeoutExpired): + status = "unreadable" + reader.join(timeout=1) + if reader.is_alive(): + status = "unreadable" + else: + disposable.cleanup() + 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..870bd4b23d --- /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); + 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) void close().then(() => process.exit(code === 0 ? 0 : 1), () => process.exit(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 8827e33c17..69d2154989 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -88,6 +88,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..49c1f4e6f5 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 = { @@ -23,7 +25,7 @@ "loopx/control_plane/goals/first_party_host_admission.py", "loopx/control_plane/goals/source_session_recreation.py", "loopx/control_plane/goals/source_session_turn_effects.py", - "loopx/control_plane/coordination/runtime_shadow.py", + "loopx/control_plane/coordination/authority_source_capture.py", "loopx/control_plane/coordination/shadow_goal_scope.py", "loopx/control_plane/projects/registry.py", "loopx/control_plane/turn_driver/codex_sessions.py", @@ -32,6 +34,52 @@ } +# 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_runtime_root"}, +} + + +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_runtime_root") + calls = [node for node in ast.walk(reader) if isinstance(node, ast.Call)] + assert {node.func.id for node in calls if isinstance(node.func, ast.Name)} == { + "load_project_registry", "resolve_runtime_root", + } + attributes = [node for node in calls if isinstance(node.func, ast.Attribute)] + assert len(attributes) == 1 + assert attributes[0].func.attr == "resolve" + assert isinstance(attributes[0].func.value, ast.Call) + assert attributes[0].func.value.func.id == "resolve_runtime_root" + returned = [node.value for node in ast.walk(reader) if isinstance(node, ast.Return)] + assert returned == [attributes[0]] + guard = next(node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == "_canonical_writer_guard_path") + guard_calls = [node for node in ast.walk(guard) if isinstance(node, ast.Call)] + assert all(isinstance(node.func, ast.Name) for node in guard_calls) + assert {node.func.id for node in guard_calls} == { + "shadow_maintenance_lock_target", "_canonical_runtime_root", + } + + def test_direct_project_registry_loaders_have_source_session_denial() -> None: callers: set[str] = set() for path in (REPO_ROOT / "loopx").rglob("*.py"): @@ -59,27 +107,16 @@ def test_direct_project_registry_loaders_have_source_session_denial() -> None: "loopx/control_plane/goals/first_party_host_admission.py", "loopx/control_plane/goals/source_session_recreation.py", "loopx/control_plane/goals/source_session_turn_effects.py", - "loopx/control_plane/coordination/runtime_shadow.py", + "loopx/control_plane/coordination/authority_source_capture.py", "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 +126,28 @@ 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("reader_name", ["_canonical_runtime_root", "_canonical_writer_guard_path"]) +@pytest.mark.parametrize("authority_call", ["effect_runtime_result", "mutate_project_registry"]) +def test_recreation_metadata_exception_rejects_authority_calls(authority_call: str, reader_name: 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 == reader_name + ) + 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 8a3e131d8d..3ffaa477c9 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( @@ -1695,8 +1698,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() @@ -1927,14 +1930,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 @@ -1978,9 +1979,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_cold_source_disposition_e2e.py b/tests/control_plane/test_cold_source_disposition_e2e.py index bcdd9be27e..2d063af8fc 100644 --- a/tests/control_plane/test_cold_source_disposition_e2e.py +++ b/tests/control_plane/test_cold_source_disposition_e2e.py @@ -117,11 +117,10 @@ def backup(fixture, name): "--format", "json", "backup-state", "--project", str(fixture.state.parent), "--output-dir", str(fixture.state.parent.parent / "backups"), "--backup-id", name, "--current-project-only", "--no-skills", "--no-automations", "--execute"], - cwd=fixture.state.parent.parent, capture_output=True, text=True, timeout=60) - imported_package = Path(child.stderr.partition("\n")[0]).resolve() - assert imported_package.is_file(), child.stderr - assert imported_package.name == "__init__.py" - assert imported_package.parent.name == "loopx" + cwd=fixture.state.parent.parent, + env={**os.environ, "PYTHONPATH": str(Path(loopx.__file__).resolve().parent.parent)}, + capture_output=True, text=True, timeout=60) + assert str(Path(loopx.__file__).resolve()) in child.stderr assert child.returncode == 0, child.stdout + child.stderr result = json.loads(child.stdout) assert result["ok"], result 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_local_provider_settlement_journey.py b/tests/control_plane/test_local_provider_settlement_journey.py index 04868933c1..d98b73d9ed 100644 --- a/tests/control_plane/test_local_provider_settlement_journey.py +++ b/tests/control_plane/test_local_provider_settlement_journey.py @@ -14,8 +14,8 @@ from canonical_authority_fixture import isolate_sqlite_runtime import test_quota_settlement_cli as settlement -from test_quota_authority_settlement_journey import _source from test_todo_list_record_references import expand_references +from test_quota_authority_settlement_journey import _source REPO = Path(__file__).resolve().parents[2] diff --git a/tests/control_plane/test_native_child_closeout_cli.py b/tests/control_plane/test_native_child_closeout_cli.py index d9602dc609..9973df7cd1 100644 --- a/tests/control_plane/test_native_child_closeout_cli.py +++ b/tests/control_plane/test_native_child_closeout_cli.py @@ -63,7 +63,9 @@ def reject(*args: str, reason: str) -> None: assert refreshed["settlement_progress"]["state"] == "spend_required" ack = json.loads(Path(refreshed["json_path"]).read_text())["autonomous_replan_ack"] assert ack["recorded"] is True - assert "fresh_vision_path_outcome" in ack["semantic_delta"]["outcomes"] + # The preceding Todo add durably acknowledged the runnable successor. + # Refresh reads that original ACK rather than substituting a later path outcome. + assert "new_runnable_successor" in ack["semantic_delta"]["outcomes"] reject(*base, "record", "--operation-id", "op-new", "--stage", "decision", "--operation", "spawn", "--outcome", "started", "--entrypoint-id", "generic_host", "--execute", reason="open, work-admitted") 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 2b05051c61..b3bd9cd761 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -1900,6 +1900,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", @@ -1977,6 +1979,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", @@ -2038,6 +2042,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", @@ -2884,12 +2890,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" @@ -2904,23 +2928,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 @@ -4096,6 +4103,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 @@ -7125,6 +7133,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..7ae86771bb 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" @@ -53,8 +53,8 @@ def test_cli_successor_refresh_resets_periodic_window(tmp_path: Path, capsys) -> registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ "id": GOAL, "status": "active", "repo": str(project), "state_file": state.name, # 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/test_todo_projection_recovery.py b/tests/control_plane/test_todo_projection_recovery.py index c9ce1b847e..1457cbe0cd 100644 --- a/tests/control_plane/test_todo_projection_recovery.py +++ b/tests/control_plane/test_todo_projection_recovery.py @@ -446,5 +446,5 @@ def test_objective_display_never_changes_canonical_authority(canonical_display, assert _read(runtime) == before state.write_text("