From f74a67b50b1d32620abdf57cd4082d289cf267e4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:17:08 +0800 Subject: [PATCH 1/2] fix(quota): make an unreconcilable action selection self-diagnosing A guard bound with `--todo-id` whose selection cannot be reconciled with the current projection used to raise a bare RuntimeError. That surfaced as `quota_unexpected_collection_error` with the reason "quota collection failed" and a recommended action pointing at heartbeat receipt writeback, so the real cause was invisible to the Agent that hit it. The preflight now raises a typed `QuotaActionSelectionConflictError` that names the requested Todo, the projection's current selection, and the qualification state, and that carries its own `error_code` and `recommended_action`: - `kind=unqualified`: the projection carries no typed qualification at all; - `kind=conflict`: the requested Todo is neither the current selection nor deferred/rejected by it. `quota_error_code` returns `quota_action_selection_conflict`, and `quota_failure_payload` reports the conflict as its own `status` with a typed `action_selection_conflict` block (`kind`, `requested_todo_id`, `selected_todo_id`, `qualification_state`) instead of the generic collection failure. The recommended action is a real next read: rerun without `--todo-id` to see the current selection, then bind that Todo, a deferred Todo, or the Todo the recovery obligation must settle. The neighboring typed paths are unchanged: a Todo that is deferred or rejected by the delivery frontier keeps its existing typed payload, and a qualified selection for the requested Todo still passes the preflight. Covered by `tests/control_plane/test_quota_action_selection_conflict.py`: both raise kinds, the code mapping, the failure payload shape, and the non-conflict case that must keep returning False. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/quota.py | 15 ++- loopx/cli_commands/quota_failure_report.py | 18 +++ loopx/control_plane/quota/error_codes.py | 54 ++++++++ .../test_quota_action_selection_conflict.py | 117 ++++++++++++++++++ 4 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 tests/control_plane/test_quota_action_selection_conflict.py diff --git a/loopx/cli_commands/quota.py b/loopx/cli_commands/quota.py index 17518c2a5e..e19539b78a 100644 --- a/loopx/cli_commands/quota.py +++ b/loopx/cli_commands/quota.py @@ -22,6 +22,8 @@ from ..control_plane.quota.effect_program import SettlementIdentity from ..control_plane.quota.error_codes import ( QuotaCommandValidationError, + QuotaActionSelectionConflictError, + QuotaActionSelectionConflictKind, ) from ..control_plane.quota.heartbeat_receipt import ( HEARTBEAT_RECEIPT_SCHEMA_VERSION, @@ -268,11 +270,18 @@ def _apply_requested_quota_action_selection_preflight( return False if not isinstance(qualification_value, Mapping): - raise RuntimeError("requested action selection lacks typed qualification") + raise QuotaActionSelectionConflictError( + QuotaActionSelectionConflictKind.UNQUALIFIED, + requested_todo_id=requested_todo_id, + selected_todo_id=selected_todo_id, + ) qualification_state = str(qualification.get("state") or "") if qualification_state not in {"deferred", "rejected"}: - raise RuntimeError( - "requested action selection qualification conflicts with its projection" + raise QuotaActionSelectionConflictError( + QuotaActionSelectionConflictKind.CONFLICT, + requested_todo_id=requested_todo_id, + selected_todo_id=selected_todo_id, + qualification_state=qualification_state, ) qualification_reason = str( qualification.get("reason") or "candidate_not_currently_eligible" diff --git a/loopx/cli_commands/quota_failure_report.py b/loopx/cli_commands/quota_failure_report.py index c3e97ea2a2..7384ed43c9 100644 --- a/loopx/cli_commands/quota_failure_report.py +++ b/loopx/cli_commands/quota_failure_report.py @@ -20,6 +20,7 @@ ) from ..control_plane.quota.error_codes import ( HeartbeatReceiptIdentityConflictError, + QuotaActionSelectionConflictError, QuotaCommandValidationError, QuotaIdentityPreconditionError, quota_error_code, @@ -127,6 +128,23 @@ def quota_failure_payload( **verbose_debug, **lock_timeout_fields, } + if isinstance(error, QuotaActionSelectionConflictError): + # The requested Todo could not be reconciled with the projection. Report + # the real conflict and the next read to make, rather than the generic + # "quota collection failed" and a pointer at receipt writeback. + payload.update( + { + "reason": str(error), + "status": "quota_action_selection_conflict", + "recommended_action": error.recommended_action, + "action_selection_conflict": { + "kind": error.kind.value, + "requested_todo_id": error.requested_todo_id, + "selected_todo_id": error.selected_todo_id, + "qualification_state": error.qualification_state, + }, + } + ) if isinstance(error, QuotaIdentityPreconditionError): payload.update( { diff --git a/loopx/control_plane/quota/error_codes.py b/loopx/control_plane/quota/error_codes.py index 3cac31974c..2f78cf5c1b 100644 --- a/loopx/control_plane/quota/error_codes.py +++ b/loopx/control_plane/quota/error_codes.py @@ -59,6 +59,58 @@ def __init__( super().__init__(reason) +class QuotaActionSelectionConflictKind(StrEnum): + """Why a requested ``--todo-id`` could not be reconciled with the projection.""" + + UNQUALIFIED = "unqualified" + CONFLICT = "conflict" + + +class QuotaActionSelectionConflictError(RuntimeError): + """Public-safe diagnostic for an unreconcilable requested action selection. + + A guard bound to a ``--todo-id`` has to agree with the current projection. + When it cannot, this error names what was requested, what the projection + currently selects, and what the caller should do next, so the failure is not + reported as an opaque quota collection failure. + """ + + error_code = "quota_action_selection_conflict" + + def __init__( + self, + kind: QuotaActionSelectionConflictKind, + *, + requested_todo_id: str | None, + selected_todo_id: str | None = None, + qualification_state: str | None = None, + ) -> None: + self.kind = kind + self.requested_todo_id = requested_todo_id + self.selected_todo_id = selected_todo_id + self.qualification_state = qualification_state + if kind is QuotaActionSelectionConflictKind.UNQUALIFIED: + reason = ( + "the current projection carries no typed action-selection " + "qualification, so the requested Todo " + f"{requested_todo_id or '(none)'} cannot be reconciled with the " + "delivery frontier" + ) + else: + reason = ( + f"requested Todo {requested_todo_id or '(none)'} is neither the " + "projection's current selection " + f"({selected_todo_id or 'none'}) nor deferred or rejected by it " + f"(qualification state: {qualification_state or 'absent'})" + ) + self.recommended_action = ( + "rerun `loopx quota should-run` without --todo-id to read the current " + "selection, then bind that Todo, a deferred Todo, or the Todo the " + "recovery obligation must settle" + ) + super().__init__(reason) + + def quota_error_code(exc: BaseException) -> str: if isinstance(exc, json.JSONDecodeError): return "quota_state_invalid_json" @@ -66,6 +118,8 @@ def quota_error_code(exc: BaseException) -> str: return "quota_invalid_arguments" if isinstance(exc, QuotaIdentityPreconditionError): return exc.error_code + if isinstance(exc, QuotaActionSelectionConflictError): + return exc.error_code if isinstance(exc, HeartbeatReceiptIdentityConflictError): return "heartbeat_receipt_identity_conflict" if isinstance(exc, PermissionError): diff --git a/tests/control_plane/test_quota_action_selection_conflict.py b/tests/control_plane/test_quota_action_selection_conflict.py new file mode 100644 index 0000000000..5bd0e8cd1a --- /dev/null +++ b/tests/control_plane/test_quota_action_selection_conflict.py @@ -0,0 +1,117 @@ +"""An unreconcilable `--todo-id` guard names its own cause and next read.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest + +from loopx.cli_commands.quota import _apply_requested_quota_action_selection_preflight +from loopx.cli_commands.quota_failure_report import quota_failure_payload +from loopx.control_plane.quota.error_codes import ( + QuotaActionSelectionConflictError, + QuotaActionSelectionConflictKind, + quota_error_code, +) + + +REQUESTED_TODO_ID = "todo_requested_selection" +SELECTED_TODO_ID = "todo_projected_selection" + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "ok": True, + "should_run": True, + "effective_action": "normal_run", + "selected_todo": None, + "execution_obligation": {"must_attempt_work": True}, + "interaction_contract": {"agent_channel": {"must_attempt": True}}, + } + payload.update(overrides) + return payload + + +def _raise(payload: dict[str, object]) -> QuotaActionSelectionConflictError: + with pytest.raises(QuotaActionSelectionConflictError) as raised: + _apply_requested_quota_action_selection_preflight( + payload, + requested_todo_id=REQUESTED_TODO_ID, + receipt_bound_todo_id=None, + receipt_bound_replan_obligation_id=None, + ) + return raised.value + + +def _qualified_for(todo_id: str) -> dict[str, object]: + return { + "schema_version": "action_selection_qualification_v0", + "state": "qualified", + "requested_todo_id": REQUESTED_TODO_ID, + "selected_todo": {"todo_id": todo_id}, + } + + +def test_conflicting_qualification_names_requested_and_selected_todo() -> None: + error = _raise( + _payload(action_selection_qualification=_qualified_for(SELECTED_TODO_ID)) + ) + + assert error.kind is QuotaActionSelectionConflictKind.CONFLICT + assert error.error_code == "quota_action_selection_conflict" + assert REQUESTED_TODO_ID in str(error) + assert SELECTED_TODO_ID in str(error) + assert "qualified" in str(error) + assert quota_error_code(error) == "quota_action_selection_conflict" + + +def test_missing_qualification_is_typed_rather_than_unexplained() -> None: + error = _raise(_payload(selected_todo={"todo_id": SELECTED_TODO_ID})) + + assert error.kind is QuotaActionSelectionConflictKind.UNQUALIFIED + assert REQUESTED_TODO_ID in str(error) + assert "no typed action-selection qualification" in str(error) + + +def test_a_qualified_selection_for_the_requested_todo_is_not_a_conflict() -> None: + is_conflict = _apply_requested_quota_action_selection_preflight( + _payload(action_selection_qualification=_qualified_for(REQUESTED_TODO_ID)), + requested_todo_id=REQUESTED_TODO_ID, + receipt_bound_todo_id=None, + receipt_bound_replan_obligation_id=None, + ) + + assert is_conflict is False + + +def test_failure_payload_reports_the_conflict_instead_of_collection_failure() -> None: + error = _raise( + _payload(action_selection_qualification=_qualified_for(SELECTED_TODO_ID)) + ) + args = argparse.Namespace( + quota_command="should-run", + goal_id="quota-conflict-fixture", + agent_id="agent-fixture", + runtime_root=None, + verbose=False, + ) + + payload = quota_failure_payload( + args, + registry_path=Path("/tmp/quota-conflict-registry.json"), + runtime_root_arg=None, + error=error, + ) + + assert payload["error_code"] == "quota_action_selection_conflict" + assert payload["status"] == "quota_action_selection_conflict" + assert payload["reason"] != "quota collection failed" + assert payload["reason"] == str(error) + assert "heartbeat receipt writeback" not in str(payload["recommended_action"]) + assert payload["action_selection_conflict"] == { + "kind": "conflict", + "requested_todo_id": REQUESTED_TODO_ID, + "selected_todo_id": SELECTED_TODO_ID, + "qualification_state": "qualified", + } From b66a1c66e8fa15ddc4c5745e504e30cd0b826dec Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:29:33 +0800 Subject: [PATCH 2/2] feat(dashboard): let the owner confirm a validated steward team plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The steward's team intake produces one validated multi-lane preview, and the Chat action surface already owns `team.plan` with a preview and an apply. The dashboard could not render it: its transport schema accepted a fixed action-kind enum without `team.plan`, so a validated preview failed to parse instead of becoming a card an owner could confirm. That left the one-sentence team flow with no confirmation surface at all. - `src/data/chat.ts`: the transport kind enum accepts `team.plan`. - `personal-workspace-model.ts`: the workspace preview kind matches. - `team-plan-preview.ts` (new): a presentation-only reducer that turns the preview into what an owner reads before confirming — the Goal the plan staffs, its objective, one row per lane (the Agent, that lane's first bounded Todo with its priority and action kind, and its acceptance signal), the declared staffing gaps with the work they did not staff, the quota envelope and the stop condition. It owns no authority and claims no lane already exists. - `personal-workspace-page.tsx`: the proposal card uses that reducer, with a summary, an impact statement that says what confirming does, and a primary label for confirmation. - `i18n.tsx`: the new strings in English and Chinese. Verification: `tsc --noEmit -p tsconfig.json` clean; `npm run smoke:team-plan-proposal` (new) prints `team plan proposal smoke ok`; the existing `smoke:chat-route` and `smoke:action-review-plan` stay green, so the kind addition did not disturb the other proposal surfaces. The card only appears when a validated team plan exists, so the dashboard's first viewport, hero and navigation are unchanged. The user-visible entry point covered here is the dashboard chat/workspace proposal card; the Lark goal channel and the CLI are unchanged by this diff. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/package.json | 1 + .../smoke/team-plan-proposal-smoke.ts | 133 ++++++++++++++++++ apps/presentation/dashboard/src/data/chat.ts | 3 + .../src/features/personal-workspace/i18n.tsx | 16 +++ .../personal-workspace-model.ts | 3 +- .../personal-workspace-page.tsx | 11 ++ .../personal-workspace/team-plan-preview.ts | 115 +++++++++++++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 49aa4a1607..dc4c21385b 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -38,6 +38,7 @@ "smoke:goal-acceptance-browser": "node ../../../examples/dashboard-goal-acceptance-browser-smoke.mjs", "smoke:goal-acceptance-packaged": "LOOPX_GOAL_ACCEPTANCE_PACKAGED=1 LOOPX_GOAL_ACCEPTANCE_PORT=5292 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/dashboard-goal-acceptance-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-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/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", "smoke:status-sources": "rm -rf /tmp/loopx-status-source-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-status-source-smoke smoke/status-source-catalog-smoke.ts src/data/ssh-host-catalog.ts src/data/status-source-catalog.ts src/data/local-status-query.ts src/data/status.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-status-source-smoke/apps/presentation/dashboard/smoke/status-source-catalog-smoke.js", diff --git a/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts b/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts new file mode 100644 index 0000000000..3007e04f20 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-plan-proposal-smoke.ts @@ -0,0 +1,133 @@ +/** + * A validated steward team plan must survive the transport and read as lanes. + * + * Before the `team.plan` kind existed in the dashboard schema, the transport + * rejected the proposal outright, so an owner could never confirm the preview + * the steward had already validated. + */ + +import { typedActionKindSchema, typedActionProposalSchema } from "../src/data/chat.js"; +import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "../src/features/personal-workspace/team-plan-preview.js"; + +const GOAL_ID = "team-plan-smoke-goal"; + +function check(condition: boolean, message: string) { + if (!condition) { + console.error(`FAIL ${message}`); + process.exitCode = 1; + } +} + +check(typedActionKindSchema.safeParse("team.plan").success, "transport accepts the steward team plan kind"); +check(!typedActionKindSchema.safeParse("team.plans").success, "the kind stays exact"); + +const plan = { + schema_version: "steward_team_plan_preview_v0", + kind: "steward_team_plan_preview", + goal_id: GOAL_ID, + objective: "Ship the steward team intake", + lanes: [ + { + lane_id: "lane_backend", + agent_id: "agent-backend", + acceptance: "the bounded Todo is created through the canonical owner", + staffing: "ready", + first_todo: { + text: "Implement the bounded intake", + priority: "P1", + task_class: "advancement_task", + action_kind: "implement", + }, + }, + { + lane_id: "lane_review", + agent_id: "agent-reviewer", + acceptance: "the review receipt is recorded", + staffing: "gap", + gap_reason_code: "agent_not_registered", + declined_first_todo: { + text: "Independently review the intake", + priority: "P1", + task_class: "advancement_task", + action_kind: "validate", + }, + }, + ], + gaps: [{ lane_id: "lane_review", reason_code: "agent_not_registered" }], + quota_envelope: { slots: 4, window: "1d" }, + stop_condition: "every lane reports a typed outcome or a stated gap", + applies: false, +}; + +const proposal = typedActionProposalSchema.parse({ + schema_version: "loopx_chat_action_proposal_v1", + proposal_id: "proposal_team_plan_smoke", + action_kind: "team.plan", + summary: "Staff a two-lane team", + normalized_parameters: { goal_id: GOAL_ID, plan, requested_by: "owner" }, + context: { goal_id: GOAL_ID }, + expected_state_fingerprint: "sha256:team-plan-smoke", + permission_classification: "durable_write", + validation_evidence: [ + "every ready lane names an Agent this Goal registers", + "the plan names the Goal it staffs", + ], + available_transitions: ["apply", "cancel"], + status: "preview_ready", + receipt: null, + stale: null, + created_at: "2026-09-16T12:00:00Z", + updated_at: "2026-09-16T12:00:00Z", +}); + +check(proposal.action_kind === "team.plan", "the proposal keeps the team plan kind"); +check(teamPlanGoalId(proposal.normalized_parameters) === GOAL_ID, "the card names the Goal the plan staffs"); +check(teamPlanLaneCount(proposal.normalized_parameters) === 2, "the card counts the previewed lanes"); + +const translate = (key: string, values?: Record) => { + const table: Record = { + "proposal.field.goalId": "Goal", + "proposal.field.objective": "Objective", + "proposal.field.laneGaps": "Unstaffed lanes", + "proposal.field.quotaEnvelope": "Quota envelope", + "proposal.field.stopCondition": "Stop condition", + "proposal.teamPlan.acceptanceShort": "acceptance", + "proposal.teamPlan.gapLane": "unstaffed", + "proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo", + }; + const template = table[key] ?? key; + return Object.entries(values ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, String(value)), + template, + ); +}; + +const fields = teamPlanFields(proposal.normalized_parameters, translate as never); +const byKey = new Map(fields.map((field) => [field.key, field])); +const readyLane = byKey.get("lane_lane_backend"); +const gapLane = byKey.get("lane_lane_review"); + +check(readyLane?.label === "agent-backend", "a ready lane names the Agent that runs it"); +check( + readyLane?.value.includes("P1") === true + && readyLane?.value.includes("Implement the bounded intake") === true + && readyLane?.value.includes("acceptance: the bounded Todo is created through the canonical owner") === true, + "a ready lane shows its first bounded Todo, its priority and its acceptance signal", +); +check( + gapLane?.value.startsWith("unstaffed · agent_not_registered") === true + && gapLane?.value.includes("Independently review the intake") === true, + "a gap lane says it is unstaffed, names the reason and keeps the work it did not staff", +); +check(byKey.get("lane_gaps")?.value === "lane_review: agent_not_registered", "the gap summary joins lane and reason"); +check(byKey.get("quota_envelope")?.value === "slots: 4 · window: 1d", "the quota envelope is shown as data"); +check( + byKey.get("stop_condition")?.value === "every lane reports a typed outcome or a stated gap", + "the stop condition is shown", +); +check( + fields.some((field) => field.value.includes("agent-") === true && field.value.includes("ready") === true) === false, + "the card never renders a lane as already created", +); + +if (process.exitCode !== 1) console.log("team plan proposal smoke ok"); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 6afd9ac015..5c7589ae18 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -332,6 +332,9 @@ export const typedActionKindSchema = z.enum([ "gate.resolve", "run.correct", "operation.execute", + // The steward's team intake: one validated multi-lane preview that the owner + // confirms. The apply re-validates the same payload before creating work. + "team.plan", ]); const typedOperationEnvelopeSchema = z.object({ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 0d712b3568..ecaa63b998 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -694,6 +694,14 @@ const en = { "proposal.summary.lifecycleResume": "Resume Goal: {title}", "proposal.summary.lifecycleStop": "Stop Goal: {title}", "proposal.summary.monitor": "Create a scheduled check for the current Goal: {target}", + "proposal.summary.teamPlan": "Staff a {count}-lane team for {goal}", + "proposal.impact.teamPlan": "Confirming creates each ready lane's first bounded Todo through the canonical owner; a lane with a staffing gap creates nothing, no lane exists before the apply receipt returns, and a Goal or Agent change between preview and apply makes this proposal stale instead of applying drifted staffing.", + "proposal.primary.teamPlan": "Confirm and staff the lanes", + "proposal.field.laneGaps": "Unstaffed lanes", + "proposal.field.quotaEnvelope": "Quota envelope", + "proposal.teamPlan.acceptanceShort": "acceptance", + "proposal.teamPlan.gapLane": "unstaffed", + "proposal.teamPlan.laneUnstaffed": "staffing gap, no first Todo", "proposal.workspace.current": "Current local workspace (no Repository bound)", "proposal.workspace.named": "{workspace} (execution environment only; does not bind a repository)", "proposal.workspaceGate.agentImpact": "Bind an Agent identity, then recheck the original action. No Goal has been written.", @@ -1722,6 +1730,14 @@ const zhCN: Record = { "proposal.summary.lifecycleResume": "恢复 Goal:{title}", "proposal.summary.lifecycleStop": "停止 Goal:{title}", "proposal.summary.monitor": "为当前 Goal 创建定时检查:{target}", + "proposal.summary.teamPlan": "为 {goal} 配出 {count} 条 lane 的团队", + "proposal.impact.teamPlan": "确认后会通过既有 owner 为每条就绪 lane 创建它的首个有界 Todo;声明了编制缺口的 lane 不创建任何东西;apply 回执返回前不存在任何 lane;预览与 apply 之间 Goal 或 Agent 发生变化会让这条提案变为 stale,而不是按漂移后的编制执行。", + "proposal.primary.teamPlan": "确认并组建各 lane", + "proposal.field.laneGaps": "未配齐的 lane", + "proposal.field.quotaEnvelope": "配额包络", + "proposal.teamPlan.acceptanceShort": "验收", + "proposal.teamPlan.gapLane": "未配齐", + "proposal.teamPlan.laneUnstaffed": "编制缺口,无首个 Todo", "proposal.workspace.current": "当前本地工作区(未绑定 Repository)", "proposal.workspace.named": "{workspace}(仅提供执行环境,不会自动关联仓库)", "proposal.workspaceGate.agentImpact": "先完成 Agent 身份绑定,再重新检查原操作;当前没有写入 Goal。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 7fd7452ed9..da085993a8 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -216,7 +216,8 @@ export type WorkspaceActionPreview = { | "monitor.update" | "gate.resolve" | "run.correct" - | "operation.execute"; + | "operation.execute" + | "team.plan"; agentLabel?: string; fields: Array<{ key: string; label: string; value: string }>; goalId?: string; 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 992ba0946e..c9d25141a2 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 @@ -3,6 +3,7 @@ import { isStaleActionFailure, } from "../../../../../../loopx/control_plane/presentation/action_review_plan.js"; import { refreshAttention } from "./attention-details"; +import { teamPlanFields, teamPlanGoalId, teamPlanLaneCount } from "./team-plan-preview"; import { useEffect, useMemo, useRef, useState, type ClipboardEvent as ReactClipboardEvent } from "react"; import { AlertCircle, Bot, CalendarClock, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X } from "lucide-react"; @@ -561,6 +562,11 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) const operationTitle = operationFrame?.content.title ?? proposal.summary; const localizedSummary = proposal.action_kind === "operation.execute" ? operationTitle + : proposal.action_kind === "team.plan" + ? t("proposal.summary.teamPlan", { + goal: teamPlanGoalId(proposal.normalized_parameters), + count: teamPlanLaneCount(proposal.normalized_parameters), + }) : proposal.action_kind === "goal.create" ? t("proposal.summary.goalCreate", { title }) : proposal.action_kind === "heartbeat.bind" @@ -579,10 +585,14 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) reviewPlan, fields: proposal.action_kind === "operation.execute" ? operationProposalFields(proposal, reviewPlan, t) + : proposal.action_kind === "team.plan" + ? teamPlanFields(proposal.normalized_parameters, t) : proposalFields(proposal.normalized_parameters, t), goalId: typeof proposal.normalized_parameters.goal_id === "string" ? proposal.normalized_parameters.goal_id : undefined, impact: proposal.action_kind === "operation.execute" ? t("proposal.impact.operation") + : proposal.action_kind === "team.plan" + ? t("proposal.impact.teamPlan") : proposal.action_kind === "goal.create" ? t("proposal.impact.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" @@ -607,6 +617,7 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) ? t("proposal.primary.operationResultVerified") : t("proposal.primary.operationResultPending") : t("proposal.primary.operationGroup") + : proposal.action_kind === "team.plan" ? t("proposal.primary.teamPlan") : proposal.action_kind === "goal.create" ? t("proposal.primary.goalCreate") : proposal.action_kind === "goal.lifecycle" && lifecycleOperation === "stop" ? t("proposal.primary.lifecycleStop") diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts new file mode 100644 index 0000000000..94297dc69d --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-plan-preview.ts @@ -0,0 +1,115 @@ +/** + * Present a validated steward team plan for owner confirmation. + * + * The reducer owns no action authority: it reads the preview the host already + * validated and shows, per lane, the Agent that runs it, that lane's first + * bounded Todo, its acceptance signal, and whether the lane is a declared + * staffing gap. Confirming the card is what asks the canonical owners to create + * the lanes, so the card states what confirming does and never claims a lane + * already exists. + */ + +import type { WorkspaceTranslate } from "./i18n.js"; + +export type TeamPlanPreviewField = { key: string; label: string; value: string }; + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" ? value as Record : {}; +} + +function asText(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +export function teamPlanFields( + parameters: Record, + t: WorkspaceTranslate, +): TeamPlanPreviewField[] { + const plan = asRecord(parameters.plan); + const fields: TeamPlanPreviewField[] = []; + const goalId = asText(parameters.goal_id) || asText(plan.goal_id); + if (goalId) { + fields.push({ key: "goal_id", label: t("proposal.field.goalId"), value: goalId }); + } + const objective = asText(plan.objective); + if (objective) { + fields.push({ key: "objective", label: t("proposal.field.objective"), value: objective }); + } + const lanes = Array.isArray(plan.lanes) ? plan.lanes : []; + lanes.forEach((rawLane, index) => { + const lane = asRecord(rawLane); + const laneId = asText(lane.lane_id) || `lane-${index + 1}`; + const agentId = asText(lane.agent_id); + const acceptance = asText(lane.acceptance); + if (asText(lane.staffing) === "gap") { + // A gap lane keeps the work it did not staff and creates nothing. + const declined = asRecord(lane.declined_first_todo); + fields.push({ + key: `lane_${laneId}`, + label: agentId || laneId, + value: [ + t("proposal.teamPlan.gapLane"), + asText(lane.gap_reason_code), + asText(declined.text), + ].filter(Boolean).join(" · "), + }); + return; + } + const todo = asRecord(lane.first_todo); + const laneValue = [ + asText(todo.priority), + asText(todo.action_kind), + asText(todo.text), + ].filter(Boolean).join(" · "); + fields.push({ + key: `lane_${laneId}`, + label: agentId || laneId, + value: [ + laneValue || t("proposal.teamPlan.laneUnstaffed"), + acceptance ? `${t("proposal.teamPlan.acceptanceShort")}: ${acceptance}` : "", + ].filter(Boolean).join(" · "), + }); + }); + const gaps = Array.isArray(plan.gaps) ? plan.gaps : []; + if (gaps.length > 0) { + fields.push({ + key: "lane_gaps", + label: t("proposal.field.laneGaps"), + value: gaps + .map((rawGap) => { + const gap = asRecord(rawGap); + return [asText(gap.lane_id), asText(gap.reason_code)].filter(Boolean).join(": "); + }) + .filter(Boolean) + .join(" · "), + }); + } + const envelope = asRecord(plan.quota_envelope); + const envelopeEntries = Object.entries(envelope); + if (envelopeEntries.length > 0) { + fields.push({ + key: "quota_envelope", + label: t("proposal.field.quotaEnvelope"), + value: envelopeEntries.map(([key, value]) => `${key}: ${String(value ?? "")}`).join(" · "), + }); + } + const stopCondition = asText(plan.stop_condition); + if (stopCondition) { + fields.push({ + key: "stop_condition", + label: t("proposal.field.stopCondition"), + value: stopCondition, + }); + } + return fields; +} + +export function teamPlanLaneCount(parameters: Record): number { + const plan = asRecord(parameters.plan); + return Array.isArray(plan.lanes) ? plan.lanes.length : 0; +} + +export function teamPlanGoalId(parameters: Record): string { + const plan = asRecord(parameters.plan); + return asText(parameters.goal_id) || asText(plan.goal_id); +}