Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { useWorkspaceI18n } from "./i18n";

/** The saved browser carrier is recovery context, never write authority: the
* server loads its immutable plan and the canonical owner checks the digest. */
export function GoalOwnershipSettings({goalId, onChanged}: {goalId: string; onChanged: () => void}) {
export function GoalOwnershipSettings({goalId, onChanged, refreshKey = 0}: {
goalId: string; onChanged: () => void; refreshKey?: number;
}) {
const {t} = useWorkspaceI18n();
const key = `loopx-ownership-preview:${goalId}`;
const [current, setCurrent] = useState<GoalOwnership | null>(null);
Expand Down Expand Up @@ -36,7 +38,7 @@ export function GoalOwnershipSettings({goalId, onChanged}: {goalId: string; onCh
}).catch(() => { if (generation.current === token) setError(t("ownership.loadFailed")); })
.finally(() => { if (generation.current === token) setBusy(false); });
return () => { generation.current++; };
}, [goalId, key, reload, t]);
}, [goalId, key, reload, refreshKey, t]);

async function submit(apply: boolean) {
if (inFlight.current || busy) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
const [invalidSaved, setInvalidSaved] = useState(false);
const inFlight = useRef(false);
const generation = useRef(0);
const onChangedRef = useRef(onChanged);
onChangedRef.current = onChanged;
useEffect(() => {
const token = ++generation.current;
setCurrent(null); setCold(undefined); setResult(null); setCarrier(null); setConfirmed(false); setMode(""); setInvalidSaved(false); setError(null); setBusy(true);
Expand All @@ -45,6 +47,9 @@ export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChan
if (token !== generation.current) return;
setResult(recovered);
if (recovered.current !== undefined) setCurrent(recovered.current);
// Original-operation recovery may observe a cutover whose reply was
// lost. Sibling ownership must read today's policy independently.
if (recovered.ok && recovered.current?.canonical) onChangedRef.current();
if (!recovered.ok) setError(t("storage.rejected"));
else if (!recovered.current) setError(t("storage.unavailable"));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function WorkspaceSettingsPage({
const [tab, setTab] = useState<SettingsPage>(initialTab === "machine" ? "capabilities" : initialTab);
const [capabilityScope, setCapabilityScope] = useState<"machine" | "goal">(initialTab === "capabilities" ? "goal" : "machine");
const [capabilityGoalId, setCapabilityGoalId] = useState(initialGoalId ?? "");
const [ownershipRefresh, setOwnershipRefresh] = useState(0);
const tabsRef = useRef<HTMLElement>(null);
useEffect(() => {
const navigation = tabsRef.current;
Expand Down Expand Up @@ -223,8 +224,11 @@ export function WorkspaceSettingsPage({
/>
) : null}
{tab === "ownership" && selectedGoal ? <div key={selectedGoal.goalId}>
<GoalOwnershipSettings goalId={selectedGoal.goalId} onChanged={onChanged} />
<GoalStorageSettings goalId={selectedGoal.goalId} onChanged={onChanged} />
<GoalOwnershipSettings goalId={selectedGoal.goalId} onChanged={onChanged} refreshKey={ownershipRefresh} />
<GoalStorageSettings goalId={selectedGoal.goalId} onChanged={() => {
setOwnershipRefresh(value => value + 1);
onChanged();
}} />
</div> : null}
{tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? <ConfigurationBackupSettings key={`${capabilityScope}:${capabilityGoalId}`} goalId={capabilityScope === "goal" ? capabilityGoalId : null} /> : null}
{tab === "cadence" && selectedGoal ? <AutomationCadenceSettings key={selectedGoal.goalId} goal={selectedGoal} /> : null}
Expand Down
35 changes: 24 additions & 11 deletions docs/architecture/rfcs/loopx-overall-roadmap-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -952,8 +952,9 @@ import replay preserves that active grant and its later native settlement;
a stale token refuses before launch even while the same Todo has a fresh active
lease. Process exit remains separate from lease release after cutover as well.
This qualifies the existing POSIX supervisor/lease reactivation boundary with
synthetic work. App startup, full-state identity adoption, automatic Host
discovery and live model use remain separate acceptance gaps.
synthetic work. Full-state identity adoption, automatic Host discovery and live
model use remain separate acceptance gaps; the App loading checkpoint below
qualifies its own synthetic import boundary.
Proved original Todo outbox disposition now composes with cold import on both
File and SQLite: four interrupted-write windows retain pending-source backups,
exact original receipts and revision-bound rollback archives. Active capture
Expand All @@ -978,15 +979,27 @@ the source partition projector has one shared owner, with compatible old
imports. Configuration and direct guard checks run without both capture modules.
Pure runtime-root routing and its actual canonical Todo, acceptance, Chat and
CLI callers now use the existing paths owner, with an identical compatibility
reexport for capture callers. Source and fresh-wheel File/SQLite App HTTP import,
process restart, original-operation recovery, later canonical writes and prose
checks pass the same oracle with both capture modules present and physically
absent. Source/maintenance identity, Todo/handoff invariance, JSON types,
project-relative routing and failed-write behavior remain unchanged. Historical
outbox disposition and independent data/history recovery retain their separate
qualifications below; authorized reactivation and remaining App writer callers
stay open. This stage does not retire the supported old writer or qualify a
released default.
reexport for capture callers. Source and fresh-wheel File/SQLite qualification
now uses the full `serve_chat`
entrypoint with all four Todo/bootstrap/capture producer modules physically
absent. Shared Todo input transport calls the existing typed create/update/
terminal owners; pure readback and Goal identity no longer load the old writers.
The compatibility facade reexports the same APIs and retains its real Markdown
write bodies and bootstrap effects. Reviewed App import, source/backup drift
refusal, original-operation restart/replay, full current/archive readback and
later App create/edit/complete preserve the provider head under an explicitly
selected `soft_claim` policy. Missing selected storage and absent `hard_lease`
execution proof still refuse without Markdown fallback. The packaged settings
journey uses backup, preview, reload/readback and explicit confirmation.
Reviewed canonical Todo creation carries its provider revision into the existing
typed command identity and provider transaction, rejecting concurrent writes
without applying the stale request. Original-operation recovery precedes that
current-head check and preserves later writes. After import or receipt recovery,
the same settings screen independently rereads current ownership; a failed read
shows a retryable error instead of retaining the pre-import policy.
This qualifies the bounded App import/loading path, not deletion of remaining
bootstrap/Host/history callers, full-state identity adoption, D2 or a released
default. Keep those existing acceptances open.
The shared TS source check admits runtime directory aliases by physical identity,
preserving the original snapshot and foreign-source rejection. File/SQLite HTTP
joins alias-bound import, externally configured registry backup, later writes and original-operation recovery; a
Expand Down
39 changes: 28 additions & 11 deletions docs/reference/local-authority-provider-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,9 @@ with a new execution key and the current version, rather than reusing the old
execution receipt. Keep the supervisor/OS transport: writer retirement does not
remove its process-exit and current-execution proof duties.
This qualifies operator-led POSIX reactivation of synthetic work, not automatic
Host discovery/stop, pending outbox disposition, live model sessions, App startup
or identity adoption after full-history restore. The cold-import CLI reuses the
Host discovery/stop, pending outbox disposition, live model sessions or identity
adoption after full-history restore. The App loading boundary is qualified
separately below. The cold-import CLI reuses the
selected command dispatcher and
the existing Goal path resolver; its File/SQLite import and original-receipt
recovery run with `todos.py`, `bootstrap.py`, `runtime_shadow_writer_adapter.py`
Expand All @@ -424,15 +425,31 @@ with both capture modules absent. Pure runtime-root routing now belongs to
`paths.effective_runtime_root`; canonical Todo, terminal lifecycle, acceptance,
Chat and CLI callers import that owner directly. The old adapter reexports the
same function for supported capture callers. Source and fresh-wheel File/SQLite
HTTP import, process restart, original-operation recovery, later writes and
canonical prose checks use the same oracle with both capture modules present
and physically absent. Project-relative routes, explicit override precedence,
source identity, maintenance, Todo/handoff, JSON type and failed-write protections
remain intact. Historical outbox disposition and independent data/history
recovery retain their separate qualifications; authorized reactivation and
remaining App writer callers still need their original acceptance. This stage
does not qualify supported old-writer retirement,
release default or historical support cutoff.
qualification runs the complete `serve_chat`
entrypoint with all four modules physically absent. The packaged Goal settings
journey performs backup/preview, reload and original-operation readback before
explicit confirmation. Full current and archived requirements survive import;
subsequent reviewed App creation, editing and completion work with the explicitly
selected `soft_claim` policy. Restart/replay preserves those later writes.
Missing selected storage and missing `hard_lease` execution proof still refuse
without a Markdown fallback. App reads use the existing readback owner, and
shared input transport reaches the existing typed mutation owners. Reviewed
canonical Todo creates use request v2 to bind the preview's provider
revision to the existing command identity and transaction CAS. Concurrent
changes reject the stale request; original-operation recovery runs first and
retains subsequent writes. Unreviewed v0/v1 callers keep their existing intent
and receipt identity. Import and original-receipt recovery refresh current
ownership independently on the same settings screen, including retryable read
failures; a saved receipt does not substitute for today's policy.
Supported old APIs reexport the same functions; their real Markdown writers and bootstrap
operations remain for actual callers. This qualifies the bounded import/loading
journey, not retirement of every writer, full-state identity adoption, D2 or a
released SQLite default.

已审核的 canonical Todo 创建使用请求 v2,将预览的 provider revision 绑定到既有
命令身份和事务 CAS;并发修改会拒绝过期请求。原操作恢复先于当前 head 校验,保留
之后的新写入;未审核的 v0/v1 调用保留原有意图和回执身份。导入或原回执恢复后,
同一设置页面独立回读当前所有权,读取失败时可重试;保存的回执不代替当前策略。
Source checks accept registered runtime directory aliases only when both paths
resolve to the same physical directory. Retargeting an alias after preview still
rejects before the import fence; original source observations are not rewritten.
Expand Down
27 changes: 6 additions & 21 deletions loopx/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from __future__ import annotations

import re
from uuid import uuid4
from pathlib import Path

from .capabilities.machine_configuration.goal_storage import new_goal_storage_target, initialize_goal_storage_target
from .control_plane.projects.identity import (
GoalCreationConflictError as GoalCreationConflictError,
slugify_goal_id as slugify_goal_id,
default_goal_id as default_goal_id,
derive_goal_display_name as derive_goal_display_name,
)
from .registry import find_registry_goal
from .control_plane.coordination.legacy_writer_fence import legacy_todo_write_transaction, require_legacy_state_replacement_allowed
from .control_plane.coordination.legacy_writer_fence import require_registry_source_write_allowed
Expand All @@ -16,7 +21,6 @@
from typing import Any

from .control_plane.runtime.time import now_local_iso
from .control_plane.runtime.public_safety import public_safe_compact_text
from .control_plane.runtime.document_io import atomic_write_state_text
from .control_plane.todos.active_state_editing import (
TODO_SECTION_HEADINGS,
Expand Down Expand Up @@ -56,25 +60,6 @@
DEFAULT_NEXT_ACTION = "Initial routing is owned by the connected domain adapter."


class GoalCreationConflictError(ValueError):
"""A create-only bootstrap cannot adopt another registry operation."""


def slugify_goal_id(value: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.lower()).strip("-")
return slug or "project-goal"


def default_goal_id(project: Path) -> str:
return f"{slugify_goal_id(project.name)}-goal"


def derive_goal_display_name(goal_text: str | None) -> str | None:
"""Derive a public-safe display title from user-supplied goal text."""

return public_safe_compact_text(goal_text, limit=132)


def now_iso() -> str:
return now_local_iso()

Expand Down
2 changes: 1 addition & 1 deletion loopx/bootstrap_command_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from typing import Any

from .agent_registry import registered_agent_ids_for_goal
from .bootstrap import default_goal_id
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,
Expand Down
2 changes: 1 addition & 1 deletion loopx/capabilities/manager_context/goal_notice.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def current_notice_revision(
*, registry_path: Path, runtime_root: Path, goal_id: str,
facts: Mapping[str, Any], scope_valid: Callable[[], bool],
) -> str:
from ...todos import list_goal_todos
from ...control_plane.todos.list_readback import list_goal_todos
from ...control_plane.quota.blocked_transition_notice import build_blocked_transition_notice
from ...control_plane.effect_runtime import effect_runtime_result
from .goal_attention import notice_request_fields
Expand Down
62 changes: 47 additions & 15 deletions loopx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from typing import Any, Iterable, Mapping
from urllib.parse import unquote

from .todos import add_goal_todo
from .control_plane.todos.mutation_api import add_goal_todo
from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable
from .public_safe_text import LOCAL_PATH_SURFACE_PATTERN
from .control_plane.work_items.governed_transition_proposal import (
STEWARD_TEAM_PLAN_PREVIEW_KIND,
Expand Down Expand Up @@ -661,6 +662,10 @@ def _normalize_todo_text(text: str) -> str:


def _todo_revision(payload: dict[str, Any]) -> str | None:
# Canonical dry-runs return their provider head; the compatibility writer
# retains its existing document revision witness.
if payload.get("decision_read_from_provider") is True:
return str(payload["provider_revision"])
correctness = payload.get("local_state_write_correctness")
if not isinstance(correctness, dict):
return None
Expand All @@ -675,13 +680,15 @@ def _todo_revision(payload: dict[str, Any]) -> str | None:


def _compact_todo_payload(payload: dict[str, Any], *, applied: bool) -> dict[str, Any]:
canonical = payload.get("todo")
record = canonical if isinstance(canonical, dict) else {}
todo = {
"goal_id": str(payload.get("goal_id") or ""),
"todo_id": str(payload.get("todo_id") or ""),
"text": str(payload.get("todo") or ""),
"status": str(payload.get("status") or "open"),
"task_class": str(payload.get("task_class") or "advancement_task"),
"action_kind": str(payload.get("action_kind") or CHAT_TODO_ACTION_KIND),
"text": str(record.get("text") if record else payload.get("todo") or ""),
"status": str(record.get("status") if record else payload.get("status") or "open"),
"task_class": str(record.get("task_class") or payload.get("task_class") or "advancement_task"),
"action_kind": str(record.get("action_kind") or payload.get("action_kind") or CHAT_TODO_ACTION_KIND),
}
return {
"ok": bool(payload.get("ok")),
Expand All @@ -696,6 +703,10 @@ def _compact_todo_payload(payload: dict[str, Any], *, applied: bool) -> dict[str

def _todo_preview_fingerprint(payload: dict[str, Any]) -> str:
compact = _compact_todo_payload(payload, applied=False)
if payload.get("decision_read_from_provider") is True:
# A create dry-run allocates a proposed operation identity without a
# write. Its random Todo id is not the reviewed intent or source head.
compact["todo"].pop("todo_id")
return _stable_digest(
{
"schema_version": CHAT_TODO_PREVIEW_SCHEMA_VERSION,
Expand Down Expand Up @@ -734,15 +745,18 @@ def _todo_no_write_receipt(
*,
goal_id: str,
current_preview: dict[str, Any],
write_attempted: bool = False,
state_revision: str | None = None,
) -> dict[str, Any]:
receipt = {
"schema_version": CHAT_TODO_NO_WRITE_RECEIPT_SCHEMA_VERSION,
"goal_id": goal_id,
"status": "not_applied",
"outcome": "preview_stale",
"write_attempted": False,
"current_preview_id": _todo_preview_fingerprint(current_preview),
"state_revision": _todo_revision(current_preview),
"write_attempted": write_attempted,
**({"current_preview_id": _todo_preview_fingerprint(current_preview)}
if not write_attempted else {}),
"state_revision": state_revision or _todo_revision(current_preview),
}
receipt["receipt_id"] = _stable_digest(receipt)
return receipt
Expand All @@ -755,6 +769,7 @@ def _add_review_todo(
text: str,
priority: str | None = None,
dry_run: bool,
expected_provider_revision: str | None = None,
) -> dict[str, Any]:
return add_goal_todo(
registry_path=registry_path,
Expand All @@ -765,6 +780,8 @@ def _add_review_todo(
task_class="advancement_task",
action_kind=CHAT_TODO_ACTION_KIND,
dry_run=dry_run,
**({"expected_provider_revision": expected_provider_revision}
if expected_provider_revision is not None else {}),
)


Expand Down Expand Up @@ -810,13 +827,28 @@ def apply_todo_review_preview(
current_preview=current_preview,
),
)
applied = _add_review_todo(
registry_path=registry_path,
goal_id=goal_id,
text=text,
priority=priority,
dry_run=False,
)
try:
applied = _add_review_todo(
registry_path=registry_path,
goal_id=goal_id,
text=text,
priority=priority,
dry_run=False,
**({"expected_provider_revision": _todo_revision(current_preview)}
if current_preview.get("decision_read_from_provider") is True else {}),
)
except LocalCoordinationAuthorityUnavailable as exc:
if exc.code != "provider_revision_mismatch":
raise
# Only a conclusive owner rejection proves no write. Unknown commit
# responses keep their existing original-operation recovery contract.
receipt = _todo_no_write_receipt(goal_id=goal_id,
current_preview=current_preview, write_attempted=True,
state_revision=exc.payload.get("current_provider_revision"))
raise TodoReviewPreviewConflict(
"stale todo preview; preview the proposal again before applying",
receipt=receipt,
) from exc
compact = _compact_todo_payload(applied, applied=True)
compact["receipt"] = _todo_write_receipt(
preview_id=preview_id,
Expand Down
Loading
Loading