diff --git a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts index b7a61c4567..b0c3e86794 100644 --- a/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts +++ b/apps/presentation/dashboard/smoke/bound-conversation-smoke.ts @@ -187,6 +187,20 @@ try { assert.deepEqual(calls, [{path: `/api/chat/sessions?conversation_binding_id=${bindingId}`, method: "GET"}], "A fresh check of the same unchanged binding reuses the complete verified history"); + // The final source check can keep the same address while reporting a + // different canonical Session scope. Previously read pages cannot publish. + sourceChecks = 0; + globalThis.fetch = async (input, init) => { + const response = await routedRead(input, init); + if (!isBoundDiscovery(input) || ++sourceChecks !== 2) return response; + const changedSource = await response.json(); + changedSource.sessions[0].channel_id = "unrelated-original-channel"; + return new Response(JSON.stringify(changedSource), {status: response.status}); + }; + await assert.rejects(() => fetchChatHistory(options), /original Session scope changed during history read/); + assert.equal(sourceChecks, 2); + globalThis.fetch = routedRead; + // Current source revocation blocks recovery before another history page. child.stdin.write("deny\n"); assert.equal((await next()).source_verified, false); calls.length = 0; diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 95f19d780f..3aa0f37c87 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -959,6 +959,7 @@ export type ChatSessionSnapshot = { session: ChatSessionSummary; messages: ChatVisibleMessage[]; active_turn: Record | null; + selected_turn?: {turn_id: string; status: string; terminal: boolean}; // Derived from one complete, verified read. Never persisted or an authority grant. verifiedHistoryBasis?: string; message_page?: { @@ -1092,6 +1093,20 @@ export async function fetchChatSessionHistory(session: ChatSessionSummary | stri return readChatSessionHistory(session, signal, turnId); } +export async function fetchConfirmedTaskTurn( + {goalId, todoId, sessionId, turnId}: {goalId: string; todoId: string; sessionId: string; turnId: string}, + signal: AbortSignal, +) { + const history = await fetchChatSessionHistory(sessionId, signal, turnId); + const turn = history.selected_turn; + if (history.session.goal_id !== goalId || history.session.channel_id !== `task.${todoId}` + || turn?.turn_id !== turnId || typeof turn.status !== "string" || !turn.status + || typeof turn.terminal !== "boolean") { + throw new Error("The original Task Turn could not be verified."); + } + return {turn, messages: history.messages}; +} + async function readChatSessionHistory(session: ChatSessionSummary | string, signal?: AbortSignal, turnId?: string, progress?: ChatHistoryReadProgress, checkpoint?: (progress: ChatHistoryReadProgress) => void): Promise { const sessionId = typeof session === "string" ? session : session.session_id; @@ -1375,10 +1390,13 @@ export async function fetchChatHistory(options: { // while it was in flight. Reuse the original source owner, never a fallback. if (missing.length && results.some(result => result.status === "fulfilled")) { if (options.bound) { + const read = results[0]; try { - await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)); + const confirmed = await resolveBoundChatSession(options.bound, AbortSignal.timeout(CHAT_HISTORY_READ_TIMEOUT_MS)); + if (read?.status === "fulfilled" && !sameHistorySource(confirmed, read.value.session)) { + throw new Error("The original Session scope changed during history read."); + } } catch (error) { - const read = results[0]; if (options.managed || read?.status !== "fulfilled" || !historyTransportUnavailable(error)) throw error; return { messages: [], diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 3fe4f5dda2..144f4bec37 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -185,14 +185,18 @@ export function ChannelTimeline({ } if (item.kind === "proposal") { const appliedTeamPlan = item.proposal.actionKind === "team.plan" && item.proposal.status === "applied"; + const taskReceipt = item.proposal.reviewPlan?.taskExecution; const pendingOperation = item.proposal.reviewPlan?.operationFrame?.kind === "pending"; return ( {showManagerTeamResults && onOpenGoalEvidence && appliedTeamPlan && item.proposal.goalId && item.proposal.teamPlanTodoIds?.length ? diff --git a/apps/presentation/dashboard/src/features/personal-workspace/confirmed-task-result.tsx b/apps/presentation/dashboard/src/features/personal-workspace/confirmed-task-result.tsx new file mode 100644 index 0000000000..e9797bb6b4 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/confirmed-task-result.tsx @@ -0,0 +1,62 @@ +import {useEffect, useId, useState} from "react"; +import {fetchConfirmedTaskTurn} from "../../data/chat"; +import {visibleAgentMessage} from "./answer-text"; +import {localizedSessionStatus, useWorkspaceI18n} from "./i18n"; +import {MarkdownText} from "./markdown"; + +type TaskReceipt = Parameters[0]; +type Result = Awaited>; + +export function ConfirmedTaskResult({receipt}: {receipt: TaskReceipt}) { + const {t} = useWorkspaceI18n(); + const panelId = useId(); + const [open, setOpen] = useState(false); + const [read, setRead] = useState(null); + const [retry, setRetry] = useState(0); + const {goalId, todoId, sessionId, turnId} = receipt; + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + let next: ReturnType | undefined; + async function load() { + try { + const result = await fetchConfirmedTaskTurn({goalId, todoId, sessionId, turnId}, controller.signal); + if (controller.signal.aborted) return; + setRead(result); + if (!result.turn.terminal) next = setTimeout(load, 2_500); + } catch { + if (!controller.signal.aborted) setRead(new Error()); + } + } + void load(); + return () => { controller.abort(); clearTimeout(next); }; + }, [open, goalId, todoId, sessionId, turnId, retry]); + + const result = read && !(read instanceof Error) ? read : null; + const messages = result?.turn.terminal + ? result.messages.filter(message => ["agent", "assistant", "error"].includes(message.role)) + .map(message => ({...message, text: visibleAgentMessage(message.text)})).filter(message => message.text) + : []; + return <> + + + ; +} 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 49283ad4e9..407073ec4e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -46,6 +46,7 @@ import { localizedGoalState, localizedSessionStatus, useWorkspaceI18n } from "./ import { formatCostUsd, formatDurationMs, formatTokenCount, formatUsageValue, hasGoalUsage } from "./personal-workspace-model"; import { TeamPlanResult } from "./team-plan-result"; import { TaskRequest, taskRequestIdentity, type TaskRequestRead } from "./task-request"; +import { ConfirmedTaskResult } from "./confirmed-task-result"; import { parseTodoResumeCondition, todoResumeTransition } from "./todo-resume-condition"; import { MarkdownText } from "./markdown"; import { formatMonitorDate } from "./monitor-readback"; @@ -184,6 +185,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention const visibleProposalFields = selection.kind === "proposal" ? selection.item.fields.filter(field => !completionProofFields.includes(field)) : []; const proposalTarget = selection.kind === "proposal" ? selection.item : null; + const taskExecution = proposalTarget?.status === "applied" && !proposalReadbackUnavailable && !readOnly + ? proposalTarget.reviewPlan?.taskExecution : undefined; const proposalRequests = proposalTarget?.todoId ? attentionHistory.filter(item => item.goalId === proposalTarget.goalId && item.todoId === proposalTarget.todoId) : []; @@ -343,7 +346,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention && selection.item.reviewPlan?.operationFrame?.kind === "result" && selection.item.reviewPlan.operationFrame.resultKind === "unknown"; const decisionFrame = selection.kind === "proposal" ? selection.item.reviewPlan?.decisionFrame : undefined; - const title = selection.kind === "attention" ? t("drawer.titleAttention") + const title = taskExecution ? t("drawer.taskLaunchReceipt") + : selection.kind === "attention" ? t("drawer.titleAttention") : selection.kind === "todo" ? t("drawer.taskDetails") : selection.kind === "run" ? t("drawer.runDetails") : selection.kind === "output" ? t("drawer.titleOutput") @@ -661,7 +665,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention role="dialog" >
-

{title}

{selection.kind !== "goal" ?

{contextLabel}

: null}
+

{title}

{selection.kind !== "goal" && !taskExecution ?

{contextLabel}

: null}
{selection.kind === "todo" && onToggleInspectorSize ? : null}
: null} {selection.item.actionKind === "team.plan" && selection.item.status === "applied" ? :
- {selection.item.reviewPlan?.operationFrame?.kind === "pending" + {!taskExecution ? {selection.item.reviewPlan?.operationFrame?.kind === "pending" ? t(`proposal.kind.${selection.item.actionKind}`) - : `${t(`proposal.kind.${selection.item.actionKind}`)} · ${t(`proposal.status.${selection.item.status}`)}`} + : `${t(`proposal.kind.${selection.item.actionKind}`)} · ${t(`proposal.status.${selection.item.status}`)}`} : null} {decisionFrame ? {t(`proposal.decision.${decisionFrame.decision}`)} : null}

{selection.item.title}

{selection.item.todoId && (selection.item.actionKind === "gate.resolve" || selection.item.actionKind === "todo.update") ?
: null} {selection.item.impact ?

{selection.item.impact}

: null} - {selection.item.reviewPlan && !selection.item.reviewPlan.retryOriginal && selection.item.actionKind !== "team.plan" && !(decisionFrame && ["ready", "applied"].includes(selection.item.status)) ?

{operationUnknown + {selection.item.reviewPlan && !selection.item.reviewPlan.retryOriginal && !taskExecution && selection.item.actionKind !== "team.plan" && !(decisionFrame && ["ready", "applied"].includes(selection.item.status)) ?

{operationUnknown ? t("actionReview.operation_reconcile_original") : selection.item.reviewPlan.operationFrame?.kind === "inactive" ? t(`actionReview.${selection.item.reviewPlan.operationFrame.reason}`) @@ -1239,7 +1243,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ? t("actionReview.operation_result_delivery_pending") : t(`actionReview.${selection.item.reviewPlan.reason}`)}

: null} {selection.item.status === "ready" && selection.item.actionKind !== "team.plan" && !decisionFrame ?

{t("drawer.proposalExplainer")}

: null} - {visibleProposalFields.length ?
{visibleProposalFields.map((field) =>
{field.label}
{field.value}
)}
: null} + {!taskExecution && visibleProposalFields.length ?
{visibleProposalFields.map((field) =>
{field.label}
{field.value}
)}
: null}
} {selection.item.reviewPlan?.creationProgress?.goalId && selection.item.status !== "applied" ?
{t("proposal.field.goalId")}: {selection.item.reviewPlan.creationProgress.goalId} · {t("proposal.status.applied")} @@ -1247,8 +1251,10 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.reviewPlan.creationProgress.todoIds ? {t("proposal.field.initialTodos")}: {selection.item.reviewPlan.creationProgress.todoIds.length} · {t("proposal.status.applied")} : null}
: null} {selection.item.status === "applied" && decisionFrame ?

{t(`proposal.applied.gate.${decisionFrame.decision}`)}{decisionFrame.dependentEffect ? {t(`proposal.dependentEffect.${decisionFrame.dependentEffect}`)} : null}

- : selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t("drawer.proposalApplied")}

: null} - {selection.item.status === "applied" && selection.item.actionKind !== "operation.execute" && selection.item.goalId ? : null} + : selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : t(taskExecution ? "feedback.taskStartRequested" : "drawer.proposalApplied")}

: null} + {taskExecution ? + : selection.item.status === "applied" && selection.item.actionKind !== "operation.execute" && selection.item.goalId ? : null} {selection.item.status === "stale" ?

{t("drawer.proposalStale")}

: null} {selection.item.status === "error" && !selection.item.reviewPlan?.retryOriginal ?
{operationUnknown ? t("proposal.operationState.submission_unknown") : selection.item.actionKind === "team.plan" || selection.item.actionKind === "goal.create" || selection.item.reviewPlan?.reason === "readback_unverified" ? t("actionReview.readback_unverified") : t("drawer.proposalApplyFailed")}{selection.item.errorMessage ? {selection.item.errorMessage} : null}{selection.item.actionKind !== "goal.create" ? {t(operationUnknown ? "actionReview.operation_reconcile_original" : selection.item.actionKind === "team.plan" ? "proposal.teamPlan.retryUnavailable" : "drawer.proposalApplyFailedHint")} : null}
: null} {selection.item.status === "rejected" ?

{t("drawer.proposalRejected")}

: null} @@ -1259,7 +1265,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.status === "gated" && selection.item.actionKind === "gate.resolve" ?
{t("drawer.gateDecisionRecheckTitle")}{t("drawer.gateDecisionRecheck")}
: selection.item.status === "gated" ?
{selection.item.actionKind === "operation.execute" ? selection.item.primaryLabel : selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length || selection.item.gate?.kind === "agent_unavailable" ? selection.item.impact : t("drawer.gateRequiresHostDescription")}{selection.item.gate?.nextAction ? {selection.item.gate.nextAction} : null}
: null} {!readOnly && selection.item.workspaceCandidates?.length ?
{selection.item.workspaceCandidates.map((candidate) => )}
: null} - {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (!["team.plan", "goal.create"].includes(selection.item.actionKind) || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(["team.plan", "goal.create"].includes(selection.item.actionKind) && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? : null} + {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (!["team.plan", "goal.create"].includes(selection.item.actionKind) || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(["team.plan", "goal.create"].includes(selection.item.actionKind) && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame || taskExecution) && selection.item.status === "applied") ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && !selection.item.reviewPlan?.creationProgress?.goalId && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? : null} {!readOnly && selection.item.persisted !== false && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} {!["applied", "applying"].includes(selection.item.status) ? : null} @@ -1314,7 +1320,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
goal_id: {goalId}kind: {selection.kind} {selection.kind === "run" ? <>session_id: {selection.item.sessionId ?? t("drawer.notLinked")}turn_id: {selection.item.turnId ?? t("common.none")}adapter: {selection.item.agentId}status: {selection.item.sessionStatus ?? selection.item.status} : selection.kind === "proposal" ? <>action: {selection.item.actionKind}status: {selection.item.status} : <>schedule_id: {selection.item.scheduleId}status: {selection.item.status ?? "active"}} - {completionProofFields.map(field => {field.key}: {field.value})} + {(taskExecution ? visibleProposalFields : completionProofFields).map(field => {taskExecution ? field.label : field.key}: {field.value})}
) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5ed30c9b3d..435030e722 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -238,6 +238,17 @@ const en = { "feedback.applying": "Running: {title}", "feedback.cancelFailed": "Cancel failed: {error}", "feedback.completed": "Completed: {title}", + "feedback.taskCreated": "Task created", + "feedback.taskStartRequested": "Task submitted for execution", + "drawer.taskLaunchReceipt": "Task receipt", + "drawer.taskReceiptResult": "Result of this execution", + "drawer.taskReceiptShowResult": "View this result", + "drawer.taskReceiptHideResult": "Hide result", + "drawer.taskReceiptReading": "Reading the original execution…", + "drawer.taskReceiptPending": "Waiting for a result from this execution.", + "drawer.taskReceiptMissing": "The original execution is temporarily unavailable.", + "drawer.taskReceiptNoResult": "This execution ended without returning a result.", + "drawer.taskReceiptTimedOut": "Timed out", "feedback.todoMarkedComplete": "Marked complete", "feedback.executionFailed": "Execution failed: {error}", "feedback.gateRequired": "Your confirmation is required: {summary}", @@ -941,6 +952,7 @@ const en = { "drawer.retryOriginal": "Retry original operation", "actionReview.canonical_update_retry": "The edit is not yet verified. Retry this operation to recover its result.", "actionReview.goal_creation_retry": "Goal initialization is incomplete. Retry the original creation; its recorded storage choice is preserved.", + "actionReview.task_start_retry": "The Task was saved. Retry its original start to continue with that Task.", "actionReview.canonical_update_projection_pending": "The operation was committed; display delivery is pending. Retry this operation to restore the current view.", "actionReview.team_plan_retry": "Retry this assignment using the original plan. Current state is checked again, and any tasks already created are recovered without duplication.", "actionReview.apply_failed": "Execution did not complete. Check the failure and regenerate the preview before retrying.", @@ -1844,6 +1856,17 @@ const zhCN: Record = { "feedback.applying": "正在执行:{title}", "feedback.cancelFailed": "取消失败:{error}", "feedback.completed": "已完成:{title}", + "feedback.taskCreated": "任务已创建", + "feedback.taskStartRequested": "任务已提交执行", + "drawer.taskLaunchReceipt": "任务回执", + "drawer.taskReceiptResult": "本次执行结果", + "drawer.taskReceiptShowResult": "查看本次结果", + "drawer.taskReceiptHideResult": "收起结果", + "drawer.taskReceiptReading": "正在读取原执行…", + "drawer.taskReceiptPending": "等待本次执行返回结果。", + "drawer.taskReceiptMissing": "暂时读不到原执行记录。", + "drawer.taskReceiptNoResult": "本次执行已结束,未返回结果。", + "drawer.taskReceiptTimedOut": "已超时", "feedback.todoMarkedComplete": "已标记完成", "feedback.executionFailed": "执行失败:{error}", "feedback.gateRequired": "需要你确认:{summary}", @@ -2547,6 +2570,7 @@ const zhCN: Record = { "drawer.retryOriginal": "重试原操作", "actionReview.canonical_update_retry": "编辑结果尚未确认。重试此操作以恢复原结果。", "actionReview.goal_creation_retry": "Goal 初始化尚未完成。请重试原创建操作,已记录的存储选择会保留。", + "actionReview.task_start_retry": "任务已保存。重试原操作以继续启动这个任务。", "actionReview.canonical_update_projection_pending": "操作已提交,展示尚未同步。重试此操作以恢复当前视图。", "actionReview.team_plan_retry": "可以从同一提案重试分配。系统会重新核验当前状态,并恢复已记录的结果,不会重复创建任务。", "actionReview.apply_failed": "执行未完成。请检查失败原因并重新生成预览后再试。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index ae3026be62..b232a83b46 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -48,7 +48,6 @@ const dashboard = source("../../views/dashboard-page.tsx"); const tasks = source("./goal-tasks-view.tsx"); const status = source("../../data/status.ts"); const chatData = source("../../data/chat.ts"); -const actionReview = source("../../../../../../loopx/control_plane/presentation/action_review_plan.ts"); assert.match(model, /kind: "todo"/, "Todo has its own drawer selection"); for (const field of ["dependencies", "nextTransition", "ownerLabel", "todoId", "taskClass"]) { @@ -132,7 +131,6 @@ for (const state of ["delivered", "verification_required", "explicit_unverified" assert.match(returnDelivery, new RegExp(state), `Return delivery renders ${state}`); } assert.doesNotMatch(returnDelivery, /message_ref|provider_receipt|intent_digest/, "Provider-private locator facts never enter the return status badge"); -assert.match(actionReview, /proposal\.action_kind !== "operation\.execute" \|\| \(operationFrame\?\.kind === "result" && operationFrame\.resultDeliveryVerified\)/, "An operation is not complete in the Dashboard until the current result delivery is verified"); assert.match(page, /reviewPlan\.operationFrame/, "Dashboard operation details consume the shared TS review frame"); assert.match(page, /const restoreable = stored[\s\S]*\|\| proposal\.action_kind === "operation\.execute"/, "Canonical operation readback includes terminal and cancelled rows instead of retaining a stale gated card"); assert.match(page, /proposal\.action_kind !== "operation\.execute"[\s\S]*reviewPlan\.interaction !== "completed"/, "Pending operation result-card readback remains visible instead of becoming a generic apply error"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 72884d084e..affc210cc9 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -125,7 +125,7 @@ function dedupeProposals(proposals: WorkspaceActionPreview[]): WorkspaceActionPr const latest = new Map(); proposals.forEach((proposal) => { const subject = proposal.fields.find((field) => field.key === "todo_id")?.value ?? ""; - const key = proposal.actionKind === "operation.execute" ? proposal.previewId + const key = proposal.actionKind === "operation.execute" || proposal.reviewPlan?.taskExecution ? proposal.previewId : [proposal.actionKind, proposal.goalId ?? "", subject, proposal.title].join(":"); // Two records can describe the same draft; keep the newest by its stored // time rather than whichever one this list happened to end with, since a @@ -1714,10 +1714,15 @@ export function PersonalWorkspacePage({ .filter((proposal) => ["preview_ready", "gated", "deferred", "applying"].includes(proposal.status) || compileActionReviewPlan(proposal).retryOriginal === true || (proposal.action_kind === "team.plan" && proposal.status === "applied") + || (proposal.action_kind === "todo.create" && proposal.status === "applied") // Terminal operations replace cached gated cards, including in an // already-open drawer. They never authorize local execution. || proposal.action_kind === "operation.execute") - .map((proposal) => workspaceProposal(proposal, t)); + .map((proposal) => workspaceProposal(proposal, t)) + // Keep a launch receipt reachable after a reload; plain creation does + // not have a Turn to revisit. + .filter(proposal => proposal.actionKind !== "todo.create" || proposal.status !== "applied" + || Boolean(proposal.reviewPlan?.taskExecution)); const restored = Object.fromEntries(restoreable.map((proposal) => [proposal.previewId, proposal])); const previousIds = restoredProposalIdsRef.current; restoredProposalIdsRef.current = new Set(Object.keys(restored)); @@ -2146,14 +2151,15 @@ export function PersonalWorkspacePage({ const returned = error instanceof ChatApiError ? typedActionProposalSchema.safeParse(error.payload.proposal) : null; let stored = returned?.success ? returned.data : undefined; - if (!stored && ["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) && !readbackMismatch) { + const scopedReadback = ["team.plan", "goal.create", "todo.update", "todo.create"].includes(proposal.actionKind); + if (!stored && scopedReadback && !readbackMismatch) { const readback = await actionReadback.refetch(); // Query errors retain cached data; it cannot certify this attempt. if (readback.isSuccess) stored = readback.data?.find(item => item.proposal_id === proposal.previewId); } if (stored?.proposal_id === proposal.previewId && stored.action_kind === proposal.actionKind && matchesTodoTarget(stored) - && (!["team.plan", "goal.create", "todo.update"].includes(proposal.actionKind) || stored.normalized_parameters.goal_id === proposal.goalId)) { + && (!scopedReadback || stored.normalized_parameters.goal_id === proposal.goalId)) { await actionReadback.acceptProposal(stored); const observed = workspaceProposal(stored, t); const reconciled = { ...observed, errorMessage: observed.status === "error" @@ -2828,7 +2834,8 @@ export function PersonalWorkspacePage({ else setSelection(entry.destination.selection); }} />} notice={serviceNotice} - drawer={drawerSelection ? setSelection({ kind: "attention", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={drawerReadOnly ? [] : larkConnections} + drawer={drawerSelection ? setSelection({ kind: "attention", item })} + callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={drawerReadOnly ? [] : larkConnections} todoReadbackUnavailable={drawerSelection.kind === "todo" && !workspaceGoals.some((goal) => goal.goalId === drawerSelection.item.goalId && !goal.loadState && (drawerSelection.item.detailMode !== undefined || drawerSelection.item.done || goal.agentTodos.some((todo) => todo.todoId === drawerSelection.item.todoId)))} @@ -2840,7 +2847,8 @@ export function PersonalWorkspacePage({ onRetryProposalReadback={() => void (creationProposalId ? proposalReadback.refetch() : actionReadback.refetch())} onClose={() => { if (drawerSelection.kind === "proposal" && ["applied", "rejected"].includes(drawerSelection.item.status) - && !(drawerSelection.item.status === "applied" && ["heartbeat.bind", "team.plan"].includes(drawerSelection.item.actionKind))) { + && !(drawerSelection.item.status === "applied" && (drawerSelection.item.reviewPlan?.taskExecution + || ["heartbeat.bind", "team.plan"].includes(drawerSelection.item.actionKind)))) { setProposals((current) => { const next = { ...current }; delete next[drawerSelection.item.previewId]; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index 2c88ae0b46..184162951d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1366,6 +1366,8 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .personal-run-drawer-tabs button { min-height: 36px; padding: 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--pw-muted); cursor: pointer; font-size: var(--text-caption); line-height: var(--leading-caption); font-weight: 500; } .personal-run-drawer-tabs button[aria-selected="true"] { background: #fff; color: var(--pw-text); box-shadow: 0 1px 4px rgb(30 28 20 / 12%); } .personal-session-summary { margin-top: 12px; } +.personal-task-receipt-result { margin-top: 12px; overflow-wrap: anywhere; } +.personal-task-receipt-answer p { color: var(--pw-text); font-size: var(--text-body); line-height: var(--leading-body); } .personal-session-message-record { margin-top: 14px; } .personal-session-message-record > h3 { margin: 0 0 10px; color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); letter-spacing: .06em; text-transform: uppercase; } .personal-session-message-record ol { display: grid; gap: 0; padding: 0; margin: 0; list-style: none; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts index 925a54c75b..5e7315bcda 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/todo-completion-feedback.ts @@ -39,9 +39,12 @@ export function todoCompletionPreviewFeedback( /** Call only after the action owner confirms completion of this exact proposal. */ export function completedActionFeedback( - proposal: Pick, + proposal: Pick, t: WorkspaceTranslate, ): string { + if (proposal.actionKind === "todo.create") { + return t(proposal.reviewPlan?.taskExecution ? "feedback.taskStartRequested" : "feedback.taskCreated"); + } return proposal.actionKind === "todo.update" && proposal.todoOperation === "complete" ? t("feedback.todoMarkedComplete") : t("feedback.completed", { title: proposal.title }); diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.md b/docs/architecture/rfcs/goal-workspace-surface-v0.md index 10aee26bb2..4ab1290832 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.md @@ -720,6 +720,37 @@ the existing expansion budget. This qualifies the browser driver's target selection; product focus recovery and the causes of earlier partial inventories retain their separate evidence requirements. +**Checkpoint 2026-11-12 (source-coverage execution).** +The source lane now executes the real disposable first-Goal and runtime-readiness +backends rather than returning packaged-only PASS receipts. Explicitly required +backend journeys fail before PASS when their readback did not complete. Other +scenario coverage boundaries remain valid. Existing GET/HEAD socket recovery +includes EPIPE within the same three-attempt bound; HTTP writes stay single-attempt. +Research-record search checks bind help dismissal to its current keyboard trigger +and require the selected query to survive dismissal. + +On immutable source `eedfc5e24`, local macOS/Node26 qualification passes all +86 scenarios across three non-overlapping 29/29/28 partitions with a common +catalog and source tree, verified backend-readback markers and nonempty merged +coverage. The passing partitions take 1,943.74 seconds; earlier failed runs, +setup and repair remain separate records. This is validation cost, with no +complete model/service/human cost comparison. + +The matched 200-item M2 pair retains all 15 samples and long-task windows. +Ordinary medians are 225.77/172.99/310.77/128.45/146.52 ms, maximum 366.08 ms; +coverage medians are 328.21/371.20/332.49/174.73/173.02 ms, maximum 421.22 ms. +Both have zero samples above the 1-second target. Full-suite coverage M2 also +passes: medians 325.35/270.28/326.59/164.22/166.47 ms, maximum 463.88 ms. +The real 1,200 ms repeated Board delay is correctly rejected by ordinary +qualification (slow median 1491.77 ms, maximum 1519.98 ms; all 15 +samples/windows retained). Existing strict-boundary and missing-sample tests +pass without a budget change. + +The failed source partitions and historical frozen failures retain their original +status. This is named local source qualification, not Linux CI, an installed-host +upgrade, model task completion, kernel attribution or full-cost acceptance. V10 +thresholds and the remaining real-team/product milestones retain their owners. + **Checkpoint 2026-10-11 (M1 proposed at `6c3c0c104` in [#6191](https://github.com/loopx-project/loopx/pull/6191), subsequently merged).** Verified subset: V1 reducer fixtures written from Section 5.3, mutation checks and the packaged page on synthetic status; V2 strip and Board sharing the work diff --git a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md index b621e4f368..bb885e90c5 100644 --- a/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-workspace-surface-v0.zh-CN.md @@ -513,6 +513,30 @@ M3、W2/W3、安装态以及 L1/G1 的真实交付和原请求路线返回仍由 全局 Enter 到达前被重渲染替换。验证仍须在既有展开预算内展示全部 200 项。这项证据 验证浏览器驱动的目标选择;产品焦点恢复及早期部分清单的成因仍需各自的证据。 +**检查点 2026-11-12(源码 coverage 执行)。** +源码分线现在执行真实的一次性首次 Goal 与运行就绪后端,不再提前返回仅适用于打包 +模式的 PASS 回执。明确需要后端的旅程未完成读回时,在 PASS 前失败;其他场景的 +合理 coverage 边界仍有效。既有 GET/HEAD socket 恢复纳入 EPIPE,仍至多三次; +HTTP 写请求只尝试一次。 +研究记录搜索检查将提示关闭绑定当前键盘按钮,并要求关闭后保留原查询。 + +固定源码 `eedfc5e24` 在本地 macOS/Node26 下完成全部 86 场景资格:三个互不重叠的 +29/29/28 分片共享同一完整场景目录和源码 tree,后端读回标记及非空合并 coverage 均已核对。 +本次通过的分片用时 1,943.74 秒;此前失败运行、准备和修复仍分开记录。这是验证成本, +不构成完整模型、服务和人工成本比较。 + +同版本 200 项 M2 对照保留全部 15 个样本和长任务窗口。普通模式五组中位数为 +225.77/172.99/310.77/128.45/146.52 ms,最大 366.08 ms;coverage 模式为 +328.21/371.20/332.49/174.73/173.02 ms,最大 421.22 ms,两者超过 1 秒目标的 +样本数均为 0。完整分片中的 coverage M2 也通过:中位数 +325.35/270.28/326.59/164.22/166.47 ms,最大 463.88 ms。真实注入 1,200 ms 的重复看板 +停顿被普通资格正确拒绝:慢组中位数 1491.77 ms、最大 1519.98 ms,全部 15 个 +样本和窗口仍保留。既有严格边界及缺失样本负例通过,预算未变。 + +源码分片失败及历史冻结失败保留原判定。这是指定本地源码的资格,不证明 Linux CI、 +安装宿主升级、模型任务完成、内核归因或完整成本验收。V10 阈值及剩余真实团队和产品 +里程碑仍由各自既有 owner 验收。 + **检查点 2026-10-11(M1 在 [#6191](https://github.com/loopx-project/loopx/pull/6191) 的 `6c3c0c104` 提出,后续已合并)。** 已验证的子集:V1 的按第 5.3 节编写的 reducer fixture、变异检查及合成状态打包页面; V2 的指标条与看板共用工作判定;V4 的真实隔离 Chat 动作服务与注册表 authority 上的停止、 diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index 2021df3c6c..44ed1ddf37 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3468,3 +3468,143 @@ qualifies the consumed replan read cost; full CLI process, durable writeback, model and end-to-end outcome costs are not inferred from these timings. The complete minimal load/context closure, R1/M3, independent Standard packages, workspace/meta publication and installed-host adoption remain open. + + +### 2026-11-12 — Dashboard source acceptance executes its backend journeys + +Source coverage now executes the existing first-Goal and runtime-readiness +journeys through the real disposable Chat service. Both source and packaged +lanes already prepare the verified Chat bundle. Declared backend journeys +require successful backend assertions before a PASS receipt; an early return +cannot qualify them. Scenarios with other legitimate coverage boundaries keep +their existing behavior. The shared browser proxy adds EPIPE to its existing +three-attempt GET/HEAD socket recovery, recognizes typed errno or the first +Playwright socket-error line, and never retries an HTTP write or arbitrary prose. +Research-record search checks bind help dismissal to its current keyboard trigger +and require the selected query to survive dismissal. + +On immutable source `eedfc5e24`, local macOS/Node26 qualification passes all +86 scenarios across three non-overlapping 29/29/28 partitions with a common +catalog and source tree, verified backend-readback markers and nonempty merged +coverage. The passing partitions take 1,943.74 seconds; earlier failed runs, +setup and repair remain separate records. This is validation cost, with no +complete model/service/human cost comparison. + +The matched 200-item M2 pair retains all 15 samples and long-task windows. +Ordinary medians are 225.77/172.99/310.77/128.45/146.52 ms, maximum 366.08 ms; +coverage medians are 328.21/371.20/332.49/174.73/173.02 ms, maximum 421.22 ms. +Both have zero samples above the 1-second target. Full-suite coverage M2 also +passes: medians 325.35/270.28/326.59/164.22/166.47 ms, maximum 463.88 ms. +The real 1,200 ms repeated Board delay is correctly rejected by ordinary +qualification (slow median 1491.77 ms, maximum 1519.98 ms; all 15 +samples/windows retained). Existing strict-boundary and missing-sample tests +pass without a budget change. + +Earlier failed source partitions (EPIPE, query loss and temporary-directory +failure) remain recorded separately, as do historical frozen 1.162-second +failures and cancelled runs. This repairs +source-acceptance execution and its bounded transport recovery, with no render +threshold, kernel runtime or authority change. The first-Goal fixture uses an +exit-only Agent: registration and honest failed-Turn recovery do not prove +natural-language task completion or model benefit. Complete minimal closure, +R1/M3, workspace/meta publication, installed-host adoption and full-cost +acceptance remain open. + +### 2026-11-13 — Standalone natural-language startup reaches existing owners + +The independently installed core now routes `start-goal`, +`bootstrap-command-pack`, `register-agent` and `bind-agent-thread` to the +ordinary product's existing registrars and handlers. Previously all four +required the omitted full CLI facade. The goal preview preserves exact text, +remains read-only and exposes the same ordered bootstrap, identity, Todo and +host/guard transaction. Fresh registration, collision rejection, global/source +readback and bound-session reuse execute against the default SQLite authority. +No new engine, model service, authority rule or capability activation is added. +The ordinary installation retains its complete-parser diagnostic fallback; +invalid standalone arguments retain the existing explicit unavailable failure. + +Installed qualification passes 16 checks, including the guided identity journey +and existing SQLite/File execution, rejected artifacts, renewed/released lease +refusal and confirmed-result recovery/replay. Eight focused comparisons match +the ordinary parser and preview. The original baseline independently installed +wheel refuses natural-language startup; the repaired wheel executes it. These +are executable behavioral deltas, not help-text-only delivery. + +A separate isolated generic-CLI task was performed by the current host model: +create a reusable inventory generator and JSON/Markdown reports from source +catalog/movement files, with exact decimal valuation and excluded records. Its +independent validator accepts the result. Killing the driver after its typed +result was journaled, then resuming the original key, completes the Todo and +records one settlement without invoking the model again; a further replay has +no state or quota effects. An earlier pre-work attempt expired while awaiting +model input; its unknown-outcome recovery rejection remains recorded. There +were two host transport dispatches in total and one confirmed model result; +this is not a claim of one dispatch across the entire experiment. + +A three-pair local loading census (macOS arm64, Python 3.13.5, Node 26.0.0, +new Python processes with ordinary filesystem/bytecode caches, plus two warm +calls per process) observes 8 core modules for top-level help and 186 for guided +help/preview. The direct ordinary full-facade comparison loads 1,151/1,164 +modules. Preview includes two subprocess events and nine Python socket audit +events, so process/IPC costs must remain in the closure inventory. Different +source/install cache and launch conditions, three samples, Python-only audit +coverage and unavailable model-token counters prevent a performance or complete +V10 claim. This does not approve D2 or a minimal preset. + +The source-regression attempt that exhausted local disk remains failed; its +recovered run is recorded separately. Complete minimal install/load/context +closure, initial/revised/compacted/lost-ACK context qualification, supported host +adoption, full V1–V5/V10, R1/M3, Standard/workspace/meta splitting and complete +costs remain open. Rollback the routing slice while retaining task authority and +reconciling original attempts. Existing exact-head review, premerge and bilingual +requirements, original failures, budgets and separate research/release authority +remain in force. + +### 2026-11-13 — Startup metadata isolation and installed context recovery + +Default goal-start help and preview previously imported issue-fix execution, +candidate preflight and repository analysis merely to render command hints. +The existing command templates now have a capability-owned metadata module; +bootstrap and catalog consumers use it, while the workflow module preserves +its existing Python import API and an explicit issue-fix route retains its complete preflight +contract. Independently installed base/head startup output is byte-identical; +default help/preview load 186→171 LoopX modules and the explicit route 186→179. +Module counts alone establish a narrower loading boundary, not a speed claim. + +Thirty alternating installed base/head pairs per workload on macOS arm64, +Python 3.13.5 and ordinary OS/bytecode caches observe first-entry median/p95 +of 314.73/362.55→181.25/303.01 ms for help and +542.36/670.44→311.08/572.66 ms for preview. Two subsequent calls per process +have warm medians 0.66→0.54 ms and 11.83→8.94 ms. Median peak RSS is +43.22→32.46 MB and 46.05→36.18 MB. All complete outputs match; +preview remains 52,727 bytes. Retain the head preview maximum 1,092.15 ms. +First entry includes imports; this is neither OS-cold nor full V10 qualification. +Source regression passes 348 checks with one native Windows/PowerShell skip; +the final installed wheel passes 20 checks. Earlier failed attempts remain. + +Installed CLI qualification exercises the existing context-project/read/hook +owners with complete source or explicit recovery: off keeps original values, +partial delivery and lost ACK cannot establish a baseline, unchanged/revised +views preserve complete constraints, standalone PostCompact invalidates the +baseline, and a changed captured file is refused. Warm transport may choose +full when complete delta framing would cost more. The test runs against the +original installed core as well; it qualifies the existing installed protocol +and supplies a regression boundary rather than introducing another context +owner. Native events are fixtures, not evidence of model retention or adoption. + +Instrumented SQLite/File Turn witnesses retain Python, actual Node-loader and +RPC observations. The selected Turn entry still loads 525 Python modules, +including 111 capability modules; its Node runtime retains eager shared typed +support. Complete work contracts and transport costs remain in the inventory. +This diagnostic does not qualify endpoint/network completeness, OS-cold cost, +model tokens or the full minimal composition. Keep earlier failed checks and +the separate startup/context boundary; full V1–V5/V10, R1/M3, independent +Standard/workspace packages, installed-host adoption and complete original-goal +regression remain open under their existing owners and acceptance. + +The installation gate exposed a project-skill supporting-example link that left +the installed bundle. The link now resolves to the byte-verified public source +at an immutable revision; the procedure, consent and check-scope clauses remain +unchanged. It is an on-demand supporting reference, not a runtime fetch or new +authority. Preserve the earlier failed installation gate and disk-limited base +replays separately from the repaired-head validation. diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md index b38cf52a99..83dc5bb043 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2706,3 +2706,110 @@ dry-run 拒绝路径的中位数为 142.4→135.1 ms、934.4→911.8 ms。零条 这些计时推断完整 CLI 进程、持久写回、模型及端到端成果成本。完整最小加载/ 上下文闭包、R1/M3、独立 Standard 包、workspace/meta 发布及安装宿主采用仍未 完成。 + + +### 2026-11-12 — Dashboard 源码验收执行真实后端旅程 + +源码 coverage 现在通过真实的一次性 Chat 服务执行既有首次 Goal 和运行就绪旅程。 +源码与打包分线均已准备经校验的 Chat bundle。声明需要后端的旅程必须完成真实后端 +断言后才能记录 PASS,提前返回不能满足该条件;其他有合理 coverage 边界的场景 +保留原有行为。共用浏览器代理在既有至多三次 GET/HEAD socket 恢复中纳入 EPIPE, +仅识别类型化 errno 或 Playwright 首行 socket 错误,不重试 HTTP 写请求或任意诊断文字。 +研究记录搜索检查将提示关闭绑定当前键盘按钮,并要求关闭后保留原查询。 + +固定源码 `eedfc5e24` 在本地 macOS/Node26 下完成全部 86 场景资格:三个互不重叠的 +29/29/28 分片共享同一完整场景目录和源码 tree,后端读回标记及非空合并 coverage 均已核对。 +本次通过的分片用时 1,943.74 秒;此前失败运行、准备和修复仍分开记录。这是验证成本, +不构成完整模型、服务和人工成本比较。 + +同版本 200 项 M2 对照保留全部 15 个样本和长任务窗口。普通模式五组中位数为 +225.77/172.99/310.77/128.45/146.52 ms,最大 366.08 ms;coverage 模式为 +328.21/371.20/332.49/174.73/173.02 ms,最大 421.22 ms,两者超过 1 秒目标的 +样本数均为 0。完整分片中的 coverage M2 也通过:中位数 +325.35/270.28/326.59/164.22/166.47 ms,最大 463.88 ms。真实注入 1,200 ms 的重复看板 +停顿被普通资格正确拒绝:慢组中位数 1491.77 ms、最大 1519.98 ms,全部 15 个 +样本和窗口仍保留。既有严格边界及缺失样本负例通过,预算未变。 + +此前源码分片的 EPIPE、查询丢失和临时目录失败分别保留记录;历史冻结的 1.162 秒失败 +及已取消运行也保持原判定。 +本项修复源码验收执行与有界传输恢复,不改变渲染阈值、内核运行时或 authority。 +首次 Goal 夹具使用退出式 Agent:登记及如实显示失败 Turn 的恢复不证明自然语言任务 +完成或模型收益。完整最小闭包、R1/M3、workspace/meta 发布、安装宿主采用及完整成本 +验收仍保持开放。 + +### 2026-11-13 — 独立发行版的自然语言启动到达既有 owner + +独立安装的 core 现在将 `start-goal`、`bootstrap-command-pack`、 +`register-agent` 和 `bind-agent-thread` 路由到普通产品既有的 registrar 与 +handler。此前四条命令都依赖被排除的完整 CLI facade。目标预览保留完整原文, +保持只读,并返回同一套有序的 bootstrap、身份、Todo 与 host/guard 事务。 +新身份注册、冲突拒绝、global/source 回读和绑定会话复用在默认 SQLite authority +上真实执行。没有新增 engine、模型服务、权限规则或 capability 激活。 +普通安装仍保留完整 parser 的诊断 fallback;独立 core 的非法参数仍返回既有明确 +unavailable 错误。 + +安装验证通过 16 项检查,覆盖 guided 身份旅程及既有 SQLite/File 执行、产物拒绝、 +续租/释放后的完成拒绝和已确认结果恢复/重放。八项聚焦对照与普通 parser 和预览 +一致。原始独立安装基线 wheel 拒绝自然语言启动,修复 wheel 能执行这条路径。 +这是真实可执行的行为差异,不是仅修改帮助文案。 + +另一个隔离 generic-CLI 任务由当前 host 模型实际完成:依据 catalog/movement +源文件生成可复用库存脚本及 JSON/Markdown 报告,保留精确小数计价和排除记录。 +独立 validator 接受结果。typed result 已入 journal 后杀掉 driver,再用原始 key +恢复,可完成 Todo 并只结算一次,且不再次调用模型;再次重放无状态或额度副作用。 +更早一次任务前尝试在等待模型输入期间过期,其 unknown-outcome 恢复拒绝仍保留。 +整个实验共两次 host transport dispatch、一个已确认模型结果,不能宣称全实验 +只 dispatch 一次。 + +三对本地 loading census 使用 macOS arm64、Python 3.13.5、Node 26.0.0, +新 Python 进程保留普通文件系统/bytecode 缓存,每进程再运行两次 warm 调用。 +core 顶层帮助加载 8 个模块,guided 帮助/预览加载 186 个;直接普通 full-facade +对照为 1,151/1,164 个。预览出现两次 subprocess 和九次 Python socket audit +事件,process/IPC 成本必须保留在闭包清单。源码/安装的缓存与启动条件不同、仅三 +个样本、仅 Python audit 范围及缺失模型 token 计数,使其不能支持性能或完整 +V10 结论,也不批准 D2 或 minimal preset。 + +源码回归因本地磁盘耗尽而中止的尝试仍为失败,恢复后的运行单独记录。 +完整最小 install/load/context 闭包、initial/revised/compacted/lost-ACK 上下文 +资格、受支持 host 采用、完整 V1–V5/V10、R1/M3、Standard/workspace/meta 拆包 +和完整成本仍未完成。回滚路由切片时保留 task authority 并协调原始尝试。 +既有 exact-head 评审、premerge、双语要求、原始失败、预算及独立研究/发布授权 +继续有效。 + +### 2026-11-13 — 启动元数据隔离与安装上下文恢复 + +默认目标启动的帮助和预览此前只是为了渲染命令提示,就导入 issue-fix 执行、 +candidate preflight 和仓库分析。既有命令模板现在放在 capability 自有的元数据 +模块;bootstrap 与 catalog 消费它,workflow 模块保留原 Python 导入接口,显式 +issue-fix 路由仍带完整 preflight 契约。独立安装 base/head 的启动输出逐字节一致; +默认帮助/预览的 LoopX 模块加载为 186→171,显式路由为 186→179。 +模块数量本身只证明加载边界缩小,不是速度收益结论。 + +macOS arm64、Python 3.13.5、普通 OS/bytecode 缓存下,每项 workload 运行 +30 对交替的独立安装 base/head,首次入口的中位数/p95 为帮助 +314.73/362.55→181.25/303.01 ms,预览 542.36/670.44→311.08/572.66 ms。 +每进程随后两次调用的 warm 中位数为 0.66→0.54 ms 和 11.83→8.94 ms; +峰值 RSS 中位数为 43.22→32.46 MB 和 46.05→36.18 MB。全部完整输出一致, +预览仍为 52,727 字节。保留 head 预览最大值 1,092.15 ms。首次入口包含导入, +本记录不属于 OS-cold 或完整 V10 资格。源码回归通过 348 项,1 项因缺少原生 +Windows/PowerShell 环境跳过;最终安装 wheel 通过 20 项。早期失败尝试仍保留。 + +安装 CLI 验证通过既有 context-project/read/hook owner 交付完整源或明确恢复: +关闭功能时保留原始值;部分交付和丢 ACK 不能建立基线;未变化/修订视图保留完整 +约束;独立 PostCompact 使基线失效;捕获文件被修改时拒绝读取。完整 delta framing +更贵时,warm 传输可选择 full。同一测试也在原独立 core 安装上运行,资格化既有 +安装协议并补充回归边界,不新增上下文 owner。原生事件为夹具,不证明模型保留 +上下文或宿主采用。 + +带追踪的 SQLite/File Turn witness 保留 Python、真实 Node loader 和 RPC 观察。 +选定 Turn 入口仍加载 525 个 Python 模块,其中 111 个 capability 模块;Node +runtime 仍保留 eager 的共享 typed 支持。完整 work contract 与传输成本继续保留 +在清单中。本诊断不资格化 endpoint/网络完整性、OS-cold 成本、模型 token 或完整 +最小组合。保留早期检查失败及独立的启动/上下文边界;完整 V1–V5/V10、R1/M3、 +独立 Standard/workspace 包、安装宿主采用及覆盖原始目标的完整回归仍由既有 +owner 和验收约束保持开放。 + +安装门暴露了 project skill 的支持示例链接离开安装包的问题。链接现在指向经字节 +核验的公开源文件,并固定不可变 revision;原流程、同意边界和检查范围条款保持 +原义。它是按需支持资料,不是 runtime fetch 或新 authority。早期安装门失败和 +受磁盘限制的 base 重放,与修复 head 的验证分别保留。 diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 33b295f1a8..4f7cd18e04 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -172,6 +172,12 @@ scenarioCatalog.push(completedHistoryIsolationScenario); scenarioCatalog.push(agentModelSelectionScenario); export { scenarioCatalog }; +export function requireBackendReadback(scenario, result) { + if (scenario.requiresBackendReadback && result.backendReadbackVerified !== true) { + throw new Error(`Required backend readback did not execute: ${scenario.id}`); + } +} + async function main() { const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario @@ -207,10 +213,12 @@ async function main() { ? browser : { newPage: (options = {}) => browser.newPage({ locale: "zh-CN", ...options }) }; const result = await scenario.run({ browser: scenarioBrowser, collectCoverage, url }); + requireBackendReadback(scenario, result); coverageEntries.push(...result.coverageEntries); results[scenario.id] = { status: "PASS", note: result.note, + ...(scenario.requiresBackendReadback ? {backend_readback_verified: true} : {}), duration_ms: Date.now() - startedAt, }; } catch (error) { diff --git a/examples/personal-workspace-browser/first-goal-home-live.mjs b/examples/personal-workspace-browser/first-goal-home-live.mjs index be511050f2..75f0e20f99 100644 --- a/examples/personal-workspace-browser/first-goal-home-live.mjs +++ b/examples/personal-workspace-browser/first-goal-home-live.mjs @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { resolveTestPython } from "../../scripts/test-python.mjs"; -import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; // End to end against the real service: a disposable `loopx chat` on a fresh @@ -23,9 +23,10 @@ import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs export const firstGoalHomeLiveScenario = { id: "first-goal-home-live", + requiresBackendReadback: true, async run({ browser, collectCoverage, url }) { - // The real service serves the packaged bundle; the development lane has none to serve. - if (!packaged) return { coverageEntries: [], note: "live first-Goal home runs against the packaged bundle only" }; + // Both lanes prepare the verified Chat bundle for the real backend. The + // source lane keeps Vite's source maps while proxying this same owner. const root = await mkdtemp(join(tmpdir(), "loopx-first-goal-live-")); const python = resolveTestPython({ repoRoot }); const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/fresh-install-backend.py"), root], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); @@ -130,7 +131,7 @@ export const firstGoalHomeLiveScenario = { await page.locator(".personal-goal-row", { hasText: title }).first().waitFor({ state: "visible", timeout: 20_000 }); await firstGoal.waitFor({ state: "hidden", timeout: 20_000 }); await page.screenshot({ path: resolve(outputDir, "first-goal-home-live-home.png"), animations: "disabled" }); - return { coverageEntries: await session.close(), note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning; first Goal registered from the page through the real action owner with the stub Agent's failed first Turn reported honestly; registry written, directory lists it" }; + return { coverageEntries: await session.close(), backendReadbackVerified: true, note: "real loopx chat on a fresh runtime: zero-Goal discovery with its own warning; first Goal registered from the page through the real action owner with the stub Agent's failed first Turn reported honestly; registry written, directory lists it" }; } catch (error) { if (diagnostics) process.stderr.write(diagnostics); await session?.close().catch(() => {}); diff --git a/examples/personal-workspace-browser/research-record-search.mjs b/examples/personal-workspace-browser/research-record-search.mjs index 8346ebbd2e..30a2492b79 100644 --- a/examples/personal-workspace-browser/research-record-search.mjs +++ b/examples/personal-workspace-browser/research-record-search.mjs @@ -8,9 +8,19 @@ import {outputDir, repoRoot} from "./fixture.mjs"; import {startHistoryOwner} from "./research-history.mjs"; async function readEmptyScope(page, results, message) { - await results.getByRole('button',{name:'No matches in loaded records',exact:true}).focus(); + const search = results.getByRole('searchbox'); + const query = await search.inputValue(); + const trigger = results.getByRole('button',{name:'No matches in loaded records',exact:true}); + await trigger.focus(); + // Enter opens the help even when the trigger retained focus after an earlier + // dismissal; focusing an already focused element does not fire onFocus. + await trigger.press('Enter'); await page.getByRole('tooltip').getByText(message,{exact:true}).waitFor(); - await page.keyboard.press('Escape'); + // Bind dismissal to the current trigger after rendering. A global Escape + // can reach the search input and clear the filter instead of only the help. + await trigger.press('Escape'); + await page.getByRole('tooltip').waitFor({state:'hidden'}); + assert.equal(await search.inputValue(), query, 'Dismissing scope help preserves the selected query'); } export const researchRecordSearchScenario = { diff --git a/examples/personal-workspace-browser/runtime-readiness-live.mjs b/examples/personal-workspace-browser/runtime-readiness-live.mjs index 122af36ea4..84adf6ec92 100644 --- a/examples/personal-workspace-browser/runtime-readiness-live.mjs +++ b/examples/personal-workspace-browser/runtime-readiness-live.mjs @@ -7,7 +7,7 @@ import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; import { waitForHttp } from "../dashboard-browser-smoke-support.mjs"; import { resolveTestPython } from "../../scripts/test-python.mjs"; -import { outputDir, packaged, repoRoot } from "./fixture.mjs"; +import { outputDir, repoRoot } from "./fixture.mjs"; import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; // End to end against the real service: a disposable `loopx chat` whose Codex, @@ -19,8 +19,8 @@ import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs export const runtimeReadinessLiveScenario = { id: "runtime-readiness-live", + requiresBackendReadback: true, async run({ browser, collectCoverage, url }) { - if (!packaged) return { coverageEntries: [], note: "live runtime readiness runs against the packaged bundle only" }; const root = await mkdtemp(join(tmpdir(), "loopx-readiness-live-")); const python = resolveTestPython({ repoRoot }); const child = spawn(python, ["-u", resolve(repoRoot, "examples/personal-workspace-browser/runtime-readiness-backend.py"), root, "--with-goal"], { cwd: repoRoot, env: { ...process.env, PYTHONPATH: repoRoot }, stdio: ["ignore", "pipe", "pipe"] }); @@ -152,7 +152,7 @@ export const runtimeReadinessLiveScenario = { const codex = catalog.adapters.find((row) => row.agent_id === "codex"); assert.equal(codex.available, true, "The real catalog now reports Codex available"); assert.equal(codex.unavailable_reason, undefined); - return { coverageEntries: await session.close(), note: "real loopx chat service, live PATH probe, real re-check" }; + return { coverageEntries: await session.close(), backendReadbackVerified: true, note: "real loopx chat service, live PATH probe, real re-check" }; } catch (error) { await session?.page.screenshot({ path: resolve(outputDir, "runtime-readiness-live-failure.png"), animations: "disabled" }).catch(() => {}); if (session?.errors.length) process.stderr.write(`${JSON.stringify(session.errors)}\n`); diff --git a/examples/personal-workspace-browser/scenario-context.mjs b/examples/personal-workspace-browser/scenario-context.mjs index 0f49f75046..8a275c546c 100644 --- a/examples/personal-workspace-browser/scenario-context.mjs +++ b/examples/personal-workspace-browser/scenario-context.mjs @@ -14,8 +14,13 @@ export async function fetchWorkspaceBackend(route, backendUrl, { pathname } = {} try { return await route.fetch({ url: target.href, headers }); } catch (error) { + // Playwright preserves the native socket code in its first error line. + // Only that transport signature (or a typed errno), never response prose, + // permits the existing bounded read retry. Writes remain single-attempt. + const socketCode = error?.code ?? /^route\.fetch: (?:connect|read|write) (ECONNRESET|ECONNREFUSED|EPIPE)(?:\s|$)/u + .exec(String(error?.message ?? error).split("\n", 1)[0])?.[1]; const retryable = ["GET", "HEAD"].includes(request.method()) - && /\b(?:ECONNRESET|ECONNREFUSED)\b/u.test(String(error?.message ?? error)) + && ["ECONNRESET", "ECONNREFUSED", "EPIPE"].includes(socketCode) && attempt < 2; if (!retryable) throw error; await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1))); diff --git a/examples/personal-workspace-browser/shard.test.mjs b/examples/personal-workspace-browser/shard.test.mjs index 2f0c179c41..4df9f5f5e7 100644 --- a/examples/personal-workspace-browser/shard.test.mjs +++ b/examples/personal-workspace-browser/shard.test.mjs @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import {test} from "node:test"; import {selectScenarioShard} from "./shard.mjs"; +import {requireBackendReadback} from "../personal-workspace-browser-smoke.mjs"; +import {fetchWorkspaceBackend} from "./scenario-context.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})); @@ -16,3 +18,63 @@ test("invalid partitions fail before running a partial acceptance", () => { assert.throws(() => selectScenarioShard([1,2,3], shard)); } }); + +test("a required backend journey cannot qualify from an early return", () => { + const required = {id: "backend-fixture", requiresBackendReadback: true}; + for (const result of [{coverageEntries: []}, {backendReadbackVerified: false}]) { + assert.throws(() => requireBackendReadback(required, result), /Required backend readback did not execute/); + } + assert.doesNotThrow(() => requireBackendReadback(required, {backendReadbackVerified: true})); + assert.doesNotThrow(() => requireBackendReadback({id: "ordinary-fixture"}, {coverageEntries: []})); +}); + +function proxyFixture(method, failures) { + const calls = []; + const response = {ok: true}; + return {calls, response, route: { + request: () => ({method: () => method, url: () => "http://127.0.0.1:5196/api/chat/sessions?goal_id=fixture", + headers: () => ({host: "127.0.0.1:5196", origin: "http://127.0.0.1:5196"})}), + fetch: async options => { + calls.push(options); + if (failures.length) throw failures.shift(); + return response; + }, + }}; +} + +test("native socket failures retain bounded GET and HEAD recovery", async () => { + for (const method of ["GET", "HEAD"]) { + for (const code of ["ECONNRESET", "ECONNREFUSED", "EPIPE"]) { + const fixture = proxyFixture(method, [Object.assign(new Error("native socket failure"), {code})]); + assert.equal(await fetchWorkspaceBackend(fixture.route, "http://127.0.0.1:9123"), fixture.response); + assert.equal(fixture.calls.length, 2); + assert.deepEqual(fixture.calls[0], fixture.calls[1]); + assert.equal(fixture.calls[0].url, "http://127.0.0.1:9123/api/chat/sessions?goal_id=fixture"); + assert.equal(fixture.calls[0].headers.origin, "http://127.0.0.1:9123"); + } + } + const fixture = proxyFixture("GET", [new Error("route.fetch: write EPIPE\nCall log:\nrequest")]); + assert.equal(await fetchWorkspaceBackend(fixture.route, "http://127.0.0.1:9123"), fixture.response); + assert.equal(fixture.calls.length, 2); +}); + +test("persistent socket failure cannot exceed the existing three attempts", async () => { + const errors = Array.from({length: 3}, () => Object.assign(new Error("socket failure"), {code: "EPIPE"})); + const last = errors[2]; + const fixture = proxyFixture("GET", errors); + await assert.rejects(fetchWorkspaceBackend(fixture.route, "http://127.0.0.1:9123"), error => error === last); + assert.equal(fixture.calls.length, 3); +}); + +test("write requests and arbitrary diagnostic prose never authorize a retry", async () => { + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + const error = Object.assign(new Error("socket failure"), {code: "EPIPE"}); + const fixture = proxyFixture(method, [error]); + await assert.rejects(fetchWorkspaceBackend(fixture.route, "http://127.0.0.1:9123"), observed => observed === error); + assert.equal(fixture.calls.length, 1); + } + const error = new Error("Backend result mentioned EPIPE; this is not a socket signature"); + const fixture = proxyFixture("GET", [error]); + await assert.rejects(fetchWorkspaceBackend(fixture.route, "http://127.0.0.1:9123"), observed => observed === error); + assert.equal(fixture.calls.length, 1); +}); diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 3cf4437cab..06c78b5498 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -7,10 +7,7 @@ from .agent_registry import registered_agent_ids_for_goal from .control_plane.projects.identity import default_goal_id from .paths import registered_goal_state_file -from .capabilities.issue_fix.candidate_preflight import ( - candidate_preflight_input_contract, -) -from .capabilities.issue_fix.workflow_plan import ( +from .capabilities.issue_fix.command_templates import ( ISSUE_FIX_GOAL_CANDIDATE_DISCOVERY_COMMAND_TEMPLATE, build_issue_fix_goal_command_templates, ) @@ -760,6 +757,8 @@ def _selected_goal_capability_route( "unsupported --capability-route; expected one of: " + ", ".join(START_GOAL_CAPABILITY_ROUTES) ) + from .capabilities.issue_fix.candidate_preflight import candidate_preflight_input_contract + return { "schema_version": GOAL_CAPABILITY_ROUTE_SCHEMA_VERSION, "capability_id": "issue-fix", diff --git a/loopx/capabilities/change_quality/scope.py b/loopx/capabilities/change_quality/scope.py index d526b52621..be963cdb0a 100644 --- a/loopx/capabilities/change_quality/scope.py +++ b/loopx/capabilities/change_quality/scope.py @@ -79,13 +79,29 @@ def _git( ) +def _git_path_text(output: bytes, *, operation: str) -> str: + """Decode one Git path output without normalizing its contents.""" + + raw = bytes(output) + # Git terminates path output with one LF. Strip only that separator so a + # repository directory ending in CR or LF keeps its literal name. + if raw.endswith(b"\n"): + raw = raw[:-1] + try: + return raw.decode("utf-8") + except UnicodeDecodeError: + raise ValueError(f"{operation} returned a path that is not valid UTF-8") from None + + def resolve_git_root(repo_path: Path) -> Path: candidate = repo_path.expanduser().resolve() - result = _git(candidate, "rev-parse", "--show-toplevel", text=True) - root = str(result.stdout or "").strip() - if result.returncode != 0 or not root: - detail = str(result.stderr or "").strip() + result = _git(candidate, "rev-parse", "--show-toplevel") + if result.returncode != 0 or not result.stdout: + detail = bytes(result.stderr or b"").decode("utf-8", errors="replace").strip() raise ValueError(f"repo_path is not a git worktree: {candidate}; {detail}") + root = _git_path_text(result.stdout, operation="git rev-parse --show-toplevel") + if not root: + raise ValueError(f"repo_path is not a git worktree: {candidate}") return Path(root).resolve() @@ -107,11 +123,22 @@ def _name_only(repo_root: Path, *args: str) -> list[str]: literal backslash would arrive as a fragment the caller could not open. """ - result = _git(repo_root, *args, text=True) + result = _git(repo_root, *args) if result.returncode != 0: - detail = str(result.stderr or "").strip() + detail = bytes(result.stderr or b"").decode("utf-8", errors="replace").strip() raise ValueError(f"git {' '.join(args)} failed: {detail}") - return [name for name in str(result.stdout or "").split("\0") if name] + records = bytes(result.stdout or b"").split(b"\0") + names: list[str] = [] + for record in records: + if not record: + continue + try: + names.append(record.decode("utf-8")) + except UnicodeDecodeError: + raise ValueError( + "git reported a changed pathname that is not valid UTF-8" + ) from None + return names def _dedupe(values: list[str]) -> list[str]: diff --git a/loopx/capabilities/integration_branch/core.py b/loopx/capabilities/integration_branch/core.py index 1e847c3bd2..2991c53fb6 100644 --- a/loopx/capabilities/integration_branch/core.py +++ b/loopx/capabilities/integration_branch/core.py @@ -2,6 +2,7 @@ import json import os +import re import shutil import subprocess import tempfile @@ -27,7 +28,7 @@ class IntegrationBranchError(ValueError): """A fail-closed integration-branch contract error.""" -def _retire_owned_read(process: subprocess.Popen[str]) -> None: +def _retire_owned_read(process: subprocess.Popen[Any]) -> None: """Retire the read's own process group and drop its pipes within a deadline. The shared runtime bounds termination, Windows ``taskkill`` and pipe @@ -57,15 +58,41 @@ def _git( never observed. """ + result = _git_command(repo, args, check=check, text=True) + return subprocess.CompletedProcess( + result.args, result.returncode, result.stdout, result.stderr + ) + + +def _git_bytes( + repo: Path, + *args: str, + check: bool = True, +) -> subprocess.CompletedProcess[bytes]: + """Run one Git read whose byte records are part of the caller's boundary.""" + + result = _git_command(repo, args, check=check, text=False) + return subprocess.CompletedProcess( + result.args, result.returncode, result.stdout, result.stderr + ) + + +def _git_command( + repo: Path, + args: Sequence[str], + *, + check: bool, + text: bool, +) -> subprocess.CompletedProcess[Any]: argv = ["git", "-C", str(repo), *args] process = subprocess.Popen( argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=True, - encoding="utf-8", - errors="replace", + text=text, + encoding="utf-8" if text else None, + errors="replace" if text else None, start_new_session=os.name == "posix", ) try: @@ -84,19 +111,27 @@ def _git( raise result = subprocess.CompletedProcess(argv, process.returncode, stdout, stderr) if check and result.returncode != 0: - detail = ( - (result.stderr or "").strip() - or (result.stdout or "").strip() - or "git command failed" + stderr_detail = ( + result.stderr + if isinstance(result.stderr, str) + else result.stderr.decode("utf-8", "replace") + ) + stdout_detail = ( + result.stdout + if isinstance(result.stdout, str) + else result.stdout.decode("utf-8", "replace") + ) + raise IntegrationBranchError( + stderr_detail.strip() or stdout_detail.strip() or "git command failed" ) - raise IntegrationBranchError(detail) return result def _repository_root(repo_path: str | Path) -> Path: requested = Path(repo_path).expanduser().resolve() - result = _git(requested, "rev-parse", "--show-toplevel") - return Path(result.stdout.strip()).resolve() + return Path( + _git_path_output(requested, "rev-parse", "--show-toplevel") + ).resolve() def _plan_path(repo: Path, plan_file: str | Path | None) -> Path: @@ -576,23 +611,155 @@ def integration_branch_status( } +_LEGACY_WORKTREE_PATH_ERROR = ( + "this Git version cannot safely resolve a worktree path containing a line " + "separator because 'git worktree list -z' requires Git 2.36+; upgrade Git " + "or resolve the worktree path manually" +) + + +_GIT_VERSION_PATTERN = re.compile(r"(?:^|\s)([0-9]+)\.([0-9]+)(?:\.|$)") + + +def _worktree_list_uses_nul(repo: Path) -> bool: + version = _git(repo, "version").stdout + match = _GIT_VERSION_PATTERN.search(version) + if match is None: + raise IntegrationBranchError( + f"cannot determine whether this Git version supports NUL worktree records: {version.strip()}" + ) + return (int(match.group(1)), int(match.group(2))) >= (2, 36) + + def _worktree_for_branch(repo: Path, branch: str) -> Path | None: - result = _git(repo, "worktree", "list", "--porcelain") - current_path: Path | None = None target_ref = f"refs/heads/{branch}" - # `--porcelain` frames records on LF and prints paths verbatim, so a path - # containing U+0085/U+2028/U+2029 would be split by `str.splitlines()` and - # the prefix returned as the worktree path. Frame on the writer's separator. - for line in result.stdout.split("\n"): - if line.startswith("worktree "): - current_path = Path(line.removeprefix("worktree ")).resolve() - elif line == f"branch {target_ref}": - return current_path - elif not line: - current_path = None + if not _worktree_list_uses_nul(repo): + # NUL framing arrived in Git 2.36. Older Git rejects the switch before + # writing a record; keep ordinary paths working through the line-framed + # inventory, which stays fail-closed for values it cannot represent. + return _worktree_for_branch_line_framed(repo, target_ref) + result = _git_bytes(repo, "worktree", "list", "--porcelain", "-z") + + current_path: Path | None = None + # `-z` frames fields on NUL and records on a NUL pair. A worktree path + # may contain LF, CR or Unicode line separators, so treating its text as + # a line-oriented inventory returns a prefix and can target the wrong + # checkout for cleanup or reset. + for record in result.stdout.split(b"\0\0"): + if not record: + continue + current_path = None + for field in record.split(b"\0"): + if field.startswith(b"worktree "): + current_path = Path(os.fsdecode(field.removeprefix(b"worktree "))).resolve() + elif field == f"branch {target_ref}".encode("utf-8"): + return current_path + return None + + +def _worktree_for_branch_line_framed(repo: Path, target_ref: str) -> Path | None: + """Resolve a branch from Git versions without ``worktree list -z`` support.""" + result = _git(repo, "worktree", "list", "--porcelain") + lines = result.stdout.split("\n") + index = 0 + while index < len(lines): + if not lines[index]: + index += 1 + continue + if not lines[index].startswith("worktree "): + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + path = Path(lines[index].removeprefix("worktree ")).resolve() + index += 1 + saw_head = False + saw_bare = False + branch_ref: str | None = None + while index < len(lines) and lines[index]: + field = lines[index] + if field.startswith("HEAD "): + # A path containing LF or CR can shift the real HEAD field and + # leave a second one behind. Duplicate or bare/HEAD mixtures are + # not records this format can represent safely. + if saw_head or saw_bare: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + saw_head = True + elif field == "bare": + if saw_head or saw_bare: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + saw_bare = True + elif field.startswith("branch "): + if saw_bare or not saw_head: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + branch_ref = field.removeprefix("branch ") + elif field == "detached": + if saw_bare or not saw_head: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + elif field.startswith(("locked", "prunable")): + pass + else: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + index += 1 + # A linked checkout has exactly one HEAD; a bare main worktree has a + # bare field instead. Anything else is a path torn across fields. + if not saw_head and not saw_bare: + raise IntegrationBranchError(_LEGACY_WORKTREE_PATH_ERROR) + if branch_ref == target_ref: + return path + index += 1 return None +def _path_key(path: Path) -> str: + return os.path.normcase(str(path.resolve())) + + +def _git_common_dir(base: Path, raw: str) -> Path: + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = base / candidate + return candidate.resolve() + + +def _git_path_output(repo: Path, *args: str) -> str: + """Decode one Git path, removing only the newline Git appends to it.""" + + output = _git_bytes(repo, *args).stdout + if output.endswith(b"\n"): + output = output[:-1] + return os.fsdecode(output) + + +def _confirm_checkout_ownership(repo: Path, worktree: Path, branch: str) -> None: + """Refuse a reset unless Git confirms the checkout is this branch's root.""" + + expected_ref = f"refs/heads/{branch}" + try: + top = _git_path_output(worktree, "rev-parse", "--show-toplevel") + worktree_common = _git_path_output( + worktree, "rev-parse", "--git-common-dir" + ) + repo_common = _git_path_output(repo, "rev-parse", "--git-common-dir") + head = _git_bytes(worktree, "symbolic-ref", "--quiet", "HEAD", check=False) + except IntegrationBranchError as exc: + raise IntegrationBranchError( + f"cannot verify that {worktree} is the checkout for {branch}: {exc}" + ) from None + if not top or _path_key(Path(top)) != _path_key(worktree): + raise IntegrationBranchError( + f"worktree path {worktree} is not the top of a checkout" + ) + if _path_key(_git_common_dir(worktree, worktree_common)) != _path_key( + _git_common_dir(repo, repo_common) + ): + raise IntegrationBranchError( + f"worktree {worktree} belongs to a different repository than {repo}" + ) + head_ref = head.stdout[:-1] if head.stdout.endswith(b"\n") else head.stdout + if head.returncode != 0 or os.fsdecode(head_ref) != expected_ref: + raise IntegrationBranchError( + f"worktree {worktree} is not checked out on branch {branch}" + ) + + def _clean_worktree(path: Path) -> bool: return not _git( path, @@ -808,6 +975,7 @@ def _update_integration_branch( ) checked_out_path = _worktree_for_branch(repo, branch) if checked_out_path is not None: + _confirm_checkout_ownership(repo, checked_out_path, branch) if not _clean_worktree(checked_out_path): raise IntegrationBranchError( f"integration branch worktree is dirty: {checked_out_path}" diff --git a/loopx/capabilities/issue_fix/catalog_entry.py b/loopx/capabilities/issue_fix/catalog_entry.py index 046dcc00c7..1b8b9ca0f2 100644 --- a/loopx/capabilities/issue_fix/catalog_entry.py +++ b/loopx/capabilities/issue_fix/catalog_entry.py @@ -2,7 +2,7 @@ from typing import Any -from .workflow_plan import build_issue_fix_pr_lifecycle_command +from .command_templates import build_issue_fix_pr_lifecycle_command ISSUE_FIX_CATALOG_ENTRY: dict[str, Any] = { diff --git a/loopx/capabilities/issue_fix/command_templates.py b/loopx/capabilities/issue_fix/command_templates.py new file mode 100644 index 0000000000..00224d7b8a --- /dev/null +++ b/loopx/capabilities/issue_fix/command_templates.py @@ -0,0 +1,94 @@ +"""Issue-fix command metadata without execution-provider imports.""" +from __future__ import annotations + +import shlex + +ISSUE_FIX_GOAL_CANDIDATE_DISCOVERY_COMMAND_TEMPLATE = ( + "gh issue list --repo " + '"$(gh repo view --json nameWithOwner --jq .nameWithOwner)" ' + "--state open --limit 20 --json number,title,url,labels" +) + + +def _command_arg(value: str) -> str: + if value.startswith("<") and value.endswith(">"): + return value + return shlex.quote(value) + + +def build_issue_fix_pr_lifecycle_command( + *, + cli_bin: str, + goal_id: str, + agent_id: str, + project: str | None = None, +) -> str: + """Build the canonical executable PR lifecycle reconciliation command.""" + + parts = [ + shlex.quote(cli_bin), + "issue-fix", + "pr-lifecycle", + "--url", + "", + ] + if project: + parts.extend(["--project", _command_arg(project)]) + parts.extend( + [ + "--goal-id", + _command_arg(goal_id), + "--claimed-by", + _command_arg(agent_id), + "--execute-transition", + "--format", + "json", + ] + ) + return " ".join(parts) + + +def build_issue_fix_goal_command_templates( + *, cli_bin: str, goal_id: str, agent_id: str = "" +) -> dict[str, str]: + """Return the capability-owned commands projected into goal-start packets.""" + + cli = shlex.quote(cli_bin) + goal = ( + goal_id + if goal_id.startswith("<") and goal_id.endswith(">") + else shlex.quote(goal_id) + ) + return { + "issue_fix_workflow_plan_template": ( + f"{cli} issue-fix workflow-plan " + "--url " + "--repo-path " + "--repository-context-json " + "--fetch-candidate-evidence " + "--validation-label '' " + f"--goal-id {goal} " + "--format json" + ), + "issue_fix_feasibility_template": ( + f"{cli} issue-fix feasibility " + "--url " + "--reproduction-status " + "--scope-class " + "--repository-context-json " + f"--goal-id {goal} " + "--format json" + ), + "issue_fix_pr_lifecycle_template": build_issue_fix_pr_lifecycle_command( + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + ), + "issue_fix_reviewer_request_template": ( + f"{cli} issue-fix reviewer-request " + "--url " + "--repo-path " + "--base-ref " + "--execute --format json" + ), + } diff --git a/loopx/capabilities/issue_fix/workflow_plan.py b/loopx/capabilities/issue_fix/workflow_plan.py index f5625566aa..c5529f457a 100644 --- a/loopx/capabilities/issue_fix/workflow_plan.py +++ b/loopx/capabilities/issue_fix/workflow_plan.py @@ -1,9 +1,13 @@ from __future__ import annotations -import shlex from collections.abc import Mapping, Sequence from typing import Any +from .command_templates import ( + ISSUE_FIX_GOAL_CANDIDATE_DISCOVERY_COMMAND_TEMPLATE as ISSUE_FIX_GOAL_CANDIDATE_DISCOVERY_COMMAND_TEMPLATE, + build_issue_fix_goal_command_templates as build_issue_fix_goal_command_templates, + build_issue_fix_pr_lifecycle_command as build_issue_fix_pr_lifecycle_command, +) from .acceptance_loop import build_issue_fix_caller_repo_branch_packet from .candidate_preflight import build_issue_fix_candidate_preflight_packet from .intake_surface import build_content_ops_issue_fix_metadata_preview_packet @@ -14,93 +18,6 @@ ) ISSUE_FIX_WORKFLOW_PLAN_PACKET_SCHEMA_VERSION = "issue_fix_workflow_plan_packet_v0" -ISSUE_FIX_GOAL_CANDIDATE_DISCOVERY_COMMAND_TEMPLATE = ( - "gh issue list --repo " - '"$(gh repo view --json nameWithOwner --jq .nameWithOwner)" ' - "--state open --limit 20 --json number,title,url,labels" -) -def _command_arg(value: str) -> str: - if value.startswith("<") and value.endswith(">"): - return value - return shlex.quote(value) - - -def build_issue_fix_pr_lifecycle_command( - *, - cli_bin: str, - goal_id: str, - agent_id: str, - project: str | None = None, -) -> str: - """Build the canonical executable PR lifecycle reconciliation command.""" - - parts = [ - shlex.quote(cli_bin), - "issue-fix", - "pr-lifecycle", - "--url", - "", - ] - if project: - parts.extend(["--project", _command_arg(project)]) - parts.extend( - [ - "--goal-id", - _command_arg(goal_id), - "--claimed-by", - _command_arg(agent_id), - "--execute-transition", - "--format", - "json", - ] - ) - return " ".join(parts) - - -def build_issue_fix_goal_command_templates( - *, cli_bin: str, goal_id: str, agent_id: str = "" -) -> dict[str, str]: - """Return the capability-owned commands projected into goal-start packets.""" - - cli = shlex.quote(cli_bin) - goal = ( - goal_id - if goal_id.startswith("<") and goal_id.endswith(">") - else shlex.quote(goal_id) - ) - return { - "issue_fix_workflow_plan_template": ( - f"{cli} issue-fix workflow-plan " - "--url " - "--repo-path " - "--repository-context-json " - "--fetch-candidate-evidence " - "--validation-label '' " - f"--goal-id {goal} " - "--format json" - ), - "issue_fix_feasibility_template": ( - f"{cli} issue-fix feasibility " - "--url " - "--reproduction-status " - "--scope-class " - "--repository-context-json " - f"--goal-id {goal} " - "--format json" - ), - "issue_fix_pr_lifecycle_template": build_issue_fix_pr_lifecycle_command( - cli_bin=cli_bin, - goal_id=goal_id, - agent_id=agent_id, - ), - "issue_fix_reviewer_request_template": ( - f"{cli} issue-fix reviewer-request " - "--url " - "--repo-path " - "--base-ref " - "--execute --format json" - ), - } def _todo_preview( diff --git a/loopx/capabilities/manager_context/roundtrip.py b/loopx/capabilities/manager_context/roundtrip.py index 6c07c6c43e..da348c00a2 100644 --- a/loopx/capabilities/manager_context/roundtrip.py +++ b/loopx/capabilities/manager_context/roundtrip.py @@ -50,6 +50,7 @@ from ...control_plane.content_digest import BARE_SHA256_PATTERN from ...control_plane.collaboration.source_chat_observation import original_delivery_facts from ...chat import ChatMessagePayloadConflictError +from ...chat_store import TERMINAL_TURN_STATES class ReturnResolutionBlocked(ValueError, RuntimeError): """A return cannot be resolved; ``reason`` is the provider-neutral code. @@ -431,15 +432,22 @@ def project_chat_session_snapshot(root, store, session_id, *, registry, resume_from=None, project_history_messages=None): """Project return delivery state into one existing Chat snapshot.""" - strict = message_limit is not None or resume_from is not None - snapshot = (store.session_snapshot(session_id, strict_messages=True) if strict - else store.session_snapshot(session_id)) + turn = None if turn_id is not None: if message_limit is None: raise ValueError("a selected original Turn requires paged history") turn = store.load_turn(session_id, turn_id) if not turn or turn.get("session_id") != session_id or turn.get("turn_id") != turn_id: raise ValueError("original Turn is unavailable from this Session") + strict = message_limit is not None or resume_from is not None + # Read the status first: terminal completion persists its answer before + # publishing completed. An older message prefix must not appear final. + snapshot = (store.session_snapshot(session_id, strict_messages=True) if strict + else store.session_snapshot(session_id)) + if turn is not None: + status = turn["status"] + snapshot["selected_turn"] = {"turn_id": turn_id, "status": status, "terminal": status in TERMINAL_TURN_STATES} + snapshot["active_turn"] = None # OriginalRequest already displays only this Turn. Select it before # paging/projection, retaining every message and the strict journal read. snapshot["messages"] = [row for row in snapshot["messages"] if row.get("turn_id") == turn_id] diff --git a/loopx/chat_actions.py b/loopx/chat_actions.py index 0e2b1ba01d..b3004d7dd1 100644 --- a/loopx/chat_actions.py +++ b/loopx/chat_actions.py @@ -23,6 +23,7 @@ from .chat_store import ChatSessionStore from .chat.todo_actions import ChatTodoActionMixin from .control_plane.goals.acceptance_actions import GoalAcceptanceActionMixin +from .control_plane.goals.activation import goal_is_stopped from .control_plane.goals.goal_objective_actions import GoalObjectiveActionMixin from .configure_goal import configure_goal from .control_plane.goals.configure_goal_service import ( @@ -1632,7 +1633,8 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No }, ) agent_id = parameters.get("agent_id") - if agent_id and not isinstance(steps.get("todo_assigned"), dict): + start_execution = parameters.get("start_execution") is True + if agent_id and not start_execution and not isinstance(steps.get("todo_assigned"), dict): update_goal_todo( registry_path=self.registry_path, goal_id=str(parameters["goal_id"]), @@ -1659,7 +1661,7 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No "canonical_receipt": canonical_receipt, } turn_result = None - if parameters.get("start_execution"): + if start_execution: if self.runtime_controller is None: raise ValueError("Chat Runtime Controller is unavailable") execution_agent_id = str( @@ -1675,28 +1677,69 @@ def apply(self, proposal_id: str, *, steward_context: dict[str, Any] | None = No turn_id = _opaque(execution_step.get("turn_id"), field="turn_id") created = False else: - goal = self._goal(str(parameters["goal_id"])) + goal_id = str(parameters["goal_id"]) + owner = str(parameters["agent_id"]) + goal = self._goal(goal_id) + if goal.get("status", "active") not in {"active", "ready"} or goal_is_stopped(goal): + raise ValueError("The Task's Goal is no longer active") project = Path(str(goal.get("repo") or ".")).expanduser().resolve() if not project.is_dir(): raise ValueError("the Goal project root is unavailable") - self._agent_eligibility(execution_agent_id, project=project) - session, _resumed = self.runtime_controller.open_session( - goal_id=str(parameters["goal_id"]), - agent_id=execution_agent_id, - work_dir=project, - objective=str(parameters["text"]), - mode="resume_latest", - channel_id=f"task.{todo_id}", - agent_goal_id=str(parameters["goal_id"]), - ) - session_id = _opaque(session.get("session_id"), field="session_id") - turn, created = self.runtime_controller.submit_turn( - session_id=session_id, - client_turn_id=f"task-start-{proposal_id}", - message=str(parameters["text"]), - work_dir=project, - objective=str(parameters["text"]), - ) + channel_id = f"task.{todo_id}" + client_turn_id = f"task-start-{proposal_id}" + opened = steps.get("task_session_opened") + session_id = None + turn = None + if opened is not None: + session_id = _opaque(opened.get("session_id"), field="session_id") + session = self.runtime_controller.store.load_session(session_id) + if (session is None or session.get("goal_id") != goal_id + or session.get("agent_id") != execution_agent_id or session.get("channel_id") != channel_id): + raise ValueError("The original Task Session is unavailable") + observed = self.runtime_controller.store.turn_for_client(session_id, client_turn_id) + if observed and (observed.get("started_at") or observed.get("upstream_turn_id")): + turn = observed + + created = False + if turn is None: + self._agent_eligibility(execution_agent_id, project=project) + if parameters.get("endpoint_id") and self._resolve_goal_agent(goal_id, execution_agent_id) != owner: + raise ValueError("The reviewed Agent endpoint now belongs to another Goal Agent") + claim_operation_id = f"chat-task-start:{proposal_id}" + claim = update_goal_todo( + registry_path=self.registry_path, goal_id=goal_id, todo_id=todo_id, + claimed_by=owner, agent_id=owner, claim_only=True, manage_execution=True, + claim_operation_id=claim_operation_id, dry_run=False, + ) + if claim["handoff_mode"] == "soft_claim" and claim["status"] in {"recovered", "replayed"}: + # A soft-claim receipt records a past assignment. + # Let the same owner recheck it without acquiring or changing it. + current = update_goal_todo( + registry_path=self.registry_path, goal_id=goal_id, todo_id=todo_id, + claimed_by=owner, agent_id=owner, claim_only=True, manage_execution=True, + dry_run=True, + ) + if current["changed"]: + raise ValueError("The reviewed Task assignment changed before launch") + if not isinstance(steps.get("todo_assigned"), dict): + self.store.save_checkpoint( + proposal_id, step="todo_assigned", + receipt={"todo_id": todo_id, "agent_id": owner}, + ) + if session_id is None: + session, _resumed = self.runtime_controller.open_session( + goal_id=goal_id, agent_id=execution_agent_id, work_dir=project, + objective=str(parameters["text"]), mode="resume_latest", + channel_id=channel_id, agent_goal_id=goal_id, + ) + session_id = _opaque(session.get("session_id"), field="session_id") + self.store.save_checkpoint( + proposal_id, step="task_session_opened", receipt={"session_id": session_id}, + ) + turn, created = self.runtime_controller.submit_turn( + session_id=session_id, client_turn_id=client_turn_id, message=str(parameters["text"]), + work_dir=project, objective=str(parameters["text"]), + ) turn_id = _opaque(turn.get("turn_id"), field="turn_id") self.store.save_checkpoint( proposal_id, diff --git a/loopx/cli_runtime.py b/loopx/cli_runtime.py index 5624deb6d2..01d57b6ef4 100644 --- a/loopx/cli_runtime.py +++ b/loopx/cli_runtime.py @@ -75,7 +75,8 @@ _REGISTRY_INSPECTION_COMMANDS = frozenset({"registry", "registry-boundary"}) _SELECTED_COMMANDS = _STATUS_COMMANDS | GLOBAL_READ_COMMANDS | PROJECT_LIFECYCLE_COMMANDS | _REGISTRY_INSPECTION_COMMANDS | { "todo", "quota", "task-lease", "change-window", "delegation", "turn", "doctor", "commands", "version", - "bootstrap", "connect", "configure-goal", + "bootstrap", "connect", "configure-goal", "start-goal", "bootstrap-command-pack", + "register-agent", "bind-agent-thread", "authority-archive", "coordination-shadow", "extension", "slash-commands", "manager-inbox", "agent-context", "agent-directory", "agent-capabilities", @@ -393,7 +394,19 @@ def _handle_registry_inspection_command( def _build_selected_parser(command: str) -> LoopXArgumentParser: parser, subparsers = build_cli_parser(parser_class=_SelectedArgumentParser) - if command in {"bootstrap", "connect"}: + if command == "start-goal": + from .cli_commands.start_goal import register_start_goal_command + + register_start_goal_command(subparsers) + elif command == "bootstrap-command-pack": + from .cli_commands.starter_bootstrap_registration import register_starter_bootstrap_commands + + register_starter_bootstrap_commands(subparsers) + elif command in {"register-agent", "bind-agent-thread"}: + from .cli_commands.registry_admin import register_registry_admin_commands + + register_registry_admin_commands(subparsers) + elif command in {"bootstrap", "connect"}: from .cli_commands.bootstrap_connect import register_bootstrap_connect_command register_bootstrap_connect_command(subparsers) @@ -526,13 +539,25 @@ def _dispatch_common_command( registry_path: Path, allow_missing_registry: bool, ) -> int | None: + if args.command == "start-goal": + from .cli_commands.start_goal import handle_start_goal_command + + return handle_start_goal_command( + args, print_payload, runtime_root_arg=args.runtime_root, + ) + if args.command == "bootstrap-command-pack": + from .cli_commands.starter_bootstrap import handle_loopx_bootstrap_command_pack_command + + return handle_loopx_bootstrap_command_pack_command( + args, print_payload, runtime_root_arg=args.runtime_root, + ) if args.command in {"bootstrap", "connect"}: from .cli_commands.bootstrap_connect import handle_bootstrap_connect_command return handle_bootstrap_connect_command( args, registry_path=registry_path, print_payload=print_payload, ) - if args.command == "configure-goal": + if args.command in {"configure-goal", "register-agent", "bind-agent-thread"}: from .cli_commands.registry_admin import handle_registry_admin_command return handle_registry_admin_command( diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index cd6408a622..ec6059c58c 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -16,7 +16,7 @@ export type ActionReviewReason = | "apply_failed" | "inactive_proposal" | "operation_authorization_pending" | "operation_outcome_pending" | "operation_confirmation_expired" | "operation_expiry_unknown" | "team_plan_retry" - | "canonical_update_retry" | "canonical_update_projection_pending" | "goal_creation_retry"; + | "canonical_update_retry" | "canonical_update_projection_pending" | "goal_creation_retry" | "task_start_retry"; export type OperationReviewContent = { title: string; @@ -96,6 +96,8 @@ export type ActionReviewPlan = ActionReviewIdentity & ActionReviewState & { /** Recorded creation effects, not evidence of a completed Goal or model Turn. */ creationProgress?: GoalCreationProgress; creationOutcome?: GoalCreationOutcome; + /** Accepted dispatch bound to its original Turn. It does not establish a result. */ + taskExecution?: { goalId: string; todoId: string; sessionId: string; turnId: string }; }; export type GoalCreationProgress = { @@ -505,6 +507,24 @@ function compileDecisionReviewFrame(proposal: Record): Decision ? DEPENDENT_EFFECTS[state as keyof typeof DEPENDENT_EFFECTS] : "unknown" }; } +function taskExecutionReadback( + proposal: Record, receipt: Record, sourceFingerprint: string, +): ActionReviewPlan["taskExecution"] { + const parameters = objectValue(proposal.normalized_parameters); + if (proposal.action_kind !== "todo.create" || parameters?.start_execution !== true + || receipt.outcome !== "task_execution_started") return undefined; + const resources = objectValue(receipt.resource_ids); + const canonical = objectValue(receipt.canonical_receipt); + const goalId = textValue(parameters.goal_id); + const todoId = textValue(resources?.todo_id); + const sessionId = textValue(resources?.session_id); + const turnId = textValue(resources?.turn_id); + if (!goalId || !todoId || !sessionId || !turnId + || resources?.goal_id !== goalId || canonical?.goal_id !== goalId || canonical?.todo_id !== todoId + || canonical?.status !== "applied" || !sourceFingerprint || canonical?.preview_id !== sourceFingerprint) return undefined; + return {goalId, todoId, sessionId, turnId}; +} + /** * Compile provider-neutral presentation semantics from a typed action proposal. * This reducer owns no action authority and performs no external effects. @@ -559,10 +579,14 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): return held("repair", "readback_unverified"); } const receipt = objectValue(proposal.receipt); - return receipt?.projection_verified === true - && (proposal.action_kind !== "operation.execute" || (operationFrame?.kind === "result" && operationFrame.resultDeliveryVerified)) - ? held("completed", "readback_verified") - : held("repair", "readback_unverified"); + if (receipt?.projection_verified !== true + || (proposal.action_kind === "operation.execute" + && (operationFrame?.kind !== "result" || !operationFrame.resultDeliveryVerified))) { + return held("repair", "readback_unverified"); + } + const taskExecution = taskExecutionReadback(proposal, receipt, identity.sourceFingerprint); + const completed = held("completed", "readback_verified"); + return taskExecution ? {...completed, taskExecution} : completed; } const basis = objectValue(proposal.canonical_update_basis); const parameters = objectValue(proposal.normalized_parameters); @@ -574,6 +598,18 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): && creationProgress) { return {...finish({interaction: "review", canApply: true, reason: "goal_creation_retry"}), retryOriginal: true}; } + if (proposal.action_kind === "todo.create" && parameters?.start_execution === true + && proposal.permission_classification === "durable_write" && identity.proposalId && identity.sourceFingerprint + && (proposal.status === "applying" || (proposal.status === "failed" && objectValue(proposal.failure)?.retry_safe === true))) { + const steps = objectValue(objectValue(proposal.checkpoint)?.steps); + const created = objectValue(steps?.todo_created); + const receipt = objectValue(created?.canonical_receipt); + if (textValue(created?.todo_id) && created?.todo_id === receipt?.todo_id + && textValue(parameters.goal_id) && receipt?.goal_id === parameters.goal_id + && receipt?.preview_id === identity.sourceFingerprint && receipt.status === "applied") { + return {...finish({interaction: "review", canApply: true, reason: "task_start_retry"}), retryOriginal: true}; + } + } const isCanonicalUpdate = basis?.schema_version === "loopx_chat_canonical_update_basis_v0" && textValue(basis.provider_revision) !== null && textValue(basis.registry_sha256) !== null && ((proposal.action_kind === "todo.update") diff --git a/loopx/extensions/lark/private_conversations.py b/loopx/extensions/lark/private_conversations.py index a732be14e6..a84c0687f8 100644 --- a/loopx/extensions/lark/private_conversations.py +++ b/loopx/extensions/lark/private_conversations.py @@ -233,7 +233,7 @@ def __init__(self, *, controller: Any, runtime_root: Path, runner: Any, cli_bin: self._delivery_discovery = PrivateDeliveryDiscovery(self.root) self._body_discovery = PrivateDeliveryDiscovery(self.root) self._web_projection_scan_at = 0.0 - self._web_projection_snapshots: dict[str, tuple[int, int]] = {} + self._web_projection_snapshots: dict[str, tuple[int, ...]] = {} from .owner_attention import LarkOwnerAttention self.owner_attention = LarkOwnerAttention(self) from .private_ingress import PrivateIngressObserver @@ -1522,9 +1522,8 @@ def _web_projection_paths(self) -> list[Path]: store = self.core.controller.store for sid, source_path in sources.items(): try: - stat = (store.sessions_root / sid / "messages.jsonl").stat() - snapshot = (stat.st_mtime_ns, stat.st_size) - if self._web_projection_snapshots.get(sid) == snapshot: + snapshot = file_change_fingerprint(store.sessions_root / sid / "messages.jsonl") + if snapshot is not None and self._web_projection_snapshots.get(sid) == snapshot: continue source = _read_json(source_path) if (source.get("binding_id") not in enabled or source.get("session_id") != sid @@ -1546,7 +1545,8 @@ def _web_projection_paths(self) -> list[Path]: for message in selected): row["status"] = "pending" _atomic_write_json(path, row) - self._web_projection_snapshots[sid] = snapshot + if snapshot is not None: + self._web_projection_snapshots[sid] = snapshot except (OSError, ValueError, KeyError): # An unreadable source is unknown. Keep any prior outbox; # it retains fresh source and canonical-message checks. diff --git a/loopx/help_surface.py b/loopx/help_surface.py index 9bfc84fcd7..7b5b3fa782 100644 --- a/loopx/help_surface.py +++ b/loopx/help_surface.py @@ -488,6 +488,8 @@ def render_concise_help(program: str = "loopx", *, standalone_core: bool = False "LoopX keeps long-running agent work moving with durable state and evidence.", "", "Usage:", f" {program} [global options] [command options]", "", "Start:", + ' loopx start-goal --guided --project . --goal-text ""', + " Start from a goal with your existing model host.", " loopx connect --help Connect a project and its objective.", " loopx configure-goal --help Configure its Agent and execution budget.", " loopx todo --help Create, claim and complete work.", diff --git a/packages/loopx-core/README.md b/packages/loopx-core/README.md index cc2c5a43e1..ab24bf78b2 100644 --- a/packages/loopx-core/README.md +++ b/packages/loopx-core/README.md @@ -21,8 +21,20 @@ tasks requires a qualified Node runtime (22.22.3+ and the existing SQLite reliability checks), an explicitly selected host and its own prerequisites. Use the existing `turn run-once --host generic-cli` entry, a separate validation command, `turn inspect-journal` and `--resume-turn-key` for execution and recovery. -Bootstrap with `connect`, configure one Agent with `configure-goal`, and create -and claim work through the existing `todo` commands. An ordinary managed claim +Start with one natural-language goal in your existing model host: + +```sh +loopx start-goal --guided --project . --goal-text "Create and check an inventory report." +``` + +Follow its returned host choice and ordered transaction. The preview preserves +the exact goal text and writes no state. Its project bootstrap, fresh identity +registration, session binding and subsequent Todo/guard commands execute in +this standalone distribution through their existing owners. The host supplies +the model, tools and scheduler; the wheel does not provide them or activate an +optional capability from goal wording. `connect` and `configure-goal` remain +available for explicit setup. Create and claim work through the existing `todo` +commands. An ordinary managed claim prepares the default SQLite lease without asking the task model to manage lease flags. Completion carries the original captured lease generation; recovery refuses a renewed, released or replaced generation rather than adopting it. @@ -31,6 +43,16 @@ The installed-only qualification in exercises setup, independent result validation, process loss, replay and refusal. No model service, credentials or paid task execution is supplied by this wheel. +Goal-start help and preview use issue-fix command metadata without importing +its execution providers. An explicit `--capability-route issue-fix` retains +the existing candidate-preflight contract. The installed context qualification +in [`test_installed_core_context.py`](../../tests/control_plane/test_installed_core_context.py) +executes the existing `quota context-project`, `context-read` and `context-hook` +commands: incomplete delivery cannot establish a baseline, revisions preserve +complete constraints, compaction requires recovery, and changed capture bytes +are refused. Its native events are protocol fixtures; they do not establish +model retention or installed-host adoption. + The candidate owns the same `loopx` import namespace and console command as the ordinary distribution. Do not co-install or overlay them. Return to the ordinary installation by switching back to its environment; preserve task state and diff --git a/skills/loopx-project/references/outcome-refinement.md b/skills/loopx-project/references/outcome-refinement.md index 2c0153b739..c589cdb11b 100644 --- a/skills/loopx-project/references/outcome-refinement.md +++ b/skills/loopx-project/references/outcome-refinement.md @@ -33,7 +33,7 @@ consequential meaning or authority is genuinely missing, not merely because the user gave a correction. For obsolete work covered by executable checks, follow the -[owner check-scope reconciliation example](../../../docs/reference/goal-acceptance-observations.md#owner-check-scope-reconciliation). +[owner check-scope reconciliation example](https://github.com/loopx-project/loopx/blob/ce69003c73378fdb067a6b86e7b60feb50cb1b77/docs/reference/goal-acceptance-observations.md#owner-check-scope-reconciliation). It preserves accepted work and current/final checks in an explicit `selected_work` replacement before lease-fenced supersession. Removing a binding alone does not remove work from `all_advancement`. This owner configuration path diff --git a/tests/capabilities/test_integration_branch.py b/tests/capabilities/test_integration_branch.py index 2cc388dec6..a18a9e7f5e 100644 --- a/tests/capabilities/test_integration_branch.py +++ b/tests/capabilities/test_integration_branch.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import shutil import subprocess from pathlib import Path @@ -11,6 +13,7 @@ integration_branch_status, sync_integration_branch, ) +from loopx.capabilities.integration_branch import core as integration_core from loopx.capabilities.integration_branch.core import ( IntegrationBranchError, _worktree_for_branch, @@ -56,6 +59,15 @@ def _repository(tmp_path: Path) -> Path: return repo +def _repositories_with_trailing_newline(tmp_path: Path) -> tuple[Path, Path]: + """Create two independent repositories whose root names differ by LF.""" + requested = _repository(tmp_path) + requested_with_lf = tmp_path / "repo\n" + requested.rename(requested_with_lf) + adjacent = _repository(tmp_path) + return requested_with_lf, adjacent + + def _remote_repositories(tmp_path: Path) -> tuple[Path, Path]: seed_root = tmp_path / "seed" seed_root.mkdir() @@ -527,6 +539,63 @@ def test_adopt_current_integration_head_without_touching_worktree( assert integration_branch_status(repo_path=repo)["status"] == "in_sync" +@pytest.mark.skipif(os.name != "posix", reason="newline in a path is POSIX-only") +def test_sync_root_preserves_a_trailing_newline_in_the_requested_path( + tmp_path: Path, +) -> None: + """A requested repository ending in LF must not resolve to its neighbor.""" + requested, adjacent = _repositories_with_trailing_newline(tmp_path) + _configure(requested) + _configure(adjacent) + sync_integration_branch(repo_path=requested, execute=True) + sync_integration_branch(repo_path=adjacent, execute=True) + adjacent_plan = adjacent / ".loopx/integration-branch.json" + adjacent_plan_before = adjacent_plan.read_text(encoding="utf-8") + adjacent_ref = "refs/heads/codex/local-integration" + adjacent_head_before = _git(adjacent, "rev-parse", adjacent_ref) + + _git(requested, "switch", "feature-a") + _commit(requested, "a.txt", "a2\n", "requested review update") + _git(requested, "switch", "main") + _git(adjacent, "switch", "fix-b") + _commit(adjacent, "b.txt", "b2\n", "adjacent review update") + _git(adjacent, "switch", "main") + + synced = sync_integration_branch(repo_path=requested, execute=True) + + assert synced["status"] == "synced" + assert _git(requested, "rev-parse", "refs/heads/codex/local-integration") == synced[ + "candidate_sha" + ] + assert integration_branch_status(repo_path=requested)["status"] == "in_sync" + assert _git(adjacent, "rev-parse", adjacent_ref) == adjacent_head_before + assert adjacent_plan.read_text(encoding="utf-8") == adjacent_plan_before + + +@pytest.mark.skipif(os.name != "posix", reason="newline in a path is POSIX-only") +def test_sync_root_refuses_an_unconfigured_newline_checkout_without_touching_neighbor( + tmp_path: Path, +) -> None: + """A missing local plan must fail before the adjacent repository can be read.""" + requested, adjacent = _repositories_with_trailing_newline(tmp_path) + _configure(adjacent) + sync_integration_branch(repo_path=adjacent, execute=True) + _git(adjacent, "switch", "fix-b") + _commit(adjacent, "b.txt", "b2\n", "adjacent review update") + _git(adjacent, "switch", "main") + adjacent_plan = adjacent / ".loopx/integration-branch.json" + adjacent_plan_before = adjacent_plan.read_text(encoding="utf-8") + adjacent_ref = "refs/heads/codex/local-integration" + adjacent_head_before = _git(adjacent, "rev-parse", adjacent_ref) + + with pytest.raises(IntegrationBranchError, match="plan not found"): + sync_integration_branch(repo_path=requested, execute=True) + + assert not (requested / ".loopx/integration-branch.json").exists() + assert _git(adjacent, "rev-parse", adjacent_ref) == adjacent_head_before + assert adjacent_plan.read_text(encoding="utf-8") == adjacent_plan_before + + def test_dirty_checked_out_integration_worktree_fails_closed( tmp_path: Path, ) -> None: @@ -644,6 +713,19 @@ def test_cli_rejects_plan_file_outside_loopx_state_root( assert not outside_plan.exists() +@pytest.mark.skipif(os.name != "posix", reason="line separators in paths are POSIX-only") +@pytest.mark.parametrize("line_separator", ["\n", "\r"]) +def test_worktree_for_branch_preserves_a_line_break_in_the_path( + tmp_path: Path, line_separator: str +) -> None: + """A path with LF or CR must not tear the NUL-framed worktree inventory.""" + repo = _repository(tmp_path) + odd = tmp_path / f"odd{line_separator}worktree" + _git(repo, "worktree", "add", str(odd), "feature-a") + + assert _worktree_for_branch(repo, "feature-a") == odd.resolve() + + def test_worktree_for_branch_frames_porcelain_records_on_lf(tmp_path: Path) -> None: """A worktree path containing U+0085 must resolve to the full path. @@ -657,3 +739,189 @@ def test_worktree_for_branch_frames_porcelain_records_on_lf(tmp_path: Path) -> N _git(repo, "worktree", "add", str(odd), "feature-a") assert _worktree_for_branch(repo, "feature-a") == odd.resolve() + + +def _install_legacy_git( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + unknown_switch_error: str = "error: unknown switch 'z'", +) -> None: + """Put a Git wrapper first on PATH that rejects -z like Git 2.35.""" + real_git = shutil.which("git") + assert real_git + bin_dir = tmp_path / "legacy-git-bin" + bin_dir.mkdir() + wrapper = bin_dir / "git" + wrapper.write_text( + "#!/bin/sh\n" + 'if [ "$#" -eq 3 ] && [ "$1" = "-C" ] && [ "$3" = "version" ]; then\n' + " echo 'git version 2.35.8'\n" + " exit 0\n" + "fi\n" + 'for arg in "$@"; do\n' + ' if [ "$arg" = "-z" ]; then\n' + f" printf '%s\\n' \"{unknown_switch_error}\" >&2\n" + " exit 129\n" + " fi\n" + "done\n" + f'exec "{real_git}" "$@"\n', + encoding="utf-8", + ) + wrapper.chmod(0o755) + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + + +@pytest.mark.skipif(os.name != "posix", reason="legacy Git stub is POSIX-only") +def test_worktree_for_branch_supports_legacy_git_without_nul_framing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Git 2.35 rejects worktree-list -z; ordinary paths must still resolve.""" + repo = _repository(tmp_path) + ordinary = tmp_path / "ordinary worktree" + _git(repo, "worktree", "add", str(ordinary), "feature-a") + _install_legacy_git(tmp_path, monkeypatch) + + assert _worktree_for_branch(repo, "feature-a") == ordinary.resolve() + + +@pytest.mark.skipif(os.name != "posix", reason="legacy Git stub is POSIX-only") +def test_legacy_git_format_is_selected_by_version_not_error_language( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A localized usage error must not change the worktree inventory format.""" + repo = _repository(tmp_path) + ordinary = tmp_path / "ordinary worktree" + _git(repo, "worktree", "add", str(ordinary), "feature-a") + _install_legacy_git( + tmp_path, + monkeypatch, + unknown_switch_error="erreur : option inconnue 'z'", + ) + monkeypatch.setenv("LC_ALL", "fr_FR.UTF-8") + + assert _worktree_for_branch(repo, "feature-a") == ordinary.resolve() + + +@pytest.mark.skipif(os.name != "posix", reason="line separators in paths are POSIX-only") +@pytest.mark.parametrize("line_separator", ["\n", "\r"]) +def test_legacy_git_fails_closed_on_line_separator_worktree_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, line_separator: str +) -> None: + """A legacy line-framed inventory must refuse a path it cannot represent.""" + repo = _repository(tmp_path) + odd = tmp_path / f"odd{line_separator}worktree" + _git(repo, "worktree", "add", str(odd), "feature-a") + _install_legacy_git(tmp_path, monkeypatch) + + with pytest.raises(IntegrationBranchError, match="Git 2.36"): + _worktree_for_branch(repo, "feature-a") + + +@pytest.mark.skipif(os.name != "posix", reason="legacy Git stub is POSIX-only") +def test_sync_execute_supports_legacy_git_with_ordinary_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Git 2.35 must still sync an ordinary linked integration worktree.""" + repo = _repository(tmp_path) + _configure(repo) + sync_integration_branch(repo_path=repo, execute=True) + integration_worktree = tmp_path / "integration worktree" + _git(repo, "switch", "main") + _git(repo, "worktree", "add", str(integration_worktree), "codex/local-integration") + source_worktree = tmp_path / "source worktree" + _git(repo, "worktree", "add", str(source_worktree), "feature-a") + _commit(source_worktree, "a.txt", "a2\n", "review update") + _install_legacy_git(tmp_path, monkeypatch) + + synced = sync_integration_branch(repo_path=repo, execute=True) + + assert synced["status"] == "synced" + assert _git(integration_worktree, "rev-parse", "HEAD") == synced["candidate_sha"] + + +@pytest.mark.skipif(os.name != "posix", reason="special path names are POSIX-only") +@pytest.mark.parametrize("suffix", ["\n", "\r", " "]) +def test_sync_execute_preserves_checkout_paths_when_verifying_ownership( + tmp_path: Path, suffix: str +) -> None: + """Path output must not strip a checkout name's trailing characters.""" + repo = _repository(tmp_path) + _configure(repo) + sync_integration_branch(repo_path=repo, execute=True) + integration_worktree = tmp_path / f"integration worktree{suffix}" + _git(repo, "switch", "main") + _git(repo, "worktree", "add", str(integration_worktree), "codex/local-integration") + source_worktree = tmp_path / "source worktree" + _git(repo, "worktree", "add", str(source_worktree), "feature-a") + _commit(source_worktree, "a.txt", "a2\n", "review update") + + synced = sync_integration_branch(repo_path=repo, execute=True) + + assert synced["status"] == "synced" + assert _git(integration_worktree, "rev-parse", "HEAD") == synced["candidate_sha"] + assert integration_branch_status(repo_path=repo)["status"] == "in_sync" + + +@pytest.mark.skipif(os.name != "posix", reason="legacy Git stub is POSIX-only") +def test_legacy_git_skips_bare_record_and_syncs_linked_worktree( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A bare main record must not hide the linked integration checkout.""" + (tmp_path / "seed").mkdir() + seed = _repository(tmp_path / "seed") + bare = tmp_path / "bare.git" + _git(tmp_path, "init", "--bare", "-b", "main", str(bare)) + _git(seed, "remote", "add", "origin", str(bare)) + _git(seed, "push", "origin", "main", "feature-a", "fix-b") + linked = tmp_path / "linked" + _git(bare, "worktree", "add", str(linked), "main") + _configure(linked) + _git(bare, "branch", "codex/local-integration", "main") + integration = tmp_path / "integration worktree" + _git(bare, "worktree", "add", str(integration), "codex/local-integration") + plan = linked / ".loopx/integration-branch.json" + (integration / ".loopx").mkdir() + shutil.copy2(plan, integration / ".loopx/integration-branch.json") + source = tmp_path / "source worktree" + _git(bare, "worktree", "add", str(source), "feature-a") + _commit(source, "a.txt", "a2\n", "review update") + _install_legacy_git(tmp_path, monkeypatch) + + synced = sync_integration_branch(repo_path=integration, execute=True) + + assert synced["status"] == "synced" + assert _git(integration, "rev-parse", "HEAD") == synced["candidate_sha"] + assert integration_branch_status(repo_path=integration)["status"] == "in_sync" + + +def test_sync_refuses_a_checkout_that_is_not_the_integration_branch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A parsed path must prove branch ownership before any reset.""" + repo = _repository(tmp_path) + _configure(repo) + sync_integration_branch(repo_path=repo, execute=True) + _git(repo, "switch", "feature-a") + _commit(repo, "a.txt", "a2\n", "review update") + _git(repo, "switch", "main") + wrong = tmp_path / "wrong branch worktree" + _git(repo, "worktree", "add", str(wrong), "fix-b") + wrong_head = _git(wrong, "rev-parse", "HEAD") + integration_head = _git(repo, "rev-parse", "codex/local-integration") + plan_path = repo / ".loopx/integration-branch.json" + plan_before = plan_path.read_text(encoding="utf-8") + + def wrong_worktree(repo_path: Path, branch: str) -> Path: + return wrong + + monkeypatch.setattr( + integration_core, "_worktree_for_branch", wrong_worktree + ) + + with pytest.raises(IntegrationBranchError, match="not checked out on branch"): + sync_integration_branch(repo_path=repo, execute=True) + + assert _git(wrong, "rev-parse", "HEAD") == wrong_head + assert _git(repo, "rev-parse", "codex/local-integration") == integration_head + assert plan_path.read_text(encoding="utf-8") == plan_before diff --git a/tests/control_plane/test_goal_create_completion.py b/tests/control_plane/test_goal_create_completion.py index b0e25ec3b5..44911ceb76 100644 --- a/tests/control_plane/test_goal_create_completion.py +++ b/tests/control_plane/test_goal_create_completion.py @@ -239,6 +239,89 @@ def assert_retained_then_submit(*args, **kwargs): controller.close() +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("mode", ["soft_claim", "hard_lease"]) +def test_confirmed_task_start_recovers_the_same_claim_and_dispatch(environment, app, monkeypatch, provider, mode): + import runpy + + from loopx.capabilities.native_chat.project_context import ChatProjectContexts + from loopx.chat_runtime import ChatRuntimeController + from loopx.chat_store import ChatSessionStore + from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + configure, _, _, project, runtime = environment + store, request, service = app + configure(provider, mode=mode) + goal = preview_goal(request) + status, created = request(f"/api/actions/{goal['proposal_id']}/apply", {}) + assert status == 200, created + + capture, binary = runtime / "task-native-requests.jsonl", runtime / "task-codex" + source = runpy.run_path(str(Path(__file__).parents[2] / "examples/loopx-chat-runtime-smoke.py"))["FAKE_CODEX"] + binary.write_text(source.replace(' method = request.get("method")', + f' with open({str(capture)!r}, "a") as stream:\n stream.write(json.dumps(request) + "\\n")\n' + ' method = request.get("method")')) + binary.chmod(0o700) + chat = ChatSessionStore(runtime / "chat") + controller = ChatRuntimeController(store=chat, codex_bin=str(binary), registry_path=service.registry_path, + project_contexts=ChatProjectContexts([project])) + service.chat_store, service.runtime_controller = chat, controller + + try: + status, preview = request("/api/actions/preview", { + "action_kind": "todo.create", "summary": "Start the appendix check", "idempotency_key": "start-appendix", + "context": {"kind": "goal", "goal_id": "report"}, + "normalized_parameters": {"goal_id": "report", "text": "Verify the supporting appendix", + "agent_id": "codex", "start_execution": True}, + }) + assert status == 201, preview + proposal_id = preview["proposal"]["proposal_id"] + apply = f"/api/actions/{proposal_id}/apply" + save_checkpoint = store.save_checkpoint + + service.runtime_controller = None + status, unavailable = request(apply, {}) + assert status == 400 and "Runtime Controller" in unavailable["error"], unavailable + todo_id = store.load(proposal_id)["checkpoint"]["steps"]["todo_created"]["todo_id"] + partial = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="report", include_leases=True) + assert [todo.get("claimed_by") for todo in partial["todos"] if todo["todo_id"] == todo_id] == [None] + assert partial["leases"] == [] + service.runtime_controller = controller + + def lose_claim_response(*args, **kwargs): + if kwargs.get("step") == "todo_assigned": + raise OSError("Lost the response after committing the Task assignment") + return save_checkpoint(*args, **kwargs) + + monkeypatch.setattr(store, "save_checkpoint", lose_claim_response) + status, failed = request(apply, {}) + assert status == 424, failed + assert store.load(proposal_id)["checkpoint"]["steps"]["todo_created"]["todo_id"] == todo_id + authority = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="report", include_leases=True) + assert [todo["claimed_by"] for todo in authority["todos"] if todo["todo_id"] == todo_id] == ["codex"] + assert chat.list_sessions(goal_id="report") == [] + + monkeypatch.setattr(store, "save_checkpoint", save_checkpoint) + status, recovered = request(apply, {}) + assert status == 202, recovered + ids = recovered["proposal"]["receipt"]["resource_ids"] + assert ids["todo_id"] == todo_id + turn = controller.wait_for_turn(session_id=ids["session_id"], turn_id=ids["turn_id"], timeout_sec=10) + assert turn["status"] == "completed" + sessions = chat.list_sessions(goal_id="report") + assert [session["channel_id"] for session in sessions] == [f"task.{todo_id}"] + messages = [json.loads(line) for line in capture.read_text().splitlines()] + assert [row["params"]["sandbox"] for row in messages if row.get("method") == "thread/start"] == ["workspace-write"] + assert sum(row.get("method") == "turn/start" for row in messages) == 1 + status, replay = request(apply, {}) + assert status == 202 and replay["proposal"]["receipt"] == recovered["proposal"]["receipt"] + assert read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="report", include_leases=True) == authority + assert [(lease["todo_id"], lease["owner"]) for lease in authority["leases"]] == ( + [(todo_id, "codex")] if mode == "hard_lease" else []) + finally: + controller.close() + + @pytest.mark.parametrize("provider", ["file", "sqlite"]) @pytest.mark.parametrize("completion_surface", ["native-cli", "external-worker"]) def test_ordinary_goal_replans_after_first_todo_and_delivers_the_missing_section(environment, app, monkeypatch, provider, completion_surface): diff --git a/tests/control_plane/test_installed_core_context.py b/tests/control_plane/test_installed_core_context.py new file mode 100644 index 0000000000..ccef5f705c --- /dev/null +++ b/tests/control_plane/test_installed_core_context.py @@ -0,0 +1,136 @@ +"""Installed CLI context continuity; native events are protocol fixtures, not a model.""" +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +import shlex +import subprocess + +from test_core_distribution import AGENT, GOAL, _command, _environment +from test_core_distribution import core_python as core_python + + +def test_installed_context_recovers_partial_delivery_revisions_and_compaction(core_python, tmp_path): + directory = tmp_path / "native" + directory.mkdir(mode=0o700) + registry, runtime = tmp_path / "absent-registry.json", tmp_path / "absent-runtime" + source = tmp_path / "original.json" + event = {"session_id": "core-context-session", "cwd": str(tmp_path)} + environment = _environment(core_python, {"CODEX_THREAD_ID": event["session_id"]}) + prefix = ["--registry", str(registry), "--runtime-root", str(runtime), "--format", "json"] + + def command(arguments, *, stdin=None, expected=0): + result = subprocess.run(_command(core_python, [*prefix, *arguments]), + input=None if stdin is None else json.dumps(stdin), env=environment, + cwd=tmp_path, capture_output=True, text=True, timeout=45) + assert result.returncode == expected, (result.stdout, result.stderr) + return json.loads(result.stdout) + + def hook(name, **fields): + return command(["quota", "context-hook", "--context-delivery", "on", "--codex-app", + "--codex-context-dir", str(directory)], stdin={**event, "hook_event_name": name, **fields}) + + def state(): + return json.loads((directory / "session.json").read_bytes()) + + def acknowledge(pages): + for page in pages: + assert hook("PostToolUse", tool_use_id=f"page-{page['offset']}", + tool_response=json.dumps(page)) == {} + + row = {"schema_version": "todo_item_v0", "todo_id": "todo_context", "text": "Verify the report.", + "note": "Preserve the complete source and its constraints. " * 150 + "Stop before publication.", + "content_revision": "revision:one", "claimed_by": AGENT} + original = {"ok": True, "goal_id": GOAL, "agent_identity": {"agent_id": AGENT}, + "heartbeat_receipt": {"turn_instance_id": "context-fixture"}, + "stop": "Stop before publication.", "opaque_counter": 9007199254740993, + "agent_todo_summary": {"schema_version": "todo_summary_v0", + "first_open_items": [row], "backlog_items": [copy.deepcopy(row)], + "claimed_open_items": [copy.deepcopy(row)]}} + + def project(payload, setting="on"): + raw = json.dumps(payload, ensure_ascii=False).encode() + source.write_bytes(raw) + arguments = ["quota", "context-project", "--capture-file", str(source), + "--capture-sha256", hashlib.sha256(raw).hexdigest(), "--goal-id", GOAL, + "--agent-id", AGENT, "--turn-instance-id", "context-fixture", + "--context-delivery", setting, "--codex-app", "--codex-context-dir", str(directory)] + if setting == "on": + arguments += ["--max-page-output-bytes", "4000"] + return command(arguments) + + def read_required(wire, payload, *, budget=4000): + arguments = shlex.split(wire.get("recovery", wire)["command"]) + pages, contents = [], b"" + while True: + page = command(arguments[arguments.index("quota"):]) + assert page["ok"] and page["offset"] == len(contents) + if budget is not None: + assert len((json.dumps(page, ensure_ascii=False, separators=(",", ":")) + "\n").encode()) <= budget + contents += page["content"].encode() + pages.append(page) + if page["eof"]: + break + arguments = shlex.split(page["next_command"]) + required = json.loads(contents) + assert required["stop"] == payload["stop"] + assert required["opaque_counter"] == 9007199254740993 + for lane, expected in payload["agent_todo_summary"].items(): + if lane == "schema_version": + continue + values = [] + for item in required["agent_todo_summary"][lane]: + if "context_ref" in item: + item_source = required + for part in item["context_ref"].split("/")[1:]: + item_source = item_source[int(part)] if isinstance(item_source, list) else item_source[part] + values.append(item_source) + else: + values.append(item) + assert values == expected + return pages + + assert hook("SessionStart", source="startup") == {} + # Feature-off preserves even unknown semantics, without acknowledging them. + off_pages = read_required(project(original, "off"), original, budget=None) + assert json.loads("".join(page["content"] for page in off_pages)) == original + assert "candidate" not in state() + first = project(original) + assert first["mode"] == "full" + pages = read_required(first, original) + assert len(pages) > 1 + acknowledge(pages[:1]) + assert "candidate" not in state() + # Lost ACK: publishing another view cannot make an incomplete baseline valid. + unacknowledged = project(original) + assert unacknowledged["mode"] == "full" + acknowledge(read_required(unacknowledged, original)) + assert "candidate" in state() + warm = project(original) + assert warm["mode"] in {"delta", "full"} # Complete framing may make full cheaper. + acknowledge(read_required(warm, original)) + + revised = copy.deepcopy(original) + revised["stop"] = "Stop all publication and await revised approval." + for lane in ("first_open_items", "backlog_items", "claimed_open_items"): + revised["agent_todo_summary"][lane][0].update(content_revision="revision:two", + note="Contrary evidence changed the work. Preserve the revised constraint.") + changed = project(revised) + acknowledge(read_required(changed, revised)) + before = state() + assert hook("PostCompact") == {} + assert "candidate" not in state() and state()["recovery"] == before["recovery"] + compacted = project(revised) + assert compacted["mode"] == "full" + acknowledge(read_required(compacted, revised)) + + # The advertised reader rejects changed source bytes rather than trusting a digest label. + arguments = shlex.split(compacted["recovery"]["command"]) + capture = state()["recovery"]["path"] + pinned = Path(capture) + pinned.write_bytes(pinned.read_bytes() + b" ") + refused = command(arguments[arguments.index("quota"):], expected=1) + assert refused["ok"] is False and "content" not in refused + assert not registry.exists() and not runtime.exists() diff --git a/tests/control_plane_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index b7137aa574..bc52c22407 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -27,6 +27,51 @@ test("interrupted Goal bootstrap retries its original identity before downstream } }); +test("an interrupted Task start retries only its original saved Task", () => { + const canonicalReceipt = {todo_id: "todo-original", goal_id: "report", preview_id: "reviewed", status: "applied"}; + const created = {todo_id: "todo-original", canonical_receipt: canonicalReceipt}; + const proposal = {proposal_id: "start-task", expected_state_fingerprint: "reviewed", action_kind: "todo.create", + permission_classification: "durable_write", status: "failed", failure: {retry_safe: true}, + normalized_parameters: {goal_id: "report", start_execution: true}, + checkpoint: {steps: {todo_created: created}}}; + const plan = compileActionReviewPlan(proposal); + assert.equal(plan.reason, "task_start_retry"); + assert.equal(plan.retryOriginal, true); + assert.equal(plan.canApply, true); + for (const change of [{checkpoint: null}, {status: "gated"}, {failure: {retry_safe: false}}, + {normalized_parameters: {goal_id: "report", start_execution: false}}, + {checkpoint: {steps: {todo_created: {...created, todo_id: "todo-other"}}}}, + {expected_state_fingerprint: "another-review"}, {normalized_parameters: {goal_id: "other", start_execution: true}}]) { + assert.equal(compileActionReviewPlan({...proposal, ...change}).retryOriginal, undefined); + } +}); + +test("confirmed Task dispatch resolves only to the receipt's own execution Turn", () => { + const resources = {goal_id: "repair", todo_id: "todo-first", session_id: "session-first", turn_id: "turn-first"}; + const canonical = {goal_id: "repair", todo_id: "todo-first", preview_id: "reviewed", status: "applied"}; + const receipt = {projection_verified: true, outcome: "task_execution_started", resource_ids: resources, canonical_receipt: canonical}; + const proposal = {proposal_id: "start-task", expected_state_fingerprint: "reviewed", action_kind: "todo.create", + status: "applied", normalized_parameters: {goal_id: "repair", start_execution: true}, receipt}; + const result = compileActionReviewPlan(proposal); + assert.equal(result.canApply, false); + assert.deepEqual(result.taskExecution, {goalId: "repair", todoId: "todo-first", sessionId: "session-first", turnId: "turn-first"}); + for (const change of [ + {status: "applying"}, + {normalized_parameters: {goal_id: "repair", start_execution: false}}, + {receipt: {...receipt, projection_verified: false}}, + {receipt: {...receipt, resource_ids: {...resources, goal_id: "other"}}}, + {receipt: {...receipt, resource_ids: {...resources, turn_id: ""}}}, + {receipt: {...receipt, canonical_receipt: {...canonical, todo_id: "todo-other"}}}, + {receipt: {...receipt, canonical_receipt: {...canonical, preview_id: "another-review"}}}, + ]) { + assert.equal(compileActionReviewPlan({...proposal, ...change}).taskExecution, undefined); + } + // Older saved actions can still report creation while the Session is unknown. + const legacy = compileActionReviewPlan({...proposal, receipt: {projection_verified: true, outcome: "todo_added"}}); + assert.equal(legacy.interaction, "completed"); + assert.equal(legacy.taskExecution, undefined); +}); + test("post-commit creation projects its effects without certifying Goal completion", () => { const steps = {workspace_validated: {outcome: "workspace_validated", workspace_digest: "a".repeat(64)}, goal_bootstrapped: {outcome: "goal_bootstrapped", goal_id: "new-goal"}, @@ -266,6 +311,21 @@ test("operation frame projects pending and verified result states", () => { assert.equal(completed.operationFrame.summary, "Simulation completed."); }); +test("an applied operation stays unverified until its current result delivery is verified", () => { + const proposal: Record = operationProposal("outcome_observed"); + proposal.status = "applied"; + proposal.receipt = {projection_verified: true}; + const verified = compileActionReviewPlan(proposal); + assert.equal(verified.interaction, "completed"); + assert.equal(verified.reason, "readback_verified"); + const {result_delivery, ...withoutDelivery} = proposal.operation; + assert.ok(result_delivery); + const pending = compileActionReviewPlan({...proposal, operation: withoutDelivery}); + assert.equal(pending.interaction, "repair"); + assert.equal(pending.reason, "readback_unverified"); + assert.equal(pending.canApply, false); +}); + test("operation frame rejects identity drift and malformed projection fields", () => { const mismatched = operationProposal("awaiting_confirmation"); mismatched.operation.operation_id = "another-operation"; diff --git a/tests/test_change_quality_scope_paths.py b/tests/test_change_quality_scope_paths.py index 60e09ba028..1b7ae16506 100644 --- a/tests/test_change_quality_scope_paths.py +++ b/tests/test_change_quality_scope_paths.py @@ -15,8 +15,8 @@ from loopx.capabilities.change_quality.scope import build_change_quality_scope -def _repository(tmp_path: Path) -> Path: - repo = tmp_path / "repo" +def _repository(tmp_path: Path, dirname: str = "repo") -> Path: + repo = tmp_path / dirname repo.mkdir() subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) subprocess.run( @@ -36,6 +36,127 @@ def _scope(repo: Path) -> dict: return build_change_quality_scope(repo_path=repo, base_ref="HEAD") +def _commit_tree_with_paths(repo: Path, base: str, names: list[bytes]) -> None: + """Commit a synthetic Git tree without requiring those names on disk.""" + + blob = subprocess.check_output( + ["git", "-C", str(repo), "hash-object", "-w", "--stdin"], + input=b"content\n", + ).strip() + tree_input = b"".join( + b"100644 blob " + blob + b"\t" + name + b"\0" for name in names + ) + tree = subprocess.check_output( + ["git", "-C", str(repo), "mktree", "-z"], input=tree_input + ).strip().decode("ascii") + commit = subprocess.check_output( + ["git", "-C", str(repo), "commit-tree", tree, "-p", base, "-m", "fixture"], + text=True, + ).strip() + subprocess.run( + ["git", "-C", str(repo), "update-ref", "refs/heads/main", commit], + check=True, + ) + + + + +def test_committed_cr_names_remain_distinct_from_git_tree(tmp_path: Path) -> None: + """Real Git bytes prove identity even where the host cannot create the names.""" + + repo = _repository(tmp_path) + base = subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "HEAD"], text=True + ).strip() + names = [b"base.txt", b"line\rbreak.txt", b"line\r\nbreak.txt"] + _commit_tree_with_paths(repo, base, names) + + scope = build_change_quality_scope(repo_path=repo, base_ref=base) + assert set(scope["sources"]["committed"]) == { + "base.txt", + "line\rbreak.txt", + "line\r\nbreak.txt", + } + assert scope["changed_file_count"] == 3 + + +@pytest.mark.skipif(os.name != "posix", reason="a carriage return in a path is POSIX-only") +@pytest.mark.parametrize("name", ["line\rbreak.txt", "line\r\nbreak.txt"]) +def test_untracked_names_with_carriage_returns_stay_exact( + tmp_path: Path, name: str +) -> None: + """Text-mode Git reads normalize CR and CRLF; the scope must not.""" + + repo = _repository(tmp_path) + (repo / name).write_text("content\n", encoding="utf-8") + + scope = _scope(repo) + assert scope["changed_files"] == [name] + assert scope["sources"]["untracked"] == [name] + + +@pytest.mark.skipif(os.name != "posix", reason="a trailing control character in a directory is POSIX-only") +@pytest.mark.parametrize("dirname", ["repo\n", "repo\r", "repo "]) +def test_repository_root_with_trailing_whitespace_is_preserved( + tmp_path: Path, dirname: str +) -> None: + """A checkout directory ending in whitespace must not be trimmed.""" + + repo = _repository(tmp_path, dirname) + (repo / "base.txt").write_text("changed\n", encoding="utf-8") + + scope = _scope(repo) + assert scope["changed_files"] == ["base.txt"] + + +@pytest.mark.skipif(os.name != "posix", reason="special names are POSIX-only") +def test_cr_and_lf_names_remain_distinct(tmp_path: Path) -> None: + repo = _repository(tmp_path) + names = ["line\rbreak.txt", "line\nbreak.txt", "line\r\nbreak.txt"] + for name in names: + (repo / name).write_text("content\n", encoding="utf-8") + + scope = _scope(repo) + assert scope["changed_files"] == sorted(names) + assert scope["changed_file_count"] == 3 + + +@pytest.mark.skipif(os.name != "posix", reason="special names are POSIX-only") +def test_cr_names_are_preserved_across_all_scope_sources(tmp_path: Path) -> None: + repo = _repository(tmp_path) + base = subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "HEAD"], text=True + ).strip() + committed_name = "committed\r.txt" + unstaged_name = "unstaged\r.txt" + for name in (committed_name, unstaged_name): + (repo / name).write_text("first\n", encoding="utf-8") + subprocess.run( + ["git", "-C", str(repo), "add", "--", committed_name, unstaged_name], + check=True, + ) + subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "add control-character paths"], + check=True, + ) + (repo / unstaged_name).write_text("second\n", encoding="utf-8") + staged_name = "staged\r.txt" + untracked_name = "untracked\r.txt" + (repo / staged_name).write_text("staged\n", encoding="utf-8") + (repo / untracked_name).write_text("untracked\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "--", staged_name], check=True) + + scope = build_change_quality_scope(repo_path=repo, base_ref=base) + assert scope["sources"]["committed"] == sorted([committed_name, unstaged_name]) + assert scope["sources"]["staged"] == [staged_name] + assert scope["sources"]["unstaged"] == [unstaged_name] + assert scope["sources"]["untracked"] == [untracked_name] + assert set(scope["changed_files"]) == { + committed_name, unstaged_name, staged_name, untracked_name + } + assert scope["changed_file_count"] == 4 + + @pytest.mark.parametrize("name", ["中文文件.txt", "café.txt", "ファイル.txt"]) def test_untracked_names_outside_ascii_keep_their_exact_spelling( tmp_path: Path, name: str @@ -89,7 +210,14 @@ def test_changing_one_byte_of_an_untracked_file_changes_the_fingerprint( @pytest.mark.parametrize( "name", - [" padded.py ", "line\nbreak.txt", "back\\slash.py", "café.txt"], + [ + " padded.py ", + "line\rbreak.txt", + "line\r\nbreak.txt", + "line\nbreak.txt", + "back\\slash.py", + "café.txt", + ], ) def test_record_evidence_keeps_the_exact_scope_path(name: str) -> None: """Evidence must name the file the scope reported, not a normalized copy.""" @@ -123,7 +251,14 @@ def test_scope_names_match_the_path_evidence_they_are_recorded_with( """The names the scope reports must be the names evidence resolves against.""" repo = _repository(tmp_path) - names = [" padded.py ", "line\nbreak.txt", "back\\slash.py", "café.txt"] + names = [ + " padded.py ", + "line\rbreak.txt", + "line\r\nbreak.txt", + "line\nbreak.txt", + "back\\slash.py", + "café.txt", + ] for name in names: (repo / name).write_text("content\n", encoding="utf-8") @@ -139,3 +274,14 @@ def test_scope_names_match_the_path_evidence_they_are_recorded_with( instruction_refs=set(), validator_ids=set(), ) + + +def test_non_utf8_changed_path_fails_explicitly(tmp_path: Path) -> None: + repo = _repository(tmp_path) + base = subprocess.check_output( + ["git", "-C", str(repo), "rev-parse", "HEAD"], text=True + ).strip() + _commit_tree_with_paths(repo, base, [b"bad-\xff.txt"]) + + with pytest.raises(ValueError, match="not valid UTF-8"): + build_change_quality_scope(repo_path=repo, base_ref=base) diff --git a/tests/test_chat_conversation_bindings.py b/tests/test_chat_conversation_bindings.py index b824abebb4..27e53dba82 100644 --- a/tests/test_chat_conversation_bindings.py +++ b/tests/test_chat_conversation_bindings.py @@ -637,9 +637,14 @@ def test_original_turn_history_keeps_complete_rows_and_scope_fences(ordinary, mo store.append_message(sid, role="agent", text=f"Original response {i}", turn_id=tid, attachments=[{"name": "original.png", "data_url": "data:image/png;base64,AA=="}] if i == 201 else None) store.update_turn(sid, tid, status="completed", response={"message": "Original response 201"}) + later, _ = store.create_turn(sid, client_turn_id="later-correction", message="Check another case", origin="web") + store.update_turn(sid, later["turn_id"], status="running") + store.update_session(sid, active_turn_id=later["turn_id"]) path = f"/api/chat/sessions/{sid}" before = capture.read_bytes() whole = request(path)[1] + assert whole["active_turn"]["turn_id"] == later["turn_id"] + assert "selected_turn" not in whole expected = [row for row in whole["messages"] if row.get("turn_id") == tid] recovered, recover = [], runtime.recover_exited_turn def observe_recovery(source): @@ -649,19 +654,21 @@ def observe_recovery(source): query = {"message_limit": 200, "turn_id": tid} code, first = request(path + "?" + urlencode(query)) assert code == 200 and first["message_page"]["turn_id"] == tid + assert first["selected_turn"] == {"turn_id": tid, "status": "completed", "terminal": True} + assert first["active_turn"] is None, "The scoped response must not carry another Turn's details" assert len(first["messages"]) == 200 and first["message_page"]["total"] == len(expected) == 203 cursor = first["message_page"]["next_cursor"] store.append_message(sid, role="agent", text="Outside the frozen original prefix", turn_id=tid) code, rest = request(path + "?" + urlencode({**query, "message_cursor": cursor})) assert code == 200 and rest["message_page"]["next_cursor"] is None assert first["messages"] + rest["messages"] == expected + _, current = request(path + "?" + urlencode({**query, "turn_id": later["turn_id"]})) + assert current["selected_turn"] == {"turn_id": later["turn_id"], "status": "running", "terminal": False} assert recovered == [], "A historical Turn read must not recover the Session's active execution" assert capture.read_bytes() == before, "A scoped read cannot execute or recover model work" assert "turn_id" not in request(path + "?message_limit=200")[1]["message_page"] assert recovered == [sid], "An unscoped Chat refresh keeps its ordinary recovery behavior" - other, _ = store.create_turn(sid, client_turn_id="other-original", message="Other question", origin="web") - store.update_turn(sid, other["turn_id"], status="completed", response={"message": "Other answer"}) - for selected in [None, other["turn_id"]]: + for selected in [None, later["turn_id"]]: wrong = {"message_limit": 200, "message_cursor": cursor, **({"turn_id": selected} if selected else {})} assert request(path + "?" + urlencode(wrong))[0] == 400 for invalid in ["turn_id=missing", "message_limit=200&turn_id=missing", "message_limit=200&turn_id=..", diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index f2e7ce2bb0..8f6cd1092e 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -15,6 +15,29 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +@pytest.mark.parametrize("preview", [False, True]) +def test_goal_start_does_not_load_unselected_issue_fix_execution(tmp_path, preview): + arguments = ["start-goal", "--help"] + if preview: + arguments = ["--runtime-root", str(tmp_path / "runtime"), "--format", "json", + "start-goal", "--guided", "--project", str(tmp_path / "project"), + "--goal-id", "fixture", "--host-surface", "shell", + "--goal-text", "Produce a verified report; preserve 总计."] + result = run_cli_main("loopx.entrypoint", arguments, + forbidden_modules=("loopx.cli", "loopx.capabilities.issue_fix.workflow_plan", + "loopx.capabilities.issue_fix.candidate_preflight", + "loopx.capabilities.issue_fix.acceptance_loop", + "loopx.capabilities.issue_fix.metadata_preview", + "loopx.capabilities.issue_fix.repository_context"), + environment={"LOOPX_USAGE_PING": "0"}) + assert result.returncode == 0, result.stderr + if preview: + packet = json.loads(result.stdout) + assert packet["goal_text"] == "Produce a verified report; preserve 总计." + assert packet["guided_transaction"]["writes_now"] is False + assert not list(tmp_path.iterdir()) + + @pytest.mark.parametrize("module", ["loopx.entrypoint", "loopx.cli"]) @pytest.mark.parametrize("command", ["auto-research", "multi-agent"]) def test_retired_demo_commands_reject_execution_without_writing_state( @@ -2056,3 +2079,38 @@ def test_selected_connect_preview_keeps_full_cli_result_without_workspace(tmp_pa assert selected.returncode == ordinary.returncode == 0, (selected.stderr, ordinary.stderr) assert json.loads(selected.stdout) == json.loads(ordinary.stdout) assert not registry.exists() + + +@pytest.mark.parametrize('arguments', [ + ['start-goal', '--help'], + ['bootstrap-command-pack', '--help'], + ['register-agent', '--help'], + ['bind-agent-thread', '--help'], + ['start-goal', '--guided', '--goal-text', '完整 goal', '--host-surface', 'unsupported'], + ['register-agent', '--goal-id', 'missing'], + ['bind-agent-thread', '--goal-id', 'missing', '--agent-id', 'test'], +]) +def test_guided_core_routes_match_complete_parser(arguments): + # Invalid argv retains the ordinary installation's canonical full-parser + # diagnostic fallback; valid selected help must never load that facade. + selected = run_cli_main('loopx.entrypoint', arguments, + forbidden_modules=('loopx.cli',) if '--help' in arguments else ()) + full = run_cli_main('loopx.cli', arguments) + assert (selected.returncode, selected.stdout, selected.stderr) == (full.returncode, full.stdout, full.stderr) + + +def test_guided_core_preview_matches_complete_owner_without_writes(tmp_path): + project = tmp_path / 'project' + project.mkdir() + argv = ['--runtime-root', str(tmp_path / 'runtime'), '--format', 'json', + 'start-goal', '--guided', '--project', str(project), + '--host-surface', 'shell', '--goal-text', 'Report "inventory" and 总计.'] + environment = {'CODEX_THREAD_ID': '', 'TRAECLI_THREAD_ID': '', 'LOOPX_USAGE_PING': '0'} + selected = run_cli_main('loopx.entrypoint', argv, forbidden_modules=('loopx.cli',), environment=environment) + full = run_cli_main('loopx.cli', argv, environment=environment) + assert selected.returncode == full.returncode == 0 + assert selected.stdout == full.stdout and selected.stderr == full.stderr + packet = json.loads(selected.stdout) + assert packet['goal_text'] == 'Report "inventory" and 总计.' + assert packet['guided_transaction']['writes_now'] is False + assert not list(project.iterdir()) and not (tmp_path / 'runtime').exists() diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py index 1d8f45ba65..65f047f428 100644 --- a/tests/test_core_distribution.py +++ b/tests/test_core_distribution.py @@ -118,7 +118,7 @@ def test_installed_core_identity_help_and_unavailable_workspace(core_python, tmp assert not Path(json.loads(probe.stdout)["package"]).is_relative_to(REPOSITORY) help_text = _run(core_python, ["--help"], tmp_path, environment={"PATH": str(tmp_path)}).stdout assert "loopx connect --help" in help_text and "loopx turn run-once --help" in help_text - assert "preset list" not in help_text and "start-goal" not in help_text + assert "preset list" not in help_text and "loopx start-goal --guided" in help_text reference = json.loads(_run(core_python, ["commands", "--format", "json"], tmp_path).stdout) commands = [entry["command"] for group in reference["groups"] for entry in group["commands"]] assert "loopx configure-goal --help" in commands @@ -132,6 +132,77 @@ def test_installed_core_identity_help_and_unavailable_workspace(core_python, tmp assert not list(tmp_path.iterdir()) +def test_installed_core_guided_start_identity_and_readback(core_python, tmp_path, monkeypatch): + from tests.control_plane.canonical_authority_fixture import isolate_sqlite_runtime + + isolate_sqlite_runtime(tmp_path, monkeypatch) + project, runtime = tmp_path / "project", tmp_path / "runtime" + project.mkdir() + prefix = ["--runtime-root", str(runtime), "--format", "json"] + objective = 'Produce an inventory report; preserve "quoted" source names and 总计.' + start = [*prefix, "start-goal", "--guided", "--project", str(project), + "--goal-id", GOAL, "--host-surface", "shell", "--goal-text", objective] + + def command(*arguments, expected=0): + return json.loads(_run(core_python, list(arguments), project, expected=expected).stdout) + + preview = command(*start) + assert preview["goal_text"] == objective + assert preview["guided_transaction"]["writes_now"] is False + assert preview["guided_transaction"]["spends_quota_now"] is False + assert preview["recommended_next_step"]["identity_selection_gate"]["default_action"] == "register_fresh_agent" + assert not list(project.iterdir()) and not runtime.exists() + # These are the real bootstrap and identity owners advertised by the + # preview, running from the standalone wheel with the full facade absent. + created = command(*prefix, "bootstrap", "--project", str(project), "--goal-id", GOAL, + "--objective", objective, "--write-scope", "docs/**") + assert created["storage_selection"]["provider"] == "sqlite" + assert created["global_sync"]["ok"] is True + registered = command(*prefix, "register-agent", "--goal-id", GOAL, + "--agent-id", AGENT, "--require-new", "--execute") + assert registered["registration_readback"]["verified"] is True + assert registered["global_sync"]["ok"] is True + collision = command(*prefix, "register-agent", "--goal-id", GOAL, + "--agent-id", AGENT, "--require-new", "--execute", expected=1) + assert collision["ok"] is False and collision["written"] is False + binding = command(*prefix, "bind-agent-thread", "--goal-id", GOAL, + "--agent-id", AGENT, "--thread-id", "core-session", "--host-surface", "shell", "--execute") + assert binding["global_sync"]["ok"] is True + assert binding["registration_readback"]["verified"] is True + resumed = command(*start, "--thread-id", "core-session") + assert resumed["agent_id"] == AGENT + assert resumed["project_connection"]["goal_found"] is True + assert "identity_selection_gate" not in resumed["guided_transaction"] + pack = command(*prefix, "bootstrap-command-pack", "--project", str(project), + "--goal-id", GOAL, "--agent-id", AGENT, "--host-surface", "shell", "--goal-text", objective) + assert pack["goal_start_contract"]["goal_text"] == objective + assert pack["goal_start_contract"]["explicit_invocation_confirms_project_local_state_writes"] is True + + +@pytest.mark.parametrize("route", ["help", "preview", "catalog"]) +def test_installed_core_start_metadata_defers_issue_fix_execution(core_python, tmp_path, route): + arguments = ["start-goal", "--help"] if route == "help" else [ + "--runtime-root", str(tmp_path / "runtime"), "--format", "json", + "start-goal", "--guided", "--project", str(tmp_path / "project"), + "--goal-id", GOAL, "--host-surface", "shell", "--goal-text", "Verify the complete report."] + script = '''import json,sys +if sys.argv[2]=='catalog': + from loopx.capabilities.issue_fix.catalog_entry import ISSUE_FIX_CATALOG_ENTRY + assert ISSUE_FIX_CATALOG_ENTRY['default_enabled'] is False +else: + from loopx.entrypoint import main + try:code=main(json.loads(sys.argv[1])) + except SystemExit as error:code=error.code + assert code==0 +for name in ('workflow_plan','candidate_preflight','acceptance_loop','metadata_preview','repository_context'): + assert 'loopx.capabilities.issue_fix.'+name not in sys.modules,name +''' + result = subprocess.run([core_python, "-I", "-c", script, json.dumps(arguments), route], + cwd=tmp_path, env=_environment(core_python), capture_output=True, text=True, timeout=45) + assert result.returncode == 0, (result.stdout, result.stderr) + assert not list(tmp_path.iterdir()) + + def test_installed_core_catalog_entries_have_executable_parsers(core_python, tmp_path): reference = json.loads(_run(core_python, ["commands", "--format", "json"], tmp_path).stdout) for group in reference["groups"]: diff --git a/tests/test_lark_private_conversations.py b/tests/test_lark_private_conversations.py index bbee4ebcc7..b3e1d8cc59 100644 --- a/tests/test_lark_private_conversations.py +++ b/tests/test_lark_private_conversations.py @@ -2813,6 +2813,52 @@ def test_app_messages_and_answers_reach_the_original_chat_once_without_new_execu runtime.close() +def test_app_projection_discovers_a_restored_transcript_with_the_same_size_and_mtime(ordinary): + import os + + store, runtime, provider, transport, original, turns, before = _app_projection_journey(ordinary) + sid = original["session_id"] + transcript = store.sessions_root / sid / "messages.jsonl" + canonical = [message for message in store.messages(sid) if message.get("turn_id") in turns] + starts = ordinary[4].read_bytes() + try: + full = transcript.read_bytes() + old = b"".join(line for line in full.splitlines(keepends=True) + if json.loads(line).get("turn_id") not in turns) + assert old.endswith(b"\n") and len(old) < len(full) + # Simulate the earlier view before a same-size atomic restore. + transcript.write_bytes(old[:-1] + b" " * (len(full) - len(old)) + b"\n") + previous = transcript.stat() + transport._web_projection_scan_at = 0 + assert transport._web_projection_paths() == [] + assert len(provider.writes) == before + + restored = transcript.with_name("restored-messages.jsonl") + restored.write_bytes(full) + os.utime(restored, ns=(previous.st_atime_ns, previous.st_mtime_ns)) + restored.replace(transcript) + observed = transcript.stat() + assert (observed.st_mtime_ns, observed.st_size) == (previous.st_mtime_ns, previous.st_size) + transport._web_projection_scan_at = 0 + path, = transport._web_projection_paths() + _drain_app_projection(transport, lambda: len(provider.writes) == before + len(canonical)) + + deliveries = json.loads(path.read_text())["deliveries"] + assert {key for key in deliveries if key.startswith("web.")} == { + "web." + message["message_id"] for message in canonical} + for message, (profile, text) in zip(canonical, provider.writes[before:], strict=True): + receipt = deliveries["web." + message["message_id"]] + assert profile == "notes-app" and message["text"] in text and receipt["verified"] + delivered = provider.messages[receipt["attempt"]["message_ref"]] + assert delivered["chat_id"] == "oc_notes_app" + transport._web_projection_scan_at = 0 + transport.reconcile() + assert len(provider.writes) == before + len(canonical) + assert ordinary[4].read_bytes() == starts and transcript.read_bytes() == full + finally: + runtime.close() + + @pytest.mark.parametrize("recovery", ["readback", "revoked"]) def test_app_failed_turn_mirrors_only_its_terminal_notice_to_the_original_chat(ordinary, monkeypatch, recovery): # noqa: F811 store, runtime, provider, transport = connect(ordinary) @@ -6310,14 +6356,19 @@ def test_private_discovery_detects_same_size_updates_with_preserved_mtime(tmp_pa os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) assert path.stat().st_size == before.st_size and path.stat().st_mtime_ns == before.st_mtime_ns assert transport.pending_delivery_paths() == [path] - # Unknown read failures must remain visible rather than reuse an old - # delivered view or turn the request into an empty/successful queue. - def unavailable(_): + # An unreadable record is held for a later scan. It keeps its bytes and + # becomes discoverable again as soon as the original reader recovers. + reads = [] + + def unavailable(candidate): + reads.append(candidate) raise OSError("read unavailable") - monkeypatch.setattr("loopx.extensions.lark.private_delivery_discovery._read_json", unavailable) - with pytest.raises(OSError, match="read unavailable"): - transport.pending_delivery_paths() + with monkeypatch.context() as faults: + faults.setattr("loopx.extensions.lark.private_delivery_discovery._read_json", unavailable) + assert transport.pending_delivery_paths() == [] + assert reads == [path] and path.read_bytes() == body + assert transport.pending_delivery_paths() == [path] def test_private_discovery_does_not_cache_a_file_replaced_during_read(tmp_path, monkeypatch):