diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 8711bce25d..025d0f0089 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -50,6 +50,7 @@ "smoke:goal-acceptance-contract-browser": "node smoke/goal-acceptance-contract-browser-smoke.mjs", "smoke:goal-acceptance-contract-packaged": "LOOPX_ACCEPTANCE_CONTRACT_PACKAGED=1 LOOPX_ACCEPTANCE_CONTRACT_PORT=5297 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node smoke/goal-acceptance-contract-browser-smoke.mjs", "smoke:status-projection-contract": "rm -rf /tmp/loopx-status-projection-contract-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-projection-contract-smoke smoke/status-projection-contract-smoke.ts src/data/status.ts src/data/status-merge.ts src/data/status-request-fence.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-projection-contract-smoke/apps/presentation/dashboard/smoke/status-projection-contract-smoke.js", + "smoke:team-plan-recovery": "vite build --ssr smoke/team-plan-recovery-smoke.mjs --outDir node_modules/.cache/loopx-team-plan-recovery --emptyOutDir && node node_modules/.cache/loopx-team-plan-recovery/team-plan-recovery-smoke.js", "smoke:team-plan-proposal": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --ignoreDeprecations 6.0 --jsx react-jsx --types node --skipLibCheck --strict --rootDir ../../.. --outDir node_modules/.cache/loopx-team-plan-smoke smoke/team-plan-proposal-smoke.ts src/data/chat.ts src/features/personal-workspace/team-plan-preview.ts src/vite-env.d.ts && node node_modules/.cache/loopx-team-plan-smoke/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.js", "smoke:status-source-switch-browser": "node ../../../examples/status-source-switch-browser-smoke.mjs", "smoke:status-source-switch-packaged": "LOOPX_STATUS_SOURCE_SWITCH_PACKAGED=1 LOOPX_STATUS_SOURCE_SWITCH_PORT=5198 node ../../../examples/status-source-switch-browser-smoke.mjs", diff --git a/apps/presentation/dashboard/smoke/team-plan-recovery-smoke.mjs b/apps/presentation/dashboard/smoke/team-plan-recovery-smoke.mjs new file mode 100644 index 0000000000..26bcf11d7f --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-plan-recovery-smoke.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import {createElement} from "react"; +import {renderToStaticMarkup} from "react-dom/server"; +import {compileActionReviewPlan} from "../../../../loopx/control_plane/presentation/action_review_plan.ts"; +import {ContextDrawer} from "../src/features/personal-workspace/context-drawer.tsx"; +import {WorkspaceI18nProvider} from "../src/features/personal-workspace/i18n.tsx"; + +const failed = { + schema_version: "loopx_chat_action_proposal_v1", proposal_id: "assignment-1", + action_kind: "team.plan", status: "failed", permission_classification: "durable_write", + expected_state_fingerprint: "source-1", available_transitions: ["apply", "cancel"], + failure: {retry_safe: true}, context: {kind: "manager", goal_id: "goal-1"}, + normalized_parameters: {goal_id: "goal-1", plan: { + schema_version: "steward_team_plan_preview_v0", kind: "steward_team_plan_preview", + goal_id: "goal-1", objective: "Inspect the contract", applies: false, + stop_condition: "Return evidence", quota_envelope: {slots: 1}, gaps: [], + lanes: [{lane_id: "inspect", agent_id: "alpha", staffing: "ready", + acceptance: "Return verified evidence", first_todo: {text: "Inspect the contract", + priority: "P1", task_class: "advancement_task", action_kind: "implement"}}], + }}, +}; +function render(raw, patch = {}) { + const reviewPlan = compileActionReviewPlan(raw); + const item = {actionKind: raw.action_kind, previewId: raw.proposal_id, goalId: "goal-1", + title: "Assign inspection", fields: [], status: raw.status === "stale" ? "stale" : "error", + reviewPlan, ...patch}; + return renderToStaticMarkup(createElement(WorkspaceI18nProvider, null, + createElement(ContextDrawer, {agents: [], callbacks: {}, onClose() {}, + selection: {kind: "proposal", item}}))); +} +assert.match(render(failed), /]*>.*?Retry original operation<\/button>/s, + "A retry-safe original assignment has a recovery action"); +for (const patch of [ + {failure: {retry_safe: false}}, {failure: null}, + {normalized_parameters: {...failed.normalized_parameters, goal_id: "another-goal"}}, + {permission_classification: "protected"}, {available_transitions: ["cancel"]}, +]) { + const html = render({...failed, ...patch}); + assert.doesNotMatch(html, /Retry original operation|Retry assignment/, + "An unsafe or mismatched assignment cannot expose original apply"); + assert.match(html, /does not permit retrying the assignment/); +} +assert.doesNotMatch(render(failed, {reviewPlan: {...compileActionReviewPlan(failed), canApply: false}}), + /Retry original operation<\/button>/, "Even a retained recovery flag cannot bypass disabled apply"); +const stale = render({...failed, status: "stale", stale: {current: "source-2"}}); +assert.doesNotMatch(stale, /Retry original operation|Recover operation result/); +assert.match(stale, /Recheck against latest state/); +assert.match(render({...failed, action_kind: "todo.create", failure: null}), /Retry with latest state/, + "Unrelated failed previews retain their existing regeneration action"); +console.log("PASS: rendered Team Plan recovery respects typed admission and stale state"); 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 6d4e20b0ad..241a456d77 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -1149,7 +1149,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : selection.item.status === "applied" && selection.item.actionKind !== "team.plan" ?

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

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

{t("drawer.proposalStale")}

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

{t("drawer.proposalRejected")}

: null} {selection.item.reviewPlan?.operationFrame?.kind === "result" && selection.item.reviewPlan.operationFrame.resultKind === "cancelled" @@ -1158,7 +1158,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.title : t("drawer.gateRequiresHost")}{selection.item.actionKind === "operation.execute" || selection.item.workspaceCandidates?.length ? 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" ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? : null} + {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status === "error" && (selection.item.actionKind !== "team.plan" || (selection.item.reviewPlan?.retryOriginal && selection.item.reviewPlan.canApply)) ? : !readOnly && selection.item.actionKind !== "operation.execute" && selection.item.status !== "gated" && !(selection.item.actionKind === "team.plan" && ["error", "stale"].includes(selection.item.status)) && !((selection.item.actionKind === "team.plan" || decisionFrame) && selection.item.status === "applied") ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && (["stale", "gated", "rejected"].includes(selection.item.status) || (selection.item.status === "ready" && selection.item.reviewPlan?.canApply === false)) ? : null} {!readOnly && selection.item.actionKind !== "operation.execute" && selection.item.actionKind !== "gate.resolve" && ["ready", "gated"].includes(selection.item.status) ?
: null} {!["applied", "applying"].includes(selection.item.status) ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 9d8303ccf2..9ea131db95 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -801,6 +801,7 @@ const en = { "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.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.", "actionReview.inactive_proposal": "This proposal is no longer ready to execute. Recheck it before continuing.", "proposal.gate.default": "Host confirmation required", @@ -869,7 +870,7 @@ const en = { "proposal.teamPlan.openGoal": "Open Goal", "proposal.teamPlan.resultTitle": "Assignment result", "proposal.teamPlan.retry": "Retry assignment", - "proposal.teamPlan.retryHint": "Retry this assignment to recover its result without duplicating tasks.", + "proposal.teamPlan.retryUnavailable": "This proposal does not permit retrying the assignment. Check the recorded failure before continuing.", "proposal.summary.teamPlan": "Assign {count} tasks for {goal}", "proposal.impact.teamPlan": "Confirm to assign the ready tasks. Unavailable assignments stay pending.", "proposal.primary.teamPlan": "Confirm assignment", @@ -2122,6 +2123,7 @@ const zhCN: Record = { "actionReview.canonical_update_retry": "编辑结果尚未确认。重试此操作以恢复原结果。", "actionReview.goal_creation_retry": "Goal 初始化尚未完成。请重试原创建操作,已记录的存储选择会保留。", "actionReview.canonical_update_projection_pending": "操作已提交,展示尚未同步。重试此操作以恢复当前视图。", + "actionReview.team_plan_retry": "可以从同一提案重试分配。系统会重新核验当前状态,并恢复已记录的结果,不会重复创建任务。", "actionReview.apply_failed": "执行未完成。请检查失败原因并重新生成预览后再试。", "actionReview.inactive_proposal": "此提案当前不可执行。请重新检查后再继续。", "proposal.gate.default": "需要宿主确认", @@ -2190,7 +2192,7 @@ const zhCN: Record = { "proposal.teamPlan.openGoal": "打开目标", "proposal.teamPlan.resultTitle": "分配结果", "proposal.teamPlan.retry": "重试分配", - "proposal.teamPlan.retryHint": "重试会恢复本次分配结果,不会重复创建任务。", + "proposal.teamPlan.retryUnavailable": "当前提案不允许重试分配。请先核对已记录的失败原因。", "proposal.summary.teamPlan": "为 {goal} 分配 {count} 项任务", "proposal.impact.teamPlan": "确认后分配可安排的任务,其余保留为待安排。", "proposal.primary.teamPlan": "确认分配", 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 e50a3378c9..7a626efd28 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 @@ -28,6 +28,7 @@ import { setupGoalChannel, stewardPrompts, transitionTypedAction, + typedActionProposalSchema, type GoalRepositoryContext, type LarkGoalConnection, type ManagerChannelBinding, @@ -693,7 +694,8 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) : operationFrame?.kind === "confirmation" && !operationFrame.confirmationDeliveryVerified ? t("proposal.impact.operationDeliveryPending") : t("proposal.impact.operation") : proposal.action_kind === "team.plan" - ? proposal.status === "applied" ? t("proposal.teamPlan.assignedHint") : t("proposal.impact.teamPlan") + ? proposal.status === "failed" || proposal.status === "stale" ? "" + : proposal.status === "applied" ? t("proposal.teamPlan.assignedHint") : t("proposal.impact.teamPlan") : decision ? proposal.status === "applied" ? "" : t(`proposal.impact.gate.${decision}`) : proposal.action_kind === "goal.create" ? t("proposal.impact.goalCreate") @@ -1204,7 +1206,7 @@ export function PersonalWorkspacePage({ setManagerChannelProposalIds([]); return; } - if (!actionReadback.data) return; + if (!actionReadback.isSuccess || !actionReadback.data) return; const knownGoals = new Set(workspaceGoals.map(goal => goal.goalId)); const stored = actionReadback.data.filter(proposal => selectedGoalId || proposal.context.kind === "manager" || (proposal.action_kind === "operation.execute" && knownGoals.has(String(proposal.normalized_parameters.goal_id)))); @@ -1228,7 +1230,7 @@ export function PersonalWorkspacePage({ if (!selectedGoalId) setManagerChannelProposalIds(stored.filter(proposal => proposal.context.kind === "manager" || proposal.action_kind === "operation.execute") .map(proposal => proposal.proposal_id)); - }, [readOnly, selectedGoalId, actionReadback.data, actionReadback.dataUpdatedAt, t, workspaceGoals]); + }, [readOnly, selectedGoalId, actionReadback.data, actionReadback.dataUpdatedAt, actionReadback.isSuccess, t, workspaceGoals]); const homeOperations = Object.values(proposals).filter(proposal => proposal.actionKind === "operation.execute" && proposal.reviewPlan?.operationFrame?.kind === "confirmation" && proposal.status === "gated"); @@ -1440,11 +1442,9 @@ export function PersonalWorkspacePage({ presentation?: "drawer" | "feedback"; } = {}, ) { - // A failed/uncertain assignment retries its original authorized operation. - // The server still revalidates admission or recovers its immutable receipt. - const retryTeamAssignment = proposal.actionKind === "team.plan" && proposal.status === "error" - && ["apply_failed", "readback_unverified"].includes(proposal.reviewPlan?.reason ?? ""); - if (proposal.reviewPlan && !proposal.reviewPlan.canApply && !retryTeamAssignment) return; + if (proposal.reviewPlan && !proposal.reviewPlan.canApply) return; + if (proposal.actionKind === "team.plan" && proposal.status === "error" + && !proposal.reviewPlan?.retryOriginal) return; const showDrawer = options.presentation !== "feedback"; const inferredLifecycleChange = proposal.actionKind === "goal.lifecycle" && proposal.goalId @@ -1507,6 +1507,8 @@ export function PersonalWorkspacePage({ setActionFeedback( result.proposal.status === "stale" ? t("feedback.stale") + : applied.actionKind === "team.plan" && applied.status === "error" && !applied.reviewPlan?.canApply + ? t("proposal.teamPlan.retryUnavailable") : t(`actionReview.${applied.reviewPlan!.reason}`), ); return; @@ -1535,7 +1537,9 @@ export function PersonalWorkspacePage({ const gate = rawGate && typeof rawGate === "object" ? rawGate as Record : {}; const gated = { ...proposal, - reviewPlan: proposal.reviewPlan ? { ...proposal.reviewPlan, interaction: "gated" as const, reason: "authority_gate" as const, canApply: false as const } : undefined, + reviewPlan: proposal.reviewPlan ? { ...proposal.reviewPlan, retryOriginal: undefined, interaction: "gated" as const, reason: "authority_gate" as const, canApply: false as const } : undefined, + impact: proposal.reviewPlan?.retryOriginal ? "" : proposal.impact, + primaryLabel: proposal.reviewPlan?.retryOriginal ? undefined : proposal.primaryLabel, gate: { kind: String(gate.kind ?? "protected_action"), nextAction: typeof gate.next_action === "string" ? gate.next_action : undefined, @@ -1556,9 +1560,34 @@ export function PersonalWorkspacePage({ } const stale = error instanceof ChatApiError && isStaleActionFailure(error.payload); const readbackMismatch = error instanceof ChatApiError && error.payload.error_code === "action_response_mismatch"; + // Failed apply responses may carry the stored proposal. Otherwise read + // it back: only the typed owner can authorize recovery of this identity. + const returned = error instanceof ChatApiError + ? typedActionProposalSchema.safeParse(error.payload.proposal) : null; + let stored = returned?.success ? returned.data : undefined; + if (!stored && proposal.actionKind === "team.plan" && !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 + && (proposal.actionKind !== "team.plan" || stored.normalized_parameters.goal_id === proposal.goalId)) { + await actionReadback.acceptProposal(stored); + const observed = workspaceProposal(stored, t); + const reconciled = { ...observed, errorMessage: observed.status === "error" + ? error instanceof Error ? error.message : String(error) : undefined }; + setProposals(current => ({ ...current, [proposal.previewId]: reconciled })); + setSelection({ item: reconciled, kind: "proposal" }); + setActionFeedback(stored.status === "stale" ? t("feedback.stale") + : observed.reviewPlan?.interaction === "completed" ? t("feedback.completed", { title: observed.title }) + : t("feedback.executionFailed", { error: error instanceof Error ? error.message : String(error) })); + return; + } const failed = { ...proposal, - reviewPlan: proposal.reviewPlan ? { ...proposal.reviewPlan, interaction: stale ? "refresh" as const : "repair" as const, reason: readbackMismatch ? "readback_unverified" as const : stale ? "stale_proposal" as const : "apply_failed" as const, canApply: false as const } : undefined, + reviewPlan: proposal.reviewPlan ? { ...proposal.reviewPlan, retryOriginal: undefined, interaction: stale ? "refresh" as const : "repair" as const, reason: readbackMismatch ? "readback_unverified" as const : stale ? "stale_proposal" as const : "apply_failed" as const, canApply: false as const } : undefined, + impact: proposal.reviewPlan?.retryOriginal ? "" : proposal.impact, + primaryLabel: proposal.reviewPlan?.retryOriginal ? undefined : proposal.primaryLabel, errorMessage: error instanceof Error ? error.message : String(error), status: (stale ? "stale" : "error") as "stale" | "error", }; diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 9e6adae975..ba41df3088 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -972,7 +972,7 @@ This narrows a local recovery gap, not the full R1/R2 coordination acceptance. **R1 transaction checkpoint.** Team-plan admission and whole-batch planning now live in `work_items/team_plan.ts`. Confirmation assigns all admitted lanes in one write with a durable operation receipt; identity is proposal + lane, never Todo text. File/SQLite authority uses the existing CAS and receipt owner; legacy Markdown writes the records and immutable receipt together under its existing fence and lock. Exact replay reads historical results even after a receiver changes, completes or deletes work. A precommit failure creates no lane prefix, and pending canonical display delivery requires recovery before Chat reports verified success. The card names partial assignments and gaps; quota/stop remain advisory and an explicit enforcement claim is rejected. Agent-originated settlement binds the same state basis at its journal's first write and re-reads it at settlement; a plan whose basis is missing or moved, or whose every lane is a gap, is a typed failed receipt that creates no Todo and replays unchanged. -This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. +This closes the local assignment/retry portion of F4, not R1's collaboration acceptance. Registered receivers are assigned without being impersonated as authors; agent-originated settlement cannot assign another peer without owner confirmation. Assignment does not attest receiver adoption, a lease, execution, dependency consumption or independent acceptance. Do not add a second confirmation to ordinary already-authorized work. Gap resolution requires new explicit intent; replay must not silently extend the confirmed subset. The fingerprint binds current local state and canonical revision, not a full shared Goal-intent transaction. Local registry and active-state guards now remain held through the typed commit: native token claims survive caller exit, changed source bytes reject new assignments, and expired handoffs can only replay an exact committed receipt. Registry contention returns a retryable failure without creating work; canonical display recovery runs after the source guards release. Failed retry-safe Team Plan proposals retain their original review identity after Goal or manager workspace reload; retry revalidates current source facts and recovers existing assignments rather than generating a new proposal. Stale, gated and unsafe failures remain unavailable for this recovery, including in the already-open drawer. Chat recovery resolves the current registered Goal and source before receipt lookup; a removed Goal or unreadable bound source can block recovery. Canonical display recovery follows the current registered source, so this slice does not recover a deleted source binding. R2/R3/R4 still own executor qualification, receiver adoption/result return and shared intent/authorization; the cross-host Turn lease is not a plan barrier. Validation: production Chat apply and real file projection recovery; packaged confirmation card; typed batch cases on FileAuthorityStore and an isolated PostgreSQL server, including equal-text identities, concurrent commit, invalid final lane, lost response and receiver changes. These fixtures do not qualify Lark transport or cross-host worker execution. diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 6dee665216..1c09d1067a 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -693,7 +693,7 @@ lifecycle。 **R1 事务检查点。** 团队计划准入与整批规划现在归 `work_items/team_plan.ts`。确认后,全部已准入 lane 与持久操作回执一次提交;身份由 proposal + lane 决定,不再由 Todo 文本决定。File/SQLite 权威复用现有 CAS 与回执 owner;legacy Markdown 在原有 fence 和锁内同时写入任务和不可变回执。同一操作重试只读历史结果,接收者后来修改、完成或删除任务也不会触发重建。提交前失败不会留下部分 lane;canonical 展示投递仍 pending 时,Chat 必须恢复后才能报告验证成功。卡片列出部分分配及缺口;quota/stop 是参考,显式强制声明会被拒绝。Agent 发起的结算在 journal 首次写入时绑定同一状态基线并在结算时重读;基线缺失或已变动、或全部 lane 均为缺口的计划,记录为类型化的失败回执,不创建 Todo,重放结果不变。 -这完成 F4 的本地分配/重试部分,不等于 R1 协同验收。注册接收者可以被分配任务,但不会被冒充为作者;Agent 发起的结算未经业主确认不能给另一 peer 分配任务。分配不证明接收者采纳、lease、执行、依赖消费或独立验收。普通已授权工作不应普遍增加第二次确认。解决缺口需要明确的新意图;重放不能静默扩展原确认子集。fingerprint 绑定当前本地状态与 canonical revision,不是完整共享 Goal 意图事务。R2/R3/R4 仍负责执行器资格、接收者采纳/结果返回及共享意图/授权;跨主机 Turn lease 不是计划屏障。 +这完成 F4 的本地分配/重试部分,不等于 R1 协同验收。注册接收者可以被分配任务,但不会被冒充为作者;Agent 发起的结算未经业主确认不能给另一 peer 分配任务。分配不证明接收者采纳、lease、执行、依赖消费或独立验收。普通已授权工作不应普遍增加第二次确认。解决缺口需要明确的新意图;重放不能静默扩展原确认子集。fingerprint 绑定当前本地状态与 canonical revision,不是完整共享 Goal 意图事务。本地 registry 与活动状态锁现在持续保护到类型化提交结束:native token claim 在调用进程退出后仍有效;源字节变化拒绝新分配;失效交接只能重放精确匹配的已提交回执。registry 竞争返回可重试失败,不创建任务;canonical 展示恢复在源锁释放后执行。允许安全重试的失败 Team Plan 提案在 Goal 或管家工作区刷新后仍保留原评审身份;重试重新核验当前源事实,并恢复已有分配,不生成新提案。stale、gated 和不允许安全重试的失败仍不提供此恢复入口,已打开的抽屉也遵循同一判定。Chat 恢复在查找回执前解析当前注册 Goal 与源绑定;Goal 被移除或绑定源不可读时可能阻断恢复。canonical 展示恢复使用当前注册源,本次修复不恢复已删除的源绑定。R2/R3/R4 仍负责执行器资格、接收者采纳/结果返回及共享意图/授权;跨主机 Turn lease 不是计划屏障。 验证覆盖真实 Chat apply、文件权威投递恢复、打包确认卡片,以及 FileAuthorityStore 和隔离 PostgreSQL 上的同文不同身份、并发提交、末条 lane 非法、响应丢失和接收者变更。这些 fixture 不验收 Lark 传输或跨主机 worker 执行。 diff --git a/loopx/control_plane/presentation/action_review_plan.ts b/loopx/control_plane/presentation/action_review_plan.ts index d5c335919f..f17f12c570 100644 --- a/loopx/control_plane/presentation/action_review_plan.ts +++ b/loopx/control_plane/presentation/action_review_plan.ts @@ -13,7 +13,7 @@ export type ActionReviewReason = | "apply_pending" | "readback_verified" | "readback_unverified" | "apply_failed" | "inactive_proposal" | "operation_authorization_pending" | "operation_outcome_pending" - | "operation_confirmation_expired" | "operation_expiry_unknown" + | "operation_confirmation_expired" | "operation_expiry_unknown" | "team_plan_retry" | "canonical_update_retry" | "canonical_update_projection_pending" | "goal_creation_retry"; export type OperationReviewContent = { @@ -498,6 +498,23 @@ export function compileActionReviewPlan(proposalValue: unknown, nowMs?: number): reason: failure?.error_code === "canonical_update_projection_pending" ? "canonical_update_projection_pending" : "canonical_update_retry"}), retryOriginal: true}; } + const teamPlanFrame = proposal.action_kind === "team.plan" ? reviewCardFrame : undefined; + const teamPlanFailure = objectValue(proposal.failure); + const teamPlanGoalId = textValue(parameters?.goal_id); + const teamPlanContext = objectValue(proposal.context); + if (proposal.schema_version === "loopx_chat_action_proposal_v1" + && proposal.action_kind === "team.plan" && proposal.status === "failed" + && proposal.permission_classification === "durable_write" + && teamPlanFailure?.retry_safe === true + && identity.proposalId && identity.sourceFingerprint + && Array.isArray(proposal.available_transitions) + && proposal.available_transitions.includes("apply") + && teamPlanFrame?.kind === "result" && teamPlanFrame.resultKind === "failed" + && teamPlanGoalId + && textValue(objectValue(parameters?.plan)?.goal_id) === teamPlanGoalId + && (teamPlanContext?.goal_id == null || teamPlanContext.goal_id === teamPlanGoalId)) { + return {...finish({interaction: "review", canApply: true, reason: "team_plan_retry"}), retryOriginal: true}; + } if (proposal.status === "applying") { if (operationFrame?.kind === "pending" && operationFrame.executionState) { return held("pending", operationFrame.executionState === "consumed_outcome_pending" diff --git a/loopx/control_plane/work_items/team_plan_adapter.py b/loopx/control_plane/work_items/team_plan_adapter.py index e5997ac7a2..6560c02e08 100644 --- a/loopx/control_plane/work_items/team_plan_adapter.py +++ b/loopx/control_plane/work_items/team_plan_adapter.py @@ -11,18 +11,25 @@ import json import re from collections.abc import Callable, Mapping +from contextlib import ExitStack from pathlib import Path from typing import Any from ...agent_registry import registered_agent_ids_for_goal from ...history import load_registry +from ...file_lock import ( + LockAcquireTimeoutError, cross_runtime_lock_witness, exclusive_cross_runtime_file_lock, +) from ...registry import find_registry_goal from ...state_refresh import now_local from ..effect_runtime import EffectRuntimeRejected, effect_runtime_result from ..runtime.public_safety import validate_public_safe_value from ..coordination.local_authority import read_canonical_todos_if_promoted from ..coordination.local_authority_shadow_adapter import effective_runtime_root -from ..coordination.legacy_writer_fence import legacy_todo_write_transaction +from ..coordination.legacy_writer_fence import ( + legacy_coordination_todo_lock_path, legacy_todo_write_transaction, +) +from ..coordination.shadow_management import shadow_maintenance_lock_target from ..coordination.runtime_shadow_writer_adapter import ( begin_todo_runtime_shadow_capture, write_captured_todo_state, settle_todo_runtime_shadow_capture, @@ -137,20 +144,34 @@ def apply_team_plan( validate_public_safe_value(dict(proposal), path="team_plan") registry_path = Path(registry_path).expanduser() runtime = effective_runtime_root(registry_path, None) - goal = find_registry_goal(load_registry(registry_path), goal_id) - if goal is None: + if find_registry_goal(load_registry(registry_path), goal_id) is None: raise ValueError("steward team plan proposal names an unknown Goal") project, state = resolve_todo_state_path(registry_path=registry_path, goal_id=goal_id) request = { "goal_id": goal_id, "plan": dict(proposal), "actor_agent_id": agent_id, - "registered_agents": registered_agent_ids_for_goal(goal), "supported_action_kinds": sorted(TODO_ACTION_KIND_ADVANCEMENT_VALUES), "observed_at": now_local(), "expected_state_fingerprint": expected_state_fingerprint, - "intent_basis": steward_team_plan_intent_basis( - goal_id=goal_id, goal=goal, registry_path=registry_path, plan=proposal), } + + def read_source_request() -> None: + # Called under the registry and state locks, including on the legacy + # path. A registry rebind while acquiring the guards must not redirect + # the operation to an unguarded source or provider. + goal = find_registry_goal(load_registry(registry_path), goal_id) + if goal is None: + raise ValueError("steward team plan proposal names an unknown Goal") + _, current_state = resolve_todo_state_path(registry_path=registry_path, goal_id=goal_id) + if (effective_runtime_root(registry_path, None).resolve() != runtime.resolve() + or current_state.resolve() != state.resolve()): + raise TeamPlanCommitError("team_plan_preview_stale", "team plan source binding changed after preview") + request.update( + registered_agents=registered_agent_ids_for_goal(goal), + intent_basis=steward_team_plan_intent_basis( + goal_id=goal_id, goal=goal, registry_path=registry_path, plan=proposal), + ) + identity = effect_runtime_result("work_items.team_plan.identity", request) marker = f"