diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.md index 2d11d2fb96..30cc1b3365 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.md @@ -246,6 +246,36 @@ envelope. Explicit sources remain additive and immutable; overflow fails with guidance rather than dropping provenance or raising the limit. A new declaration cannot use the review path to evade provenance, ownership or check authority. +Declarations and current evidence reviews have independent 128 KiB UTF-8 JSON +budgets in the same canonical snapshot. The declaration budget includes Goal +identity, original objectives, requirements, work links and every source; it +excludes only the optional `review` fields. The review budget counts their exact +additional serialized bytes. This explicitly raises the combined outcome-state +ceiling from 128 to 256 KiB; it is a resource tradeoff, not a historical budget +pass. Per-field/count limits, operation history and provider transport limits +remain unchanged. Neither envelope can borrow from the other. + +The previous shared cap admitted a 131,000-byte declaration but prevented its +next complete evidence review. Keeping that cap would require rejecting valid +progress or moving retained records into a new reference/hydration protocol. +The bounded extra envelope keeps existing synchronous consumers, one atomic +snapshot, receipts and recovery intact. No compression, external authority, +provenance pruning or migration is introduced. Oversized reviews fail before +commit, retain the prior snapshot and direct detailed evidence to its owning +material. Existing stored states remain readable by older versions, but an +older writer may reject further refinement above its combined cap; rollback +must not delete declarations or historical receipts to make them fit. + +The synthetic 20-outcome/100-requirement case grows from 131,000 to 131,753 +stored bytes after review; its full outcome projection is 154,503 bytes. On the +same already-admissible 19,454-byte input, base and candidate produce identical +state/projection. An alternating 100-sample owner-refinement plus projection +measurement gives base/candidate p50 1.025/1.034 ms and p95 1.387/1.417 ms. This +is a local pure-owner cost observation, not a CLI/provider SLO or long-horizon +benefit. Real File/SQLite CLI and native conversation tests cover the crowded +case; File/SQLite/PostgreSQL authority tests cover restart, replay, stale bases +and atomic overflow rejection. No installed-state expansion is claimed here. + The existing admitted-work context carries the complete outcome declaration; when inline content is present, no duplicate acceptance inspection is required. Changes can accompany ordinary `refresh-state --goal-outcome-update-json`, or one diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md index 21434e3b35..8af3927c66 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md @@ -174,6 +174,27 @@ active 含义;显式非活动状态仍拒绝细化。 重试信封。显式来源保持追加和不可改写;超限返回修复指导,不丢来源、不提高 上限。新增声明不能借自查路径绕过来源、归属或验收权限。 +声明和当前证据自查在同一 canonical 快照内各有 128 KiB UTF-8 JSON 预算。 +声明预算包含 Goal 身份、原始目标、要求、工作关联及全部来源,只排除可选的 +`review` 字段;自查预算按这些字段增加的实际序列化字节计算。这明确将成果状态 +合计上限从 128 提高到 256 KiB,属于资源取舍,不能把历史超限改称通过。 +各字段/数量上限、操作历史及 provider 传输限制不变,两项预算不能相互借用。 + +原共享上限允许保存 131,000 字节的声明,却阻止其下一次完整证据自查。 +保留该上限意味着拒绝有效进展,或把保留记录移入新的引用/加载协议。 +增加有界自查预算保留既有同步消费者、单一原子快照、回执和恢复;不引入压缩、 +外部权威、来源裁剪或迁移。过大自查在提交前失败,保留原快照,并提示将详细 +证据留在所属材料。旧版本仍能读取这些状态,但旧写入器可能因合计超限拒绝 +后续细化;回滚不能删除声明或历史回执来迁就旧上限。 + +合成的 20 项成果/100 项要求场景在自查后由 131,000 增至 131,753 存储字节, +完整成果投影为 154,503 字节。对原本可准入的同一份 19,454 字节输入,base 与 +候选的状态及投影相同。交替采集 100 次 owner 细化加投影,base/候选 p50 为 +1.025/1.034 ms,p95 为 1.387/1.417 ms。这只是本机纯 owner 成本观察, +不代表 CLI/provider SLO 或长程收益。真实 File/SQLite CLI 和原生会话测试 +覆盖容量临界场景;File/SQLite/PostgreSQL authority 测试覆盖重启、重放、 +旧基线及超限原子拒绝。此处不声称已扩展安装态容量。 + 既有准入工作上下文携带完整成果声明;内联内容完整时,无需额外查询验收。 变化可合并到普通 `refresh-state --goal-outcome-update-json`,或一次原生 `refine_outcome` 调用。无变化不额外调用、不增加往返,也不重复登记自查。 diff --git a/loopx/control_plane/goals/outcome_requirements.ts b/loopx/control_plane/goals/outcome_requirements.ts index c15ee767ad..0045165335 100644 --- a/loopx/control_plane/goals/outcome_requirements.ts +++ b/loopx/control_plane/goals/outcome_requirements.ts @@ -173,7 +173,14 @@ export function refineGoalOutcome(head: JsonObject, goalId: string, goalRef: Wir } const state: OutcomeState = {schema_version: GOAL_OUTCOMES_SCHEMA, goal_id: goalId, goal_ref: goalRef, revision: (previous?.revision ?? 0) + 1, outcomes: [...(previous?.outcomes.filter(o => o.id !== outcomeId) ?? []), outcome]}; - requireValue(Buffer.byteLength(JSON.stringify(state), "utf8") <= 131072, "outcome declaration capacity exceeded; retain source detail in its owning material"); + // A retained declaration must leave room for its next evidence review. + // Keep both envelopes bounded without changing the single stored snapshot, + // provenance, receipt history or the projection consumed by existing callers. + const declaration = {...state, outcomes: state.outcomes.map(({review: _review, ...outcome}) => outcome)}; + const declarationBytes = Buffer.byteLength(JSON.stringify(declaration), "utf8"); + requireValue(declarationBytes <= 131072, "outcome declaration capacity exceeded; retain source detail in its owning material"); + requireValue(Buffer.byteLength(JSON.stringify(state), "utf8") - declarationBytes <= 131072, + "outcome review capacity exceeded; retain detailed evidence in its owning material"); return readState({...head, goal_outcomes: state}, goalId)!; } diff --git a/tests/control_plane/test_goal_outcome_capacity.py b/tests/control_plane/test_goal_outcome_capacity.py new file mode 100644 index 0000000000..01605fc38a --- /dev/null +++ b/tests/control_plane/test_goal_outcome_capacity.py @@ -0,0 +1,95 @@ +"""Full declarations remain reviewable through real CLI and conversation paths.""" +import json +from pathlib import Path +import subprocess + +from test_goal_acceptance_cli import acceptance_goal as acceptance_goal +from test_goal_acceptance_prepared_action import conversation_check as conversation_check + + +def seed_crowded(project, actor): + """Synthetic disposable authority only; exercise production readers/writers.""" + state = {"schema_version": "loopx_goal_outcomes_v0", "goal_id": "goal-acceptance", + "goal_ref": None, "revision": 1, "outcomes": [ + {"id": f"result-{i}", "owner_agent_id": actor, "objective": f"Deliver result {i}", + "revision": 1, "todo_ids": [], + "requirements": [{"id": f"r{r}", "description": f"Preserve requirement {r}"} for r in range(5)], + "sources": [{"ref": f"origin-{i}", "summary": "Owner requested this result"}]} for i in range(20)]} + def size(): + return len(json.dumps(state, ensure_ascii=False, separators=(",", ":")).encode()) + i = 0 + while size() < 130400: + state["outcomes"][i % 20]["sources"].append({"ref": f"source-{i}", "summary": "x" * 500}) + i += 1 + source = {"ref": "boundary", "summary": "x"} + state["outcomes"][0]["sources"].append(source) + available = 131000 - size() + assert 0 <= available < 512 + source["summary"] += "x" * available + assert size() == 131000 + repo = Path(__file__).resolve().parents[2] + module = repo / "loopx/control_plane/coordination/local_authority_provider.ts" + script = f"import {{openLocalAuthorityStore}} from {json.dumps(module.as_uri())};" + """ +let input = ''; for await (const chunk of process.stdin) input += chunk; +const {root, state} = JSON.parse(input); +const store = await openLocalAuthorityStore(root, state.goal_id); +const prior = await store.loadAuthority(); +if (prior.status !== 'loaded') throw new Error('fixture authority missing'); +const result = await store.commitAuthority({operation_id: 'seed-capacity-fixture', + expected_provider_revision: prior.provider_revision, events: [], receipts: [], + next_projection: {...prior.head, goal_outcomes: state}}); +if (result.status !== 'applied') throw new Error('fixture commit failed'); +""" + subprocess.run(["node", "--no-warnings", "--experimental-sqlite", "--experimental-strip-types", + "--input-type=module", "-e", script], input=json.dumps({"root": str(project.parent / "runtime"), "state": state}), + text=True, capture_output=True, check=True, timeout=45) + return {row["id"]: row["sources"] for row in state["outcomes"]} + + +def review_document(): + return {"outcome_id": "result-19", "review": [{"id": f"r{i}", "conclusion": "uncertain", + "evidence_refs": ["artifact:inspection"], "note": "Independent verification remains pending."} for i in range(5)]} + + +def assert_reviewed(result, origins): + rows = result["goal_acceptance_contract"]["outcome_requirements"]["outcomes"] + assert {row["id"]: row["sources"] for row in rows} == origins + reviewed = next(row for row in rows if row["id"] == "result-19") + assert all(r["agent_review"]["conclusion"] == "uncertain" and not r["planning_satisfied"] for r in reviewed["requirements"]) + assert all(r["status"] == "unverified" for r in reviewed["requirements"]) + + +def test_crowded_cli_review_preserves_snapshot_and_retry(acceptance_goal): + project, document, cli, _ = acceptance_goal + origins = seed_crowded(project, "agent-a") + before = cli("inspect")[1] + document.write_text(json.dumps(review_document())) + args = ("refine", "--agent-id", "agent-a", "--document", str(document), "--operation-id", "review-capacity", + "--expected-provider-revision", before["provider_revision"], "--execute") + code, result = cli(*args) + assert code == 0, result + assert_reviewed(result, origins) + current = cli("inspect")[1] + assert_reviewed(current, origins) + assert cli(*args)[1]["status"] == "replayed" + assert cli("inspect")[1] == current + + +def test_crowded_native_review_uses_same_owner_and_preserves_stop(conversation_check): + project, cli, controller, sid, _, _, adapter = conversation_check + from loopx.chat_loopx_mode import TOOL + origins = seed_crowded(project, "coordinator") + handler = adapter.session.read_tool_handler + before = handler(TOOL["name"], {"action": "outcomes"}) + request = {"action": "refine_outcome", "operation_id": "review-capacity-native", + "expected_provider_revision": before["provider_revision"], "document": review_document()} + result = handler(TOOL["name"], request) + assert result["ok"], result + assert_reviewed(result, origins) + assert handler(TOOL["name"], request)["status"] == "replayed" + current = cli("inspect")[1] + assert_reviewed(current, origins) + mode = controller.store.load_session(sid)["loopx_mode"] + controller.store.update_session(sid, loopx_mode={**mode, "paused": True}) + assert handler(TOOL["name"], request)["error"] == "conversation_execution_inactive" + assert cli("inspect")[1] == current diff --git a/tests/control_plane_ts/goal_outcome_capacity.test.ts b/tests/control_plane_ts/goal_outcome_capacity.test.ts new file mode 100644 index 0000000000..7091809b0d --- /dev/null +++ b/tests/control_plane_ts/goal_outcome_capacity.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {mkdtemp, rm} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; +import {Pool} from "pg"; +import {randomUUID} from "node:crypto"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; +import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; +import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; +import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; +import {refineGoalOutcome} from "../../loopx/control_plane/goals/outcome_requirements.ts"; +import {inspectGoalAcceptance, refineGoalAcceptanceOutcome} from "../../loopx/control_plane/goals/acceptance_authority.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; + +const goal = "outcome-capacity"; +const bytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value), "utf8"); +function crowded() { + const state = {schema_version: "loopx_goal_outcomes_v0", goal_id: goal, goal_ref: null, revision: 1, + outcomes: Array.from({length: 20}, (_, i) => ({id: `outcome-${i}`, owner_agent_id: "author", objective: `Deliver result ${i}`, + revision: 1, requirements: Array.from({length: 5}, (_, r) => ({id: `r${r}`, description: `Retain requirement ${r}`})), + todo_ids: [], sources: [{ref: `origin-${i}`, summary: `Owner requested result ${i}`}]}))}; + for (let i = 0; bytes(state) < 130400; i++) { + state.outcomes[i % 20].sources.push({ref: `source-${i}`, summary: `Retained origin ${i}: ${"x".repeat(480)}`}); + } + // Leave less room than even one complete review, while keeping the legacy + // declaration valid and every source's independently bounded original text. + const source = {ref: "boundary", summary: "x"}; + state.outcomes[0].sources.push(source); + const available = 131000 - bytes(state); + assert.ok(available >= 0 && available < 512); + source.summary += "x".repeat(available); + assert.equal(bytes(state), 131000); + return authorityProjectionFixture(goal, [], [], "native", {goal_outcomes: state}); +} +const review = {outcome_id: "outcome-19", review: Array.from({length: 5}, (_, i) => ({id: `r${i}`, + conclusion: "uncertain", evidence_refs: ["artifact:current-check"], note: "Independent acceptance is still pending."}))}; + +test("a full retained declaration can receive a complete review without losing origins", () => { + const head = crowded(), original = structuredClone(head); + const state = refineGoalOutcome(head, goal, null, "author", review); + assert.ok(bytes(state) > 131072, "reproduces the previous combined-cap rejection"); + assert.ok(bytes(state) <= 262144, "declarations and reviews remain bounded together"); + const before = (original.goal_outcomes as JsonObject).outcomes as JsonObject[]; + for (const item of state.outcomes) { + const prior = before.find(row => row.id === item.id)!; + for (const field of ["objective", "owner_agent_id", "requirements", "todo_ids", "sources"]) assert.deepEqual(item[field], prior[field]); + } + assert.deepEqual(state.outcomes.find(o => o.id === review.outcome_id)!.review!.items, review.review); + assert.deepEqual(head, original, "pure refinement never mutates its input"); + assert.throws(() => refineGoalOutcome({...head, goal_outcomes: state}, goal, null, "author", { + outcome_id: "outcome-0", source: {ref: "overflow", summary: "x".repeat(512)}, + }), /declaration capacity exceeded/, "review allowance cannot expand declaration capacity"); +}); + +test("review capacity measures UTF-8 bytes across all retained reviews", () => { + let head = authorityProjectionFixture(goal, [], [], "native"); + const fullReview = Array.from({length: 32}, (_, i) => ({id: `r${i}`, conclusion: "uncertain", evidence_refs: [], note: "界".repeat(512)})); + for (let i = 0; i < 3; i++) { + head = {...head, goal_outcomes: refineGoalOutcome(head, goal, null, "author", { + outcome_id: `outcome-${i}`, owner_agent_id: "author", objective: `Deliver result ${i}`, + requirements: fullReview.map(({id}) => ({id, description: `Keep ${id}`})), source: {ref: `origin-${i}`, summary: "Original request"}, + })}; + } + for (let i = 0; i < 2; i++) head = {...head, goal_outcomes: refineGoalOutcome(head, goal, null, "author", {outcome_id: `outcome-${i}`, review: fullReview})}; + const before = structuredClone(head); + assert.throws(() => refineGoalOutcome(head, goal, null, "author", {outcome_id: "outcome-2", review: fullReview}), /review capacity exceeded/); + assert.deepEqual(head, before); + // Replacing current evidence retains the original declarations and journal + // semantics; the current-review budget is not an append-only history budget. + const small = fullReview.map(row => ({...row, note: "Uncertain"})); + head = {...head, goal_outcomes: refineGoalOutcome(head, goal, null, "author", {outcome_id: "outcome-0", review: small})}; + assert.equal(refineGoalOutcome(head, goal, null, "author", {outcome_id: "outcome-2", review: fullReview}).outcomes.length, 3); +}); + +for (const provider of ["file", "sqlite", "postgresql"] as const) test(`${provider}: crowded review survives restart and replay; overflow is atomic`, + {skip: provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL}, async t => { + const root = await mkdtemp(join(tmpdir(), "outcome-capacity-")); t.after(() => rm(root, {recursive: true, force: true})); + const pool = provider === "postgresql" ? new Pool({connectionString: process.env.LOOPX_TEST_POSTGRES_URL, max: 2}) : null; + if (pool) t.after(() => pool.end()); + const database = pool ? {connect: async () => { + const client = await pool.connect(); + return {query: async (sql: string, values?: readonly unknown[]) => client.query(sql, values ? [...values] : undefined), + release: () => client.release()}; + }} : null; + if (database) await installPostgreSqlAuthorityStoreSchema(database, `postgresql:${"b".repeat(32)}`); + const scope = {tenant_id: `outcome-capacity-${randomUUID()}`, goal_id: goal}; + const open = () => database ? new PostgreSqlAuthorityStore(database, scope) + : provider === "file" ? new FileAuthorityStore(root, goal) : new SqliteAuthorityStore(root, goal); + const store = open(), head = crowded(); + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, next_projection: head, events: [], receipts: []}); + const prior = await inspectGoalAcceptance(store, goal); + const request = {goal_id: goal, actor_agent_id: "author", operation_id: "review", expected_provider_revision: prior.provider_revision, refinement: review}; + const preview = await refineGoalAcceptanceOutcome(store, {...request, dry_run: true}); + assert.equal(preview.status, "planned"); + assert.deepEqual(await inspectGoalAcceptance(store, goal), prior); + assert.equal((await refineGoalAcceptanceOutcome(store, request)).status, "applied"); + const after = await inspectGoalAcceptance(open(), goal); + const outcomes = (after.goal_acceptance_contract as JsonObject).outcome_requirements as JsonObject; + const rows = outcomes.outcomes as JsonObject[]; + assert.equal(rows.length, 20); + const current = rows.find(row => row.id === review.outcome_id)!; + assert.ok((current.requirements as JsonObject[]).every(r => r.status === "unverified" && r.planning_satisfied === false)); + assert.equal((await refineGoalAcceptanceOutcome(open(), request)).status, "replayed"); + assert.deepEqual(await inspectGoalAcceptance(open(), goal), after); + assert.equal((await refineGoalAcceptanceOutcome(store, {...request, operation_id: "stale"})).status, "conflict"); + await assert.rejects(() => refineGoalAcceptanceOutcome(store, {...request, operation_id: "overflow", expected_provider_revision: after.provider_revision, + refinement: {outcome_id: "outcome-0", source: {ref: "overflow", summary: "x".repeat(512)}}}), /declaration capacity exceeded/); + assert.deepEqual(await inspectGoalAcceptance(open(), goal), after); + const loaded = await open().loadAuthority(); assert.equal(loaded.status, "loaded"); + if (loaded.status === "loaded") { + const original = (head.goal_outcomes as JsonObject).outcomes as JsonObject[]; + for (const row of (loaded.head.goal_outcomes as JsonObject).outcomes as JsonObject[]) { + assert.deepEqual(row.sources, original.find(o => o.id === row.id)!.sources); + } + } +});