From 68e5da4170ca10c1f1e5dbd113a1d8a8a069b9b7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:55:22 +0800 Subject: [PATCH 01/11] fix(chat): recover confirmed task starts through canonical claims Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../src/features/personal-workspace/i18n.tsx | 2 + .../personal-workspace-page.tsx | 5 +- loopx/chat_actions.py | 85 ++++++++++++++----- .../presentation/action_review_plan.ts | 14 ++- .../test_goal_create_completion.py | 83 ++++++++++++++++++ .../action_review_plan.test.ts | 19 +++++ 6 files changed, 184 insertions(+), 24 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 5ed30c9b3d..0969d22ec4 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -941,6 +941,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.", @@ -2547,6 +2548,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-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 72884d084e..e21aebcbfc 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 @@ -2146,14 +2146,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" 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/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index cd6408a622..708755cff4 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; @@ -574,6 +574,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/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_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index b7137aa574..ff5e0425d3 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -27,6 +27,25 @@ 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("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"}, From cd35c8971c9e3296aa5d14ec6b4eeb886ab5e315 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:18:04 +0800 Subject: [PATCH 02/11] fix(chat): open the accepted task execution from its confirmation Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../personal-workspace/context-drawer.tsx | 26 ++++++++++------ .../src/features/personal-workspace/i18n.tsx | 6 ++++ .../personal-workspace-page.tsx | 3 +- .../todo-completion-feedback.ts | 5 ++- .../presentation/action_review_plan.ts | 31 ++++++++++++++++--- .../action_review_plan.test.ts | 26 ++++++++++++++++ 6 files changed, 82 insertions(+), 15 deletions(-) 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..592b1aacbc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -123,10 +123,11 @@ const RUN_ACTION_LABEL_KEYS = { retry: "drawer.recoveryRetry", } as const; -export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, proposalReadbackUnavailable = false, onRetryProposalReadback, proposalReadbackFetching = false, todoReadbackUnavailable = false, runs = [], selection }: { +export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, onSelectRun, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, proposalReadbackUnavailable = false, onRetryProposalReadback, proposalReadbackFetching = false, todoReadbackUnavailable = false, runs = [], selection }: { agents: WorkspaceAgentOption[]; attentionHistory?: WorkspaceAttention[]; onSelectAttention?: (item: WorkspaceAttention) => void; + onSelectRun?: (run: WorkspaceRun) => void; callbacks: PersonalWorkspaceCallbacks; goalNotifications?: WorkspaceGoalNotification[]; goals?: WorkspaceGoal[]; @@ -184,6 +185,10 @@ 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 + ? proposalTarget.reviewPlan?.taskExecution : undefined; + const startedTaskRun = taskExecution && runs.find(run => run.goalId === taskExecution.goalId + && run.todoId === taskExecution.todoId && run.sessionId === taskExecution.sessionId); const proposalRequests = proposalTarget?.todoId ? attentionHistory.filter(item => item.goalId === proposalTarget.goalId && item.todoId === proposalTarget.todoId) : []; @@ -343,7 +348,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 +667,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} + : selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

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

: null} + {startedTaskRun && onSelectRun ? + : 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 +1267,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 +1322,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 0969d22ec4..12913ea4fc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -238,6 +238,9 @@ 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", "feedback.todoMarkedComplete": "Marked complete", "feedback.executionFailed": "Execution failed: {error}", "feedback.gateRequired": "Your confirmation is required: {summary}", @@ -1845,6 +1848,9 @@ const zhCN: Record = { "feedback.applying": "正在执行:{title}", "feedback.cancelFailed": "取消失败:{error}", "feedback.completed": "已完成:{title}", + "feedback.taskCreated": "任务已创建", + "feedback.taskStartRequested": "任务已提交执行", + "drawer.taskLaunchReceipt": "任务回执", "feedback.todoMarkedComplete": "已标记完成", "feedback.executionFailed": "执行失败:{error}", "feedback.gateRequired": "需要你确认:{summary}", 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 e21aebcbfc..f5ef71ffaa 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 @@ -2829,7 +2829,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 })} + onSelectRun={(item) => setSelection({ kind: "run", 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)))} 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/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 708755cff4..83db5b47b4 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -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 Task Session. It does not establish a result. */ + taskExecution?: { goalId: string; todoId: string; sessionId: string }; }; export type GoalCreationProgress = { @@ -505,6 +507,23 @@ 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); + if (!goalId || !todoId || !sessionId || !textValue(resources?.turn_id) + || 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}; +} + /** * Compile provider-neutral presentation semantics from a typed action proposal. * This reducer owns no action authority and performs no external effects. @@ -559,10 +578,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); diff --git a/tests/control_plane_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index ff5e0425d3..68209f5c3c 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -46,6 +46,32 @@ test("an interrupted Task start retries only its original saved Task", () => { } }); +test("confirmed Task dispatch resolves only to the receipt's own execution Session", () => { + 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"}); + 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"}, From 809125511cad12db4fcd2c09bee999ec5ad2896c Mon Sep 17 00:00:00 2001 From: Lusen Dong Date: Fri, 13 Nov 2026 00:56:51 +0800 Subject: [PATCH 03/11] fix: execute real backend journeys in Dashboard source acceptance (#8652) Source acceptance for the first Goal and runtime readiness now executes the real backend and requires readback before PASS. Read-only socket recovery and research-help keyboard checks retain their original bounds and assertions. Paired RFC checkpoints record the scoped qualification and remaining R1/M3 boundaries. Validation: complete local and Linux source86-scenario coverage; installed-candidate86-scenario catalog and team/craft companions with common provenance; strict negative cases; exact final premerge. Full bilingual exact-head review is published and all review threads are clear. Maintainer-authorized admin merge: unchanged head reviewed; current changed invariants and required validation pass. Normal readiness is held by six queued Python shards and the identical pre-existing Windows small-row40386/40000 output-baseline failure on the unchanged owner. No historical failure is reclassified and whole-goal regression/qualification remains open. Signed-off-by: lusendong.6789 --- .../rfcs/goal-workspace-surface-v0.md | 31 ++++++++++ .../rfcs/goal-workspace-surface-v0.zh-CN.md | 24 +++++++ .../rfcs/monorepo-distribution-split-v0.md | 41 ++++++++++++ .../monorepo-distribution-split-v0.zh-CN.md | 30 +++++++++ examples/personal-workspace-browser-smoke.mjs | 8 +++ .../first-goal-home-live.mjs | 9 +-- .../research-record-search.mjs | 14 ++++- .../runtime-readiness-live.mjs | 6 +- .../scenario-context.mjs | 7 ++- .../personal-workspace-browser/shard.test.mjs | 62 +++++++++++++++++++ 10 files changed, 222 insertions(+), 10 deletions(-) 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..d6976e2071 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3468,3 +3468,44 @@ 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. 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..0159df1b5a 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,33 @@ 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 发布、安装宿主采用及完整成本 +验收仍保持开放。 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); +}); From 76a0d141cc72f54c05796d8742ded99f9e35e174 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:46:53 +0800 Subject: [PATCH 04/11] fix(change-quality): preserve CR and CRLF in Git paths (#8645) * fix(change-quality): preserve CR and CRLF in Git paths Read Git path output as bytes and split NUL-framed records before decoding. This avoids universal-newline translation rewriting legal CR and CRLF filenames, and stops replacement decoding from collapsing distinct undecodable paths. The repository root read uses the same lossless path boundary and removes only Git's terminal LF. Tests cover untracked CR and CRLF names and a checkout directory ending in LF. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> * test(change-quality): cover CR, CRLF, and non-UTF-8 Git scope paths Signed-off-by: gcl-coder --------- Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Signed-off-by: gcl-coder Co-authored-by: gcl-coder --- loopx/capabilities/change_quality/scope.py | 41 +++++- tests/test_change_quality_scope_paths.py | 154 ++++++++++++++++++++- 2 files changed, 184 insertions(+), 11 deletions(-) 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/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) From 2b45afd204790a6f91fa6266793b22012068dd00 Mon Sep 17 00:00:00 2001 From: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:47:07 +0800 Subject: [PATCH 05/11] fix(integration-branch): preserve worktree paths with line breaks (#8603) * fix(integration-branch): preserve worktree paths with line breaks `git worktree list --porcelain` frames fields on LF and prints paths verbatim. A worktree path containing LF or CR was split into multiple fields, so `_worktree_for_branch` could return a prefix and cleanup or reset could target the wrong checkout. Read `worktree list --porcelain -z` as bytes, split records on NUL pairs and paths on NUL, and keep the bounded Git process lifecycle shared by text and byte reads. Add real LF and CR worktree regressions. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> * fix(integration-branch): support Git without worktree-list NUL framing Git 2.35 rejects 'worktree list -z' with an unknown switch, so every ordinary sync failed before resolving its checkout. Fall back to the line-framed porcelain inventory and keep only structural records; paths that a line-framed format cannot represent now fail closed with an explicit Git 2.36 message instead of resolving a prefix. Add regressions using a legacy Git stub for ordinary worktree resolution and sync --execute, plus LF and CR paths that must be refused. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> * fix(integration-branch): accept bare records and verify checkout ownership The line-framed fallback rejected a bare repository's main worktree record because it expected a HEAD field, so an ordinary linked checkout could not sync on Git 2.35. Accept records that carry the bare field and skip them, while requiring every other record to be a complete HEAD-framed checkout. Verify the top-level path, common Git directory and attached branch before resetting a checkout. A worktree path containing line breaks can still fabricate a complete record in the line-framed inventory, so ownership is confirmed from the actual checkout before any write. Regressions cover a bare repository with a linked integration worktree under a legacy Git stub and a parsed checkout on another branch that must not be reset or recorded in the plan. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> * fix(integration-branch): preserve ownership paths and select Git format by version Read the checkout path and common directory as bytes and remove only the newline Git appends, so a valid worktree name ending in LF, CR or whitespace is compared exactly before any reset. Choose the worktree inventory format from `git version` instead of parsing a localized usage error. Git 2.36 added `worktree list -z`; older releases keep using the fail-closed line-framed inventory. Add full sync regressions for those path suffixes and a localized legacy-Git diagnostic. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> * fix(integration-branch): preserve requested repository roots Resolve the repository root through the byte path reader so a checkout whose path ends in LF or whitespace is not redirected to an adjacent repository. The entry point now strips only the terminal newline emitted by Git, matching the ownership checks added for reset. Add full sync regressions for two independent repositories whose names differ by a trailing LF. The configured checkout must sync itself while leaving its neighbor's integration ref and plan unchanged, and a checkout without a local plan must fail before reading the neighbor. Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --------- Signed-off-by: ChengLong GU <88359731+gcl-coder@users.noreply.github.com> --- loopx/capabilities/integration_branch/core.py | 214 ++++++++++++-- tests/capabilities/test_integration_branch.py | 268 ++++++++++++++++++ 2 files changed, 459 insertions(+), 23 deletions(-) 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/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 From 56fc2a1e8d3101a4a909b45b32bbdd32d54ab56f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:47:20 +0800 Subject: [PATCH 06/11] fix(lark): rediscover App messages after transcript replacement (#8653) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../extensions/lark/private_conversations.py | 10 +-- tests/test_lark_private_conversations.py | 63 +++++++++++++++++-- 2 files changed, 62 insertions(+), 11 deletions(-) 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/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): From d118ada12dd193cbd9752247e5e7a3d01c11de90 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 02:50:14 +0800 Subject: [PATCH 07/11] fix(core): enable guided natural-language startup and identity routes Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 50 +++++++++++++++++++ .../monorepo-distribution-split-v0.zh-CN.md | 39 +++++++++++++++ loopx/cli_runtime.py | 31 ++++++++++-- loopx/help_surface.py | 2 + packages/loopx-core/README.md | 16 +++++- tests/test_cli_entrypoint.py | 35 +++++++++++++ tests/test_core_distribution.py | 49 +++++++++++++++++- 7 files changed, 216 insertions(+), 6 deletions(-) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index d6976e2071..e8fd42a4e6 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3509,3 +3509,53 @@ 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. 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 0159df1b5a..13989153c9 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2736,3 +2736,42 @@ dry-run 拒绝路径的中位数为 142.4→135.1 ms、934.4→911.8 ms。零条 首次 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、双语要求、原始失败、预算及独立研究/发布授权 +继续有效。 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/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..3afd0ea850 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. diff --git a/tests/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index f2e7ce2bb0..bdaef754c0 100644 --- a/tests/test_cli_entrypoint.py +++ b/tests/test_cli_entrypoint.py @@ -2056,3 +2056,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..05df34660d 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,53 @@ 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 + + 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"]: From cef0204672a7da1d0bc9502ed668d5b36c424164 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 04:43:19 +0800 Subject: [PATCH 08/11] Defer issue-fix execution imports during goal startup Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- .../rfcs/monorepo-distribution-split-v0.md | 42 ++++++ .../monorepo-distribution-split-v0.zh-CN.md | 33 +++++ loopx/bootstrap_command_pack.py | 7 +- loopx/capabilities/issue_fix/catalog_entry.py | 2 +- .../issue_fix/command_templates.py | 94 ++++++++++++ loopx/capabilities/issue_fix/workflow_plan.py | 93 +----------- packages/loopx-core/README.md | 10 ++ .../test_installed_core_context.py | 136 ++++++++++++++++++ tests/test_cli_entrypoint.py | 23 +++ tests/test_core_distribution.py | 24 ++++ 10 files changed, 371 insertions(+), 93 deletions(-) create mode 100644 loopx/capabilities/issue_fix/command_templates.py create mode 100644 tests/control_plane/test_installed_core_context.py diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index e8fd42a4e6..dcdcf13173 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3559,3 +3559,45 @@ 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. 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 13989153c9..d96cadea99 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2775,3 +2775,36 @@ V10 结论,也不批准 D2 或 minimal preset。 和完整成本仍未完成。回滚路由切片时保留 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 和验收约束保持开放。 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/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/packages/loopx-core/README.md b/packages/loopx-core/README.md index 3afd0ea850..ab24bf78b2 100644 --- a/packages/loopx-core/README.md +++ b/packages/loopx-core/README.md @@ -43,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/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/test_cli_entrypoint.py b/tests/test_cli_entrypoint.py index bdaef754c0..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( diff --git a/tests/test_core_distribution.py b/tests/test_core_distribution.py index 05df34660d..65f047f428 100644 --- a/tests/test_core_distribution.py +++ b/tests/test_core_distribution.py @@ -179,6 +179,30 @@ def command(*arguments, expected=0): 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"]: From 8f6fb368e4d5be79488fe6c9578350fadf6f5ac9 Mon Sep 17 00:00:00 2001 From: "lusendong.6789" Date: Fri, 13 Nov 2026 06:01:26 +0800 Subject: [PATCH 09/11] Keep installed outcome refinement guidance reachable Signed-off-by: lusendong.6789 Co-authored-by: TRAE CLI --- docs/architecture/rfcs/monorepo-distribution-split-v0.md | 7 +++++++ .../rfcs/monorepo-distribution-split-v0.zh-CN.md | 5 +++++ skills/loopx-project/references/outcome-refinement.md | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/architecture/rfcs/monorepo-distribution-split-v0.md b/docs/architecture/rfcs/monorepo-distribution-split-v0.md index dcdcf13173..44ed1ddf37 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.md @@ -3601,3 +3601,10 @@ 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 d96cadea99..83dc5bb043 100644 --- a/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md +++ b/docs/architecture/rfcs/monorepo-distribution-split-v0.zh-CN.md @@ -2808,3 +2808,8 @@ runtime 仍保留 eager 的共享 typed 支持。完整 work contract 与传输 最小组合。保留早期检查失败及独立的启动/上下文边界;完整 V1–V5/V10、R1/M3、 独立 Standard/workspace 包、安装宿主采用及覆盖原始目标的完整回归仍由既有 owner 和验收约束保持开放。 + +安装门暴露了 project skill 的支持示例链接离开安装包的问题。链接现在指向经字节 +核验的公开源文件,并固定不可变 revision;原流程、同意边界和检查范围条款保持 +原义。它是按需支持资料,不是 runtime fetch 或新 authority。早期安装门失败和 +受磁盘限制的 base 重放,与修复 head 的验证分别保留。 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 From f6cde42e7c6bb17e128df975669e917a95f0534d Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Fri, 13 Nov 2026 07:31:17 +0800 Subject: [PATCH 10/11] fix(chat): verify bound history source before publishing (#8664) Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Co-authored-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../dashboard/smoke/bound-conversation-smoke.ts | 14 ++++++++++++++ apps/presentation/dashboard/src/data/chat.ts | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) 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..1111dd55d9 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1375,10 +1375,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: [], From 13bd68bac660ba70f37765b6695a971dc4d11757 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:14:10 +0800 Subject: [PATCH 11/11] Read the exact execution from a confirmed task receipt Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 15 +++++ .../personal-workspace/channel-timeline.tsx | 6 +- .../confirmed-task-result.tsx | 62 +++++++++++++++++++ .../personal-workspace/context-drawer.tsx | 16 +++-- .../src/features/personal-workspace/i18n.tsx | 16 +++++ .../personal-workspace-contract.test.mjs | 2 - .../personal-workspace-page.tsx | 14 +++-- .../personal-workspace/personal-workspace.css | 2 + .../capabilities/manager_context/roundtrip.py | 14 ++++- .../presentation/action_review_plan.ts | 9 +-- .../action_review_plan.test.ts | 19 +++++- tests/test_chat_conversation_bindings.py | 13 +++- 12 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/confirmed-task-result.tsx diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 1111dd55d9..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; 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 592b1aacbc..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"; @@ -123,11 +124,10 @@ const RUN_ACTION_LABEL_KEYS = { retry: "drawer.recoveryRetry", } as const; -export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, onSelectRun, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, proposalReadbackUnavailable = false, onRetryProposalReadback, proposalReadbackFetching = false, todoReadbackUnavailable = false, runs = [], selection }: { +export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention, callbacks, goalNotifications = [], goals = [], inspectorExpanded = false, larkConnections = [], onClose, onToggleInspectorSize, readOnly = false, proposalReadbackUnavailable = false, onRetryProposalReadback, proposalReadbackFetching = false, todoReadbackUnavailable = false, runs = [], selection }: { agents: WorkspaceAgentOption[]; attentionHistory?: WorkspaceAttention[]; onSelectAttention?: (item: WorkspaceAttention) => void; - onSelectRun?: (run: WorkspaceRun) => void; callbacks: PersonalWorkspaceCallbacks; goalNotifications?: WorkspaceGoalNotification[]; goals?: WorkspaceGoal[]; @@ -185,10 +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 + const taskExecution = proposalTarget?.status === "applied" && !proposalReadbackUnavailable && !readOnly ? proposalTarget.reviewPlan?.taskExecution : undefined; - const startedTaskRun = taskExecution && runs.find(run => run.goalId === taskExecution.goalId - && run.todoId === taskExecution.todoId && run.sessionId === taskExecution.sessionId); const proposalRequests = proposalTarget?.todoId ? attentionHistory.filter(item => item.goalId === proposalTarget.goalId && item.todoId === proposalTarget.todoId) : []; @@ -1225,9 +1223,9 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ?

{t("proposal.readbackUnavailable")}

{onRetryProposalReadback ? : 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") @@ -1254,8 +1252,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention
: 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(taskExecution ? "feedback.taskStartRequested" : "drawer.proposalApplied")}

: null} - {startedTaskRun && onSelectRun ? + {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} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 12913ea4fc..435030e722 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -241,6 +241,14 @@ const en = { "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}", @@ -1851,6 +1859,14 @@ const zhCN: Record = { "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}", 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 f5ef71ffaa..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)); @@ -2830,7 +2835,7 @@ export function PersonalWorkspacePage({ }} />} notice={serviceNotice} drawer={drawerSelection ? setSelection({ kind: "attention", item })} - onSelectRun={(item) => setSelection({ kind: "run", item })} callbacks={effectiveDrawerCallbacks} goalNotifications={model.goalNotifications ?? []} goals={workspaceGoals} inspectorExpanded={taskInspectorExpanded} larkConnections={drawerReadOnly ? [] : larkConnections} + 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)))} @@ -2842,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/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/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index 83db5b47b4..ec6059c58c 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -96,8 +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 Task Session. It does not establish a result. */ - taskExecution?: { goalId: string; todoId: string; sessionId: string }; + /** 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 = { @@ -518,10 +518,11 @@ function taskExecutionReadback( const goalId = textValue(parameters.goal_id); const todoId = textValue(resources?.todo_id); const sessionId = textValue(resources?.session_id); - if (!goalId || !todoId || !sessionId || !textValue(resources?.turn_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}; + return {goalId, todoId, sessionId, turnId}; } /** diff --git a/tests/control_plane_ts/action_review_plan.test.ts b/tests/control_plane_ts/action_review_plan.test.ts index 68209f5c3c..bc52c22407 100644 --- a/tests/control_plane_ts/action_review_plan.test.ts +++ b/tests/control_plane_ts/action_review_plan.test.ts @@ -46,7 +46,7 @@ test("an interrupted Task start retries only its original saved Task", () => { } }); -test("confirmed Task dispatch resolves only to the receipt's own execution Session", () => { +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}; @@ -54,7 +54,7 @@ test("confirmed Task dispatch resolves only to the receipt's own execution Sessi 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"}); + 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}}, @@ -311,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_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=..",