diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-desktop.png b/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-desktop.png new file mode 100644 index 0000000000..1f76f6623d Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-narrow.png b/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-narrow.png new file mode 100644 index 0000000000..8c89a1f34c Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/after-basis-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-desktop.png b/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-desktop.png new file mode 100644 index 0000000000..5d440d6727 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-narrow.png b/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-narrow.png new file mode 100644 index 0000000000..2a901e60f3 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/after-missing-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/after-team-desktop.png b/apps/presentation/dashboard/smoke/assets/delegation-request/after-team-desktop.png new file mode 100644 index 0000000000..cdbf381fe2 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/after-team-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-desktop.png b/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-desktop.png new file mode 100644 index 0000000000..29dfa2b93a Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-narrow.png b/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-narrow.png new file mode 100644 index 0000000000..22dd389a12 Binary files /dev/null and b/apps/presentation/dashboard/smoke/assets/delegation-request/before-basis-narrow.png differ diff --git a/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx b/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx index ba59ed776c..8d22aeb353 100644 --- a/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx +++ b/apps/presentation/dashboard/smoke/delegation-preflight-smoke.tsx @@ -36,6 +36,24 @@ for (const [zh, expectedLabel, expectedBoundary] of [ } if (Object.values(unavailable.effects).some(Boolean)) throw new Error("authority-unavailable response must report zero effects"); +// A configured request binding can be inspected before a source is selected. +// It must remain readable without inventing a Todo or claiming a launch. +for (const zh of [true, false]) { + const request: DelegationPreflight = {...unavailable, binding: {id: "request", agent_id: "receiver", todo_id: null}, + state: "turn_blocked", request_required: true, authority_ready: true, authority_reason: null, + authority_state: "promoted", authority_next_action: "none"}; + const html = renderToStaticMarkup(); + if (!html.includes(zh ? "等待原请求" : "Awaiting original request")) throw new Error("missing original request state"); + if (/Todo|任务验收|启动条件已满足|prerequisites met/.test(html)) throw new Error("request inspection invented task readiness"); + const missing: DelegationPreflight = {...request, request_required: false, state: "acceptance_unavailable", + acceptance_reason_code: "independent_delegation_validation_required", + acceptance_next_action: "review_original_request_validation"}; + const refused = renderToStaticMarkup(); + if (!refused.includes(zh ? "请求验收不可用" : "Request validation unavailable")) throw new Error("missing request validation state"); + if (!refused.includes(zh ? "请求独立验收声明" : "independent request validation declaration")) throw new Error("wrong request repair owner"); + if (/Todo|任务验收/.test(refused)) throw new Error("request repair requires an unrelated Todo"); +} + for (const workspace_state of ["missing", "not_directory", "unavailable"] as const) { const check: DelegationPreflight = {...unavailable, state: "workspace_unavailable", workspace_state, workspace_next_action: "review_operator_workspace_binding", diff --git a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs index efd4249fa8..58305c462f 100644 --- a/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs +++ b/apps/presentation/dashboard/smoke/team-evidence-return-smoke.mjs @@ -64,7 +64,8 @@ try { if (body?.operation !== "read" || body.operation_id !== "accepted-analysis") return route.fallback(); api.loopxModeRequests.push({sessionId: configured.sessionId, ...body}); return checkUnavailable ? route.fulfill({status: 409, json: {error: "delegation output changed during validation"}}) - : route.fulfill({json: {...legacyReadback, validation}}); + : route.fulfill({json: {...legacyReadback, + ...(validation.source === "request_validation" ? {todo_id: null} : {}), validation}}); }; await page.route("**/api/chat/sessions/*/loopx", checkedRead); const recheck = evidence.getByRole("button", {name: "重新检查交付", exact: true}); @@ -90,6 +91,13 @@ try { await checkObservation.getByText(outputVersions[1].sha256, {exact: true}).scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "team-checked-output-mobile.png"), animations: "disabled"}); await page.setViewportSize({width: 1512, height: 980}); + validation = {...currentValidation, source: "request_validation", pinned_file_count: 0}; + await recheck.click(); + await original().waitFor(); + await evidence.getByText("本次验收依据", {exact: true}).click(); + await checkObservation.getByText("请求独立验收规则", {exact: false}).waitFor(); + assert.doesNotMatch(await checkObservation.innerText(), /任务验收规则|Task validation rules/, + "An independently validated original request must not claim a Todo validation basis"); // Another version's witness must never appear as a check of this report. validation = {...validation, output_versions: [{ref: "report.json", sha256: "f".repeat(64)}, outputVersions[1]]}; await recheck.click(); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d935855914..a033103fa1 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1386,8 +1386,8 @@ export type LoopXModeSnapshot = { settings: Partial & { execution_config?: string }; native: { status: string; tokenBudget?: number; tokensUsed?: number }; registered_agents: string[]; paused: boolean; recovery_required: boolean; - members: Array<{id: string; agent_id: string; todo_id: string}>; - deliveries: Array<{operation_id: string; agent_id: string; todo_id: string; status: string}>; + members: Array<{id: string; agent_id: string; todo_id: string | null}>; + deliveries: Array<{operation_id: string; agent_id: string; todo_id: string | null; status: string}>; ingress: Array<{client_ingress_id: string; mode: string; status: string}>; turn_id?: string; }; @@ -1395,7 +1395,7 @@ export function fetchLoopXMode(sessionId: string) { return requestJson(`/api/chat/sessions/${sessionId}/loopx`); } export type DelegationInventory = { - items: Array<{record_id: string; operation_id: string | null; agent_id?: string; todo_id?: string; + items: Array<{record_id: string; operation_id: string | null; agent_id?: string; todo_id?: string | null; status: string; worker_active?: boolean; recovery_required: boolean | null; artifacts?: Array<{ref: string; sha256: string}>; current_use?: DelegationReadback["current_use"]}>; has_more: boolean; next_cursor: string | null; page_readback_complete: boolean; @@ -1407,15 +1407,15 @@ export type DelegationDependency = { }; export type DelegationAdoption = { requester_agent_id: string; consumer_operation_id: string; consumer_request_id: string; - consumer_agent_id: string; consumer_todo_id: string; state: "current" | "unavailable"; + consumer_agent_id: string; consumer_todo_id: string | null; state: "current" | "unavailable"; source_artifacts: Array<{ref: string; sha256: string}>; consumer_artifacts: Array<{ref: string; sha256: string}>; }; export type DelegationReadback = { - operation_id: string; request_id: string; agent_id: string; todo_id: string; + operation_id: string; request_id: string; agent_id: string; todo_id: string | null; status: string; worker_active: boolean; recovery_required: boolean; artifacts?: Array<{ref: string; sha256: string; text: string}>; error?: string; - validation?: {source: "goal_acceptance" | "todo_validation"; basis_sha256: string; + validation?: {source: "goal_acceptance" | "todo_validation" | "request_validation"; basis_sha256: string; check_count: number; pinned_file_count: number; checked_at?: string; output_versions?: Array<{ref: string; sha256: string}>; oracle_checks?: Array}; task_failure?: {stage: "task_postcondition"; status: "failed" | "inconclusive" | "unavailable"; diff --git a/apps/presentation/dashboard/src/data/delegation-preflight.ts b/apps/presentation/dashboard/src/data/delegation-preflight.ts index 657f223470..de12d22bf0 100644 --- a/apps/presentation/dashboard/src/data/delegation-preflight.ts +++ b/apps/presentation/dashboard/src/data/delegation-preflight.ts @@ -1,4 +1,6 @@ export type DelegationPreflight = { + binding?: {id: string; agent_id: string; todo_id: string | null}; + request_required?: boolean; state: "workspace_unavailable" | "authority_unavailable" | "turn_blocked" | "acceptance_unavailable" | "runtime_unavailable" | "runtime_unverified" | "launchable"; workspace_state?: "missing" | "not_directory" | "unavailable"; workspace_next_action?: "review_operator_workspace_binding"; @@ -6,7 +8,7 @@ export type DelegationPreflight = { turn_lane?: {scope: "native_turn_lane"; state: string}; acceptance_ready: boolean; acceptance_reason_code?: "independent_delegation_validation_required" | "completion_validation_declaration_unavailable" | "completion_validation_declaration_mismatch" | "validation_files_unavailable" | "acceptance_binding_unavailable" | null; - acceptance_next_action?: "review_original_todo_validation" | "restore_original_validation_files" | "review_original_task_acceptance" | "none"; + acceptance_next_action?: "review_original_request_validation" | "review_original_todo_validation" | "restore_original_validation_files" | "review_original_task_acceptance" | "none"; turn_route: string | null; authority_ready: boolean | null; authority_reason: string | null; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx index faafbde1eb..88e2de8d67 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delegation-preflight-status.tsx @@ -25,6 +25,10 @@ const acceptanceDetails: Record, "none">, {en: string; zh: string}> = { + review_original_request_validation: { + en: "Next: ask the original configuration owner to review the independent request validation declaration, then recheck", + zh: "下一步:请原配置责任人核对请求独立验收声明,再重新检查", + }, review_original_todo_validation: { en: "Next: ask the original configuration owner to review the Todo validation declaration, then recheck", zh: "下一步:请原配置责任人核对任务验收声明,再重新检查", @@ -40,6 +44,13 @@ const acceptanceActions: Record - {laneLabel ?? (zh ? labels[check.state].zh : labels[check.state].en)} + {laneLabel ?? (zh ? label.zh : label.en)} {detail ? ` · ${detail}` : ""} {operationDetail ? ` · ${operationDetail}` : ""} {nextAction ? ` · ${nextAction}` : ""} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx index c6e57b509d..3c788d3d0a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -140,6 +140,7 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres {zh ? "本次验收依据" : "Current validation basis"} {result.validation ? <>

{result.validation.source === "goal_acceptance" ? (zh ? "Goal 验收规则" : "Goal acceptance rules") + : result.validation.source === "request_validation" ? (zh ? "请求独立验收规则" : "Independent request validation rules") : (zh ? "任务验收规则" : "Task validation rules")}{" · "} {result.validation.check_count} {zh ? "项检查" : "checks"}{" · "} {result.validation.pinned_file_count} {zh ? "项文件版本固定" : "file pins"}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx index 28c4ba6dd4..9d2d974c7a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-work.tsx @@ -6,7 +6,7 @@ import {fetchLoopXTeamWork, inspectLoopXMember, delegationState, delegationState import {GoalTeamEvidence} from "./goal-team-evidence"; import {DelegationPreflightStatus} from "./delegation-preflight-status"; -type Member = {id: string; agent_id: string; todo_id: string}; +type Member = {id: string; agent_id: string; todo_id: string | null}; type DelegationRecord = DelegationInventory["items"][number]; type PulseBucket = "executing" | "validating" | "accepted" | "attention" | "stopped" | "dispatched" | "unknown"; type CheckTone = "unchecked" | "ready" | "unverified" | "blocked"; diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index f0dbf0dfd6..c7208c2d44 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -173,7 +173,7 @@ How a settlement step binds to its receipt. - Tier / 层级: `cross_runtime`; status / 状态: `canonical`. - python: [`SettlementBindingKind`](../../loopx/control_plane/effect_program.py). - typescript: [`SETTLEMENT_BINDING_KINDS`](../../loopx/control_plane/effect_program.ts). -- Values / 值: `todo`, `autonomous_replan`, `unbound`. +- Values / 值: `todo`, `autonomous_replan`, `request`, `unbound`. ## settlement_failure_kind diff --git a/docs/reference/local-delegation.md b/docs/reference/local-delegation.md index 3c23ba0746..0b2ce2e6c8 100644 --- a/docs/reference/local-delegation.md +++ b/docs/reference/local-delegation.md @@ -5,82 +5,82 @@ its result after the requesting conversation disappears. The same interface is available to a coordinating member. DSH and an optional cloud provider use the existing Turn entrypoint; there is no steward-specific scheduler or task store. -Ordinary Inbox delivery without a pre-created Todo has a separate -[activation gap](#ordinary-inbox-activation-boundary). The configuration below -does not close that gap. +Ordinary Inbox delivery can use an original request as its execution subject, +under the [request activation contract](#ordinary-inbox-activation-boundary). +A saved request and an execution grant remain separate. + +Native execution remains limited to runtime-compatible project registries. +The lifecycle-only `source_session_v1` profile can prepare and inspect the exact +Goal-instance request, but its existing runtime guard rejects execution before +launching a Host. Replay retains that rejection, and recreating a Goal cannot +retarget an old request to the new instance. Request-subject support does not +enable a new registry runtime profile. + +中文:原生执行仍要求 runtime-compatible project registry。仅供生命周期操作的 +`source_session_v1` 可准备、读取准确 Goal 实例的请求,但既有 runtime guard 会在 +Host 启动前明确拒绝执行;重放保留该拒绝,Goal 重建也不会把旧请求转给新实例。 +原请求身份支持不等于启用了新的 registry runtime profile。 ## Ordinary Inbox activation boundary -**Current boundary:** saving an authorized request is supported; automatically -starting its recipient without an exact canonical Todo is not qualified by this -task-bound executor. This section records the missing integration, not a new -configuration format or execution grant. It belongs to the existing -[S3 collaboration, S4 host and S7 budget boundaries](../architecture/rfcs/loopx-overall-roadmap-v0.md#2-portfolio-streams-outcomes-gaps-and-executable-slices). - -Three existing owners explain why selecting an Agent-scoped conversation is -insufficient: - -| Boundary | Current behavior | Missing integration | -| --- | --- | --- | -| [Inbox dispatch](../../loopx/capabilities/manager_context/execution.py) and [binding selection](../../loopx/control_plane/collaboration/delegation.ts) | A selected execution binding names an exact Todo, recipient, requester and operator-owned Host configuration. A context-only delivery submits no execution. | Admit a bounded review of the saved original request under explicit existing execution authority, without asking the sender to manufacture a Todo. | -| [Turn planning](../../loopx/control_plane/turn_driver/driver.py) | Agent-scoped conversation reuse changes Session identity; Host-bound Turn lineage still requires a Todo. Even a valid quota decision with no selected Todo can fail here with `missing_turn_lineage`. | Carry the admitted request through the existing Turn owner with a truthful unavailable state until that route is implemented. Removing the lineage check alone is insufficient. | -| [Settlement identity](../../loopx/control_plane/effect_program.ts) and [source admission](../../loopx/control_plane/quota/accounting_admission.py) | An unbound observation is not a request-bound delivery/accounting contract. A Todo or replan obligation must not be invented to obtain one. | Define and validate original-request identity, once-only settlement and recovery in the existing typed owners before enabling a Host launch. | - -The decisive counterexample uses a disposable File or SQLite Goal with no Todos -and one successfully saved peer request. With an empty Next Action and available -quota, `quota should-run` can return `normal_run`, `delivery_allowed=true`, the -required Inbox read and no selected Todo. The real `turn run-once` planning path -then refuses before invoking a Host. This demonstrates a missing executable -contract; it does not prove idle presence, authorize that request's effects or -qualify automatic activation. Retain the full request and the separate admission -and refusal observations when diagnosing this case. - -Implement the remaining journey through those owners in this order: - -1. After successful durable Inbox delivery, the trusted Host offers the original - request to the existing execution owner. Pin the complete Goal instance, - recipient, source grant, request revision and original return route. Current - authorization must explicitly cover this processing; registration, delivery - permission and a discoverable conversation do not substitute for it. -2. Admit Inbox reading and independent assessment without a pre-created exact - Todo. The receiver may answer directly, defer or reject with reasons, reuse - existing work, or plan necessary work through the canonical Todo workflow. - The review admission must not silently grant repository writes, arbitrary - task execution or protected external effects. -3. Check fresh Host availability through its owning runtime and retain the - existing native single-flight fence across the preview/start race. Busy work - keeps its original queue; consume arrivals, including requests received after - an Inbox read, at the next legal processing point. Unknown or unavailable - Host state stays unavailable. Native lane liveness alone proves no App idle - state. Add neither another scheduler nor unbounded polling. -4. Persist the launch intent before spawning. Reconcile the same original - operation after restart, concurrent offers, a crash before spawn or a lost - start response. Merge wake opportunities without dropping individual - requests; ambiguous started work is not permission to execute again. Preserve - pause/stop, quota, revoked-source and replacement-Goal fences at execution - and settlement, including the no-effect refusal paths. -5. Return the actual receiver conclusion through the saved conversation route. - Validate the packaged App/CLI journey and a relevant failure/recovery case, - then qualify the installed Host separately. Expose saved, pending, - unavailable, executing, assessed and returned facts through existing caller - surfaces. Synthetic Hosts, ACKs, a valid receipt or a completed unrelated - Todo cannot establish original-message processing and result delivery. - -中文:普通 Inbox 请求可以合法保存;现有 task-bound executor 尚不能证明在未预建 -精确 Todo 时自动唤醒并处理。现有 dispatch/binding 绑定明确的 Todo 与执行 grant; -Agent 范围的会话复用也不取消 Turn 的任务 lineage;unbound 观察并非原请求的执行 -和扣额合同。真实 File/SQLite 反例中,零 Todo、有合法收件及可用额度的 Goal 可以 -得到允许运行和必读 Inbox 的 quota 决策,随后仍在原生 Turn 规划阶段被拒绝,未调用 -Host。因此不能只删除 Todo 检查、伪造 replan obligation 或隐藏创建任务来取得执行。 - -后续沿现有 typed owner 补齐原请求的准入、结算及恢复身份,再接持久投递后的可信 -Host 触发。先允许收件阅读与独立评估;直接回答、延期、拒绝、复用或规划任务均由 -接收方经规范流程决定,不能附赠写代码或外部效果权限。完整 Goal 实例、接收方、 -来源 grant、请求版本和原会话必须保留。宿主真实忙闲与启动竞争仍由现有 owner -判定;忙碌保留队列,在后续合法处理点消费新到请求,未知状态不能当空闲。复用原 -operation 覆盖并发、重启、落盘后启动前崩溃和启动回执丢失,不重复执行、扣额或 -副作用,保留停止、预算、撤权及 Goal 替换边界。最终以打包及安装态原消息实际 -处理、故障恢复和原会话结论分别验收;源码反例、合成 Host 或 ACK 不代替这些结果。 +The existing binding, Turn and settlement owners support an ordinary request +without a pre-created canonical Todo. Omit `todo_id` from the operator-owned +binding and declare `request_validation` with the existing independent command +validation fields: `validation_command` or `validation_command_argv`, +`validation_label`, and `validation_timeout_seconds`. Use the canonical declaration +shape, including a null alternative command field. The requester cannot supply +Host arguments, workspace or its own validation declaration through the brief. +An absent or malformed independent check remains unavailable. + +| Owner | Request contract | +| --- | --- | +| [Inbox/binding](../../loopx/control_plane/collaboration/delegation.ts) | Pin the original request id, current revision and digest, exact Goal instance, recipient, requester grant and operator configuration. Original Chat sources retain their original message, complete brief and return Session/Turn. | +| [Turn](../../loopx/control_plane/turn_driver/driver.py) | The signed contract carries that same request subject through capture, native Host input and journal. Agent-scoped Session reuse does not grant execution or replace the subject. | +| [Settlement](../../loopx/control_plane/effect_program.ts) | A request is a distinct binding alongside Todo and replan. Its effect identity includes the request id/revision/digest and execution-scope digest. Neither a surrogate Todo nor a planning obligation is created. | + +A normal request runs only after the existing quota owner allows ordinary +execution. Stop, pause, budget, health, true user gates and required replanning +retain precedence. Registration, a discoverable conversation, delivery permission +or a request identity grants no repository writes or protected external effects. +A context-only request still submits no execution. The receiver independently +adopts, answers, defers, rejects or reuses work; necessary new tasks use the +ordinary canonical Todo workflow. + +Chat preflight reads the original saved request under its current source grant; +a binding-only inspection cannot pretend it has selected a request. The existing +native lane and single-flight fence handle busy/unknown/unavailable state and the +preview/start race. Busy work preserves its prepared request for the existing +bounded return service's next legal processing point. No second scheduler or +Agent is added, and a released native lane does not prove App idle state. + +The original operation is persisted before launch. A restart, concurrent offer, +pre-spawn crash or lost response reconciles that operation and its exact journal; +an uncertain started effect cannot be repeated as a new request. Current binding, +source permission and Stop are rechecked before Host start and under the existing +operation/source fences around validation and settlement. Revocation after Host +work retains its artifacts and journal while withholding writeback and spend. +Restoring authority resumes that transaction without invoking the Host again. +Independent artifact validation precedes one durable writeback and one debit. +Failed validation remains failed until an explicit recheck of the original cached +result succeeds. No request settlement completes or changes an unrelated Todo. + +Return the actual conclusion through the saved conversation route. Saved, +prepared, busy, unavailable, executing, validated, accepted and returned facts +retain their separate readbacks. The source tests use real File/SQLite, CLI, +Chat dispatch, native processes, independent checks and recovery; their synthetic +Hosts and return transport do not qualify a live model, an installed App, actual +external delivery or long-horizon benefit. The existing controller/executor RFC +still owns wider contract convergence; this slice extends its existing identity +owners without introducing another controller. + +中文:普通请求可省略预建 Todo,但原 operator binding 必须声明独立 +`request_validation`;完整原消息、brief、原请求版本/摘要、执行配置摘要和原会话 +贯通既有 Inbox、签名 Turn/capture/journal 与结算 owner。请求身份不是权限,不伪造 +Todo/replan,不改变无关任务。原 Stop/暂停/预算/权限/健康及真正的门继续生效。 +忙碌保留原队列;重启、并发、启动前崩溃或丢响应沿原 operation 对账,未知效果不重跑。 +Host 后撤权保留产物和事务、阻止写回扣额;恢复授权后沿原事务继续且不重启 Host。 +独立校验失败须显式复核原结果,结论沿原对话返回。File/SQLite 原生进程与合成 +transport 的源码资格不等正式安装 App、真实模型/外部送达或长程收益验收。 ## Activate @@ -154,15 +154,17 @@ refuses fresh input before Host launch. Recovery consumes the original capture rather than replacing it with a fresh source read during settlement. Historical operations without this recorded input profile retain their original recovery; the bootstrap file remains for existing generic hosts that explicitly read it. -This task-bound input repair does not close ordinary Todo-less activation or -qualify installed App/model adoption. +The input carrier serves both Todo-bound and independently validated original +requests under the activation boundary above; it does not qualify installed +App/model adoption. 中文:新操作通过既有工作上下文和不可变 Turn 捕获交付完整 peer brief,以及 Chat 委派经认证的原始消息,不再依赖成员自行发现 `DELEGATION.json`。内部 Host locator 必须匹配原操作、接收者、Todo、工作区与 Turn,不授予权限;原材料缺失或被替换时, 在 Host 启动前拒绝新输入。恢复沿用原捕获,不在结算时改读新材料。未登记此输入 profile 的历史操作保留原恢复路径,仍由既有 generic host 使用的 bootstrap 文件 -继续支持。本切片仍绑定 Todo,不代表普通无 Todo 唤醒或正式 App/模型采用已完成。 +继续支持。该输入载体同时服务绑定 Todo 和经独立验证的原请求,执行仍遵守上述 +激活边界,不代表正式 App/模型采用已完成。 Chat retry also verifies the saved operation against the original Inbox request, complete brief, peer request, current binding and original return Session/Turn @@ -1028,15 +1030,14 @@ holder reports `turn_lane_state_unknown`. Both prevent the Chat handoff from submitting another worker and preserve its saved Inbox context. Recheck after the holder settles or observation is restored. Released/dead/absent native holders do not prove an App conversation is idle. The executing Turn still owns the -single-flight fence if another execution races the preview. This adds neither a -background retry nor ordinary Inbox activation without a bound task. +single-flight fence if another execution races the preview. This adds no separate scheduler; ordinary requests use the request-bound contract above. 中文:检查绑定时只读成员原生 Turn 通道,不抢占锁。正在执行返回 `turn_blocked` / `turn_lane_in_flight`;记录不可读或属于其他宿主时返回 `turn_lane_state_unknown`。两者均阻止 Chat 交接提交另一个 worker,已保存的 Inbox 上下文保留;当前执行结束或状态读回恢复后重新检查。原生 holder 已释放、 退出或不存在,不证明 App 会话空闲。预览之后发生竞争仍由实际 Turn 的单执行者 -锁处理。本改动不新增后台重试,也不实现无绑定任务的普通 Inbox 激活。 +锁处理;普通请求沿上述 request-bound 合同继续,不另建调度器。 `delegate inspect --binding-id independent-review` uses the same task, workspace, host, model/effort and validator arguments as an actual delegation, @@ -1710,8 +1711,8 @@ original-audience result return and domain utility remain separate acceptance. ### Continue the original operation owner -Ordinary delegation requires different requester and worker Agents and a task -completion basis. An authenticated confirmation may instead need to continue +Ordinary delegation requires different requester and worker Agents and an independent +Todo or original-request validation basis. An authenticated confirmation may instead need to continue the same Agent's original operation Session while its broader Todo stays open. Do not create another Agent or declare that Todo complete to obtain a wake. For this case, the same operator-owned execution configuration can opt in one diff --git a/loopx/capabilities/manager_context/execution.py b/loopx/capabilities/manager_context/execution.py index 990236b2b4..3e2b221b4c 100644 --- a/loopx/capabilities/manager_context/execution.py +++ b/loopx/capabilities/manager_context/execution.py @@ -106,7 +106,7 @@ def dispatch(root: Path, registry: Path, *, session: dict[str, Any], turn: dict[ # A crash before spawn leaves the original prepared operation pending. # Its recovery needs the same fresh native preflight as first dispatch; # otherwise a busy/unknown lane can strand it in a premature rejection. - preflight = service.inspect(selected) + preflight = service.inspect(selected, **({"source_request_id": receipt["request_id"]} if binding["todo_id"] is None else {})) # Match the existing start owner: an unprobed configured runtime may # attempt bounded execution, but is never presented as ready/running. # Known refusal, missing canonical acceptance or an unavailable runtime @@ -221,7 +221,7 @@ def observe() -> dict[str, Any]: if cancelled(): return False try: - preflight = service.inspect(binding["id"]) + preflight = service.inspect(binding["id"], **({"source_request_id": source_id} if binding["todo_id"] is None else {})) if preflight.get("dispatch_allowed") is True and not cancelled() and observe()["status"] == "prepared": service.resume(operation, prepared_source_request_id=source_id) except (OSError, ValueError, KeyError, TypeError, RuntimeError): diff --git a/loopx/cli_commands/turn.py b/loopx/cli_commands/turn.py index 3b4c750b0e..7a4ac7350e 100644 --- a/loopx/cli_commands/turn.py +++ b/loopx/cli_commands/turn.py @@ -100,6 +100,7 @@ def handle_turn_command( args.delegation_context_json, registry_path=registry_path, runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, todo_id=getattr(args, "todo_id", None), turn_instance_id=args.turn_instance_id, workspace=Path(args.project), + preview=not getattr(args, "execute", False), ) goal_ref = capture_first_party_host_goal_ref( registry_path=registry_path, @@ -245,6 +246,7 @@ def handle_turn_command( runtime_root_arg=runtime_root_arg, turn_start_hook_dispatch=turn_start_hook_dispatch, goal_ref=goal_ref, + request_subject=delegated_context.get("request_subject") if delegated_context else None, ) operator_inbox_urgency_projector = decision_owner.operator_inbox_urgency_projector scheduler_context = decision_owner.scheduler_execution_context @@ -258,6 +260,8 @@ def handle_turn_command( # approval/handoff contract; this option controls exec conversation reuse. session_scope = (args.session_scope if args.host in {"codex-cli", "codex-app-server"} and not getattr(args, "codex_operation_tools", False) else "todo") + if args.host in {"codex-cli", "codex-app-server"} and delegated_context and delegated_context.get("request_subject") is not None: + session_scope = "agent" if native_session_binding_resolver is not None: session_binding = native_session_binding_resolver(turn_envelope) if ( @@ -292,6 +296,11 @@ def handle_turn_command( session_scope=session_scope, goal_ref=goal_ref, ) + if delegated_context and delegated_context.get("request_subject") is not None: + import json + # Private operation metadata stays outside model input. A resumed + # Turn keeps this locator and its original captured request. + payload["delegation_operation_locator"] = json.loads(args.delegation_context_json) # Resolve machine authentication once for the readback and host launch. operator_environ = operator_provider_environ() payload["managed_executor"] = managed_executor_cli_binding(args, environ=operator_environ) diff --git a/loopx/cli_commands/turn_decision.py b/loopx/cli_commands/turn_decision.py index 4e36ed4825..c705359e56 100644 --- a/loopx/cli_commands/turn_decision.py +++ b/loopx/cli_commands/turn_decision.py @@ -139,6 +139,7 @@ def _build_turn_decision( operator_inbox_urgency_projector: Callable[..., dict[str, Any]], turn_start_hook_dispatch: Mapping[str, Any] | None = None, goal_ref: Mapping[str, object] | None = None, + request_subject: Mapping[str, Any] | None = None, ) -> Callable[..., dict[str, Any]]: """Return the ``build_turn_decision`` every Turn owner resolves through. @@ -200,6 +201,9 @@ def build_turn_decision( ), ), ) + decision = _attach_request_settlement(decision, registry_path=registry_path, + runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, + request_subject=request_subject, turn_instance_id=turn_instance_id, goal_ref=goal_ref) return _attach_original_replan_settlement( decision, registry_path=registry_path, runtime_root=runtime_root, goal_id=args.goal_id, agent_id=args.agent_id, @@ -210,6 +214,28 @@ def build_turn_decision( return build_turn_decision +def _attach_request_settlement(decision: dict[str, Any], *, registry_path: Path, + runtime_root: Path, goal_id: str, agent_id: str, request_subject, + turn_instance_id: str | None, goal_ref) -> dict[str, Any]: + """Use the collaboration owner for both initial and pre-Host request reads.""" + if request_subject is None: + return decision + decision = effect_runtime_result("collaboration.delegation.request_decision", { + "decision": decision, "request_subject": dict(request_subject), + }) + if decision.get("request_subject") is not None: + cli = decision["interaction_contract"]["cli_channel"] + cli["settlement_plan"] = build_turn_scoped_cli_settlement_plan( + goal_id=goal_id, agent_id=agent_id, request_subject=request_subject, + turn_instance_id=turn_instance_id, + command_prefix=shlex.join(["loopx", "--registry", str(registry_path), "--runtime-root", str(runtime_root)]), + scoped_cli_args="", lifecycle_actor_args="", goal_ref=goal_ref, + ).as_dict() + cli["next_cli_actions"] = [step["command_template"] for step in cli["settlement_plan"]["ordered_steps"] + if step["kind"] in ("durable_writeback", "quota_spend")] + return decision + + def apply_controller_advisory_primary( build_turn_decision: Callable[..., dict[str, Any]], ) -> dict[str, Any]: @@ -249,10 +275,13 @@ class FreshTurnDecisionOwner: operator_inbox_urgency_projector: Callable[..., dict[str, Any]] build_turn_decision: Callable[..., dict[str, Any]] requested_todo_id: str | None = None + request_subject: Mapping[str, Any] | None = None def resolve(self) -> dict[str, Any]: """Use an explicit eligible Todo, or preserve controller selection.""" + if self.request_subject is not None: + return self.build_turn_decision() if self.requested_todo_id is None: return apply_controller_advisory_primary(self.build_turn_decision) decision = self.build_turn_decision(requested_action_todo_id=self.requested_todo_id) @@ -275,6 +304,7 @@ def build_fresh_turn_decision_owner( runtime_root_arg: str | None, turn_start_hook_dispatch: Mapping[str, Any] | None = None, goal_ref: Mapping[str, object] | None = None, + request_subject: Mapping[str, Any] | None = None, ) -> FreshTurnDecisionOwner: """Read the live status and derive the shared decision inputs from it. @@ -311,9 +341,9 @@ def build_fresh_turn_decision_owner( scheduler_execution_context=scheduler_execution_context, operator_inbox_urgency_projector=operator_inbox_urgency_projector, turn_start_hook_dispatch=turn_start_hook_dispatch, - goal_ref=goal_ref, + goal_ref=goal_ref, request_subject=request_subject, ), - requested_todo_id=getattr(args, "todo_id", None), + requested_todo_id=getattr(args, "todo_id", None), request_subject=request_subject, ) @@ -352,6 +382,10 @@ def current_turn_host_launch_decision( goal_id=identity.goal_id, agent_id=identity.agent_id, ),), ) + current = _attach_request_settlement(current, registry_path=registry_path, + runtime_root=runtime_root, goal_id=identity.goal_id, agent_id=identity.agent_id, + request_subject=identity.request_subject, + turn_instance_id=identity.turn_instance_id, goal_ref=goal_ref) _attach_original_replan_settlement(current, registry_path=registry_path, runtime_root=runtime_root, goal_id=identity.goal_id, agent_id=identity.agent_id, turn_instance_id=identity.turn_instance_id, goal_ref=goal_ref, @@ -375,9 +409,9 @@ def current_turn_host_launch_decision( route = plan["route"] if route.get("would_invoke_host") is not True: raise FirstPartyHostRuntimeRejected("goal_launch_not_allowed") - if selected_turn_todo(current_envelope).get("todo_id") != identity.todo_id: + if identity.request_subject is None and selected_turn_todo(current_envelope).get("todo_id") != identity.todo_id: raise FirstPartyHostRuntimeRejected("current_turn_selection_changed") - if identity.replan_obligation_id and plan["transaction"]["settlement_plan"]["identity"] != identity.as_dict(): + if (identity.replan_obligation_id or identity.request_subject is not None) and plan["transaction"]["settlement_plan"]["identity"] != identity.as_dict(): raise FirstPartyHostRuntimeRejected("current_turn_selection_changed") return launch diff --git a/loopx/cli_commands/turn_run_once.py b/loopx/cli_commands/turn_run_once.py index 58d97c8a6c..6a7a6e6b47 100644 --- a/loopx/cli_commands/turn_run_once.py +++ b/loopx/cli_commands/turn_run_once.py @@ -125,6 +125,7 @@ def execute_turn_run_once( ), replan_obligation_id=(raw_identity.get("replan_obligation_id") if isinstance(raw_identity, Mapping) else None), + request_subject=raw_identity.get("request_subject"), ) persisted_effect_id = str(raw_identity.get("effect_id") or "") if ( @@ -498,12 +499,12 @@ def spend(*, effect_ref: str) -> dict[str, object]: execute=True, source=(quota_spend_source_for_execution_context( payload.get("scheduler_execution_context"), - ) if settlement_identity.replan_obligation_id else "adapter"), + ) if settlement_identity.replan_obligation_id or settlement_identity.request_subject is not None else "adapter"), agent_id=args.agent_id, workspace_path=delivery_workspace_path, replan_obligation_id=settlement_identity.replan_obligation_id, turn_instance_id=(settlement_identity.turn_instance_id - if settlement_identity.replan_obligation_id else None), + if settlement_identity.replan_obligation_id or settlement_identity.request_subject is not None else None), available_capabilities=args.available_capabilities, scheduler_execution_context=( payload.get("scheduler_execution_context") @@ -916,7 +917,22 @@ def resolve_dsh_operation_session(envelope: Mapping[str, Any]) -> dict[str, str] args, workspace=project, environ=operator_environ, ) + request_locator = payload.get("delegation_operation_locator") + settlement_admission = None + if settlement_identity.request_subject is not None: + if not isinstance(request_locator, dict): + raise ValueError("request Turn is missing its original delegation locator") + from ..control_plane.collaboration.delegation_context import ( + require_current_request_operation, request_settlement_lifetime, + ) + def settlement_admission(): + return request_settlement_lifetime(request_locator, settlement_identity, + registry_path=registry_path, runtime_root=runtime_root, workspace=project) + def on_managed_start_admitted() -> None: + if settlement_identity.request_subject is not None: + require_current_request_operation(request_locator, settlement_identity, + registry_path=registry_path, runtime_root=runtime_root, workspace=project) replan_packet = stable_envelope.get("replan_action_packet") capability_guard = ( replan_packet.get("capability_guard") @@ -956,6 +972,7 @@ def on_managed_start_admitted() -> None: execute=bool(args.execute), retry_failed=bool(args.retry_failed_turn), task_validator=task_validator, + settlement_admission=settlement_admission, writeback=writeback if args.execute else None, completion_writeback=completion_writeback if args.execute else None, completion_intent=completion_intent if args.execute else None, diff --git a/loopx/collaboration_mcp.py b/loopx/collaboration_mcp.py index 0e0787cef9..7005b93eba 100644 --- a/loopx/collaboration_mcp.py +++ b/loopx/collaboration_mcp.py @@ -14,7 +14,6 @@ import json import os import signal -import stat import subprocess import sys import time @@ -35,7 +34,6 @@ from .control_plane.effect_runtime import ( effect_runtime_request_scope, effect_runtime_result, EffectRuntimeRemoteError, ) -from .control_plane.coordination.local_authority import local_authority_is_promoted from .control_plane.runtime.validation_command import noninteractive_validation from .control_plane.turn_driver.journal_store import ( load_turn_journal, @@ -123,31 +121,6 @@ def _python_module_command(module: str) -> list[str]: return [sys.executable, "-P", "-m", module] -def _observe_bound_workspace(path: Path) -> tuple[str, tuple[int, int, Path] | None]: - """Observe a directory and its target identity without exposing its path. - - The binding is operator-owned, but a worktree or symlink can disappear or - change targets while a read-only Turn preview is running. Identity is a - host fact; the shared TypeScript preflight still owns the readiness rule. - """ - - try: - observed = path.stat() - if not stat.S_ISDIR(observed.st_mode): - return "not_directory", None - resolved = path.resolve(strict=True) - target = resolved.stat() - if (observed.st_dev, observed.st_ino) != (target.st_dev, target.st_ino): - return "unavailable", None - return "available", (observed.st_dev, observed.st_ino, resolved) - except FileNotFoundError: - return "missing", None - except NotADirectoryError: - return "not_directory", None - except (OSError, RuntimeError): - return "unavailable", None - - def _pinned_module_command(module: str, *, interpreter: str | None = None) -> list[str]: """Build a self-contained module argv for hosts that sanitize env vars.""" @@ -568,139 +541,13 @@ def operations(self, *, limit: int = 20, cursor: str | None = None) -> dict: with effect_runtime_request_scope(): return read_delegation_inventory(self, limit=limit, cursor=cursor) - def inspect(self, binding_id: str) -> dict: + def inspect(self, binding_id: str, *, source_request_id: str | None = None) -> dict: """Observe the real Turn preflight; never create a request or run a host.""" # Pin executable source only for this observation, not authority data. # Bindings, acceptance and validation files are still read twice below; # the next inspection must resolve its own current source revision. with effect_runtime_request_scope(): - return self._inspect(binding_id) - - def _inspect(self, binding_id: str) -> dict[str, object]: - binding = self.binding(binding_id, require_active=True) - # Host filesystem facts only; the shared TS owner projects readiness. - # Do not expose a path/error body or probe authority in a missing cwd. - workspace_path = Path(binding["workspace"]) - workspace_state, workspace_identity = _observe_bound_workspace(workspace_path) - - def workspace_fault(state: str) -> dict[str, object]: - return effect_runtime_result("collaboration.delegation.preflight", { - "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, - "workspace": {"state": state}, - "authority": None, "preview": None, "acceptance": None, - "validation_files_current": False, - }) - - def recheck_workspace() -> dict[str, object] | None: - if self.binding(binding_id, require_active=True) != binding: - raise ValueError("delegation preflight source changed; retry inspection") - current_state, current_identity = _observe_bound_workspace(workspace_path) - if current_state == "available" and current_identity == workspace_identity: - return None - # A replacement directory is not the directory whose authority - # and acceptance were observed at entry. No path or error leaks. - return workspace_fault( - current_state if current_state != "available" else "unavailable" - ) - - def authority_fault(exc: OSError | ValueError) -> dict[str, object]: - fault = recheck_workspace() - if fault is not None: - return fault - # Authority admission is a readiness observation, not a reason for - # inspection to invent a provider launch or collapse into a raw CLI error. - try: - promoted = local_authority_is_promoted( - runtime_root=self.root, - goal_id=self.goal_id, - ) - except (OSError, RuntimeError): - # A mode readback failure is an unavailable authority, never a - # reason to suggest that a fresh promotion should be attempted. - promoted = True - return effect_runtime_result("collaboration.delegation.preflight", { - "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, - "workspace": {"state": workspace_state}, - "authority": { - "ready": False, - "reason": str(exc), - "state": "unavailable" if promoted else "promotion_required", - "next_action": ( - "repair_canonical_authority" - if promoted - else "preview_reviewed_goal_authority_promotion" - ), - }, - "preview": None, "acceptance": None, "validation_files_current": False, - }) - - if workspace_state != "available": - if self.binding(binding_id, require_active=True) != binding: - raise ValueError("delegation preflight source changed; retry inspection") - return workspace_fault(workspace_state) - assert workspace_identity is not None - try: - acceptance = delegation_validation.capture(self, binding) - except (OSError, ValueError) as exc: - return authority_fault(exc) - fault = recheck_workspace() - if fault is not None: - return fault - operation = "inspect-" + _hash(binding_id)[:32] - arguments = ["turn", "run-once", "--goal-id", self.goal_id, - "--agent-id", binding["agent_id"], "--todo-id", binding["todo_id"], - "--turn-instance-id", operation, *self._execution_arguments(binding, operation)] - # Host arguments are operator-owned, but inspection must stay read-only - # even when they contain an abbreviated execution flag or a selector. - from .cli_runtime import _build_selected_parser, _SelectedParseRejected - - try: - selected = _build_selected_parser("turn").parse_args(arguments) - except (SystemExit, _SelectedParseRejected) as exc: - raise ValueError("invalid delegation Turn arguments") from exc - workspace = workspace_identity[2] - selected_project = Path(selected.project) - if not selected_project.is_absolute(): - selected_project = workspace / selected_project - if (selected.execute or selected.resume_turn_key - or (selected.goal_id, selected.agent_id, selected.todo_id, selected.turn_instance_id) - != (self.goal_id, binding["agent_id"], binding["todo_id"], operation) - or selected_project.resolve() != workspace): - fault = recheck_workspace() - if fault is not None: - return fault - raise ValueError("delegation inspection cannot execute or retarget bound work") - try: - preview = self._cli(binding, *arguments) - except (OSError, ValueError, subprocess.TimeoutExpired): - fault = recheck_workspace() - if fault is not None: - return fault - raise - fault = recheck_workspace() - if fault is not None: - return fault - if preview.get("status") != "preview" and "selection_rejection" not in preview: - raise ValueError(f"delegation Turn preflight unavailable: {preview.get('error') or preview.get('status')}") - try: - current = delegation_validation.capture(self, binding) - except (OSError, ValueError) as exc: - # Do not return the first acceptance or an already-read preview - # when current authority cannot be confirmed at the final fence. - return authority_fault(exc) - fault = recheck_workspace() - if fault is not None: - return fault - if acceptance != current or self.binding(binding_id, require_active=True) != binding: - raise ValueError("delegation preflight source changed; retry inspection") - return effect_runtime_result("collaboration.delegation.preflight", { - "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, - "workspace": {"state": workspace_state}, - "authority": {"ready": True, "reason": None}, - "preview": preview, "acceptance": acceptance["plan"], - "validation_files_current": acceptance["files_current"], - "lane": {"state": turn_lane_liveness(self._lane_target(binding))["state"]}, - }) + return delegation_validation.inspect_binding(self, binding_id, source_request_id=source_request_id) def start(self, binding_id: str, operation_id: str, brief: dict, parent_request_id: str | None = None, *, conversation: dict | None = None, @@ -742,6 +589,8 @@ def start(self, binding_id: str, operation_id: str, brief: dict, identity = {"binding": binding, "request_id": delivered["request_id"], "operation_id": operation_id} if source_request_id is not None: identity["source_request_id"] = source_request_id + if binding["todo_id"] is None: + identity["request_subject"] = delegation_validation.ordinary_request_subject(self, {"identity": identity}, binding) if confirmed_operation_id is not None: # Internal callback adapter only: a canonical locator/CAS fence, # not an executor identity or domain execution permission. @@ -868,6 +717,9 @@ def _bound( binding = self.binding(row["identity"]["binding"]["id"], require_active=require_active) if row["identity"]["binding"] != binding: raise ValueError("delegation binding changed; reconcile original execution") + if require_active and binding["todo_id"] is None: + if row["identity"].get("request_subject") != delegation_validation.ordinary_request_subject(self, row, binding): + raise ValueError("delegation request revision or execution scope changed; reconcile original execution") if require_active and "source_request_id" in row["identity"]: try: verify_delegation_source_execution(self, binding["id"], row["identity"]["operation_id"], @@ -913,6 +765,7 @@ def _read_current(self, operation_id: str) -> dict: stop = self._read_stop(path) result = {"operation_id": operation_id, "request_id": row["identity"]["request_id"], "agent_id": binding["agent_id"], "todo_id": binding["todo_id"], + **({"request_subject": row["identity"]["request_subject"]} if "request_subject" in row["identity"] else {}), "status": row["status"], "worker_active": active, "recovery_required": not active and row["status"] not in DELEGATION_TERMINAL_STATUSES and stop is None and time.time() - row.get("created_at", 0) > 15} @@ -1427,7 +1280,7 @@ def execute(self, operation_id: str, *, prepared_source_request_id: str | None = row["worker"] = self._worker_identity() self._fenced_write(path, row) # Different request ids cannot run the same assigned task concurrently. - task_lock = _root(self.root) / "execution-slots" / _hash([self.goal_id, binding["todo_id"]]) + task_lock = _root(self.root) / "execution-slots" / _hash([self.goal_id, binding["todo_id"] or row["identity"]["request_subject"]]) with exclusive_file_lock(task_lock, policy=LockAcquisitionPolicy.SINGLE_FLIGHT): self._execute(path, row, binding) except (ValueError, KeyError, subprocess.TimeoutExpired, EffectRuntimeRemoteError) as exc: @@ -1598,6 +1451,13 @@ def _acquire_delegation_lease( ) -> dict: """Acquire the promoted hard lease before worker or completion effects.""" + if binding["todo_id"] is None: + if row.get("task_lease", {}).get("required") is True: + raise ValueError("ordinary request cannot discard an existing task lease") + row["task_lease"] = {"required": False, "request_subject": row["identity"]["request_subject"]} + self._fenced_write(path, row) + return row["task_lease"] + from .control_plane.work_items.task_lease import inspect_task_lease observed = inspect_task_lease(registry_path=self.registry, runtime_root=self.root, @@ -1679,6 +1539,9 @@ def _delegated_lease_context(self, row: dict, binding: dict) -> dict | None: } def _complete_delegated_todo(self, row: dict, binding: dict) -> None: + if binding["todo_id"] is None: + self._validate(binding) + return # Request validation never creates or completes a Todo. lease = row.get("task_lease") if not isinstance(lease, dict): raise ValueError("delegation Todo completion requires its acquired task lease") @@ -1783,12 +1646,8 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: selector = ( ["--resume-turn-key", turn_key] if turn_key - else [ - "--todo-id", - binding["todo_id"], - "--turn-instance-id", - self._turn_instance_id(row), - ] + else [*( ["--todo-id", binding["todo_id"]] if binding["todo_id"] is not None else []), + "--turn-instance-id", self._turn_instance_id(row)] ) from .control_plane.collaboration.delegation_context import delegated_turn_context_arguments context = delegated_turn_context_arguments(self, row) if turn_key is None else [] @@ -1826,6 +1685,17 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: delegation_results.require_dependencies(self, binding, delegation_results.operation_brief(self, row)) if needs_settlement and not isinstance(row.get("task_lease"), dict): self._acquire_delegation_lease(path, row, binding) + if binding["todo_id"] is None and needs_settlement: + # The request's native settlement adapter owns the original + # Stop/source fences. Do not hold a parent policy lock while a + # child process reacquires it to resume the same transaction. + result = self._cli(binding, "turn", "run-once", *common, + "--resume-turn-key", row["turn_key"], *execution, "--execute", + timeout=binding["timeout_seconds"] + 60, host_record=self._host_process_record(path)) + self._record_turn_result(path, row, result, publish=False) + if result.get("status") != "committed" or result.get("result_kind") != "validated_progress": + raise ValueError(str(result.get("error") or result.get("reason") or "request settlement remains incomplete")) + needs_settlement = False # Both effects commit inside the dispatch lock that a stop also # takes, so the two sides linearize: either a stop is written first # and neither effect runs, or both effects commit first and the stop @@ -1902,7 +1772,10 @@ def _execute(self, path: Path, row: dict, binding: dict) -> None: caller_goal_ref=self._caller_goal_ref(), ) self._observe(path, row, "accepted", already_locked=True, - canonical_done=True, acceptance_ready=True, artifacts_current=True, + canonical_done=binding["todo_id"] is not None, + request_validated=binding["todo_id"] is None, + request_subject=row["identity"].get("request_subject"), + acceptance_ready=True, artifacts_current=True, requester=self._wake_requester(row)) except (ValueError, KeyError, subprocess.TimeoutExpired, EffectRuntimeRemoteError) as exc: # Retain uncertain execution for explicit same-operation recovery. diff --git a/loopx/control_plane/collaboration/delegation.ts b/loopx/control_plane/collaboration/delegation.ts index 505198a4c9..5c85989f07 100644 --- a/loopx/control_plane/collaboration/delegation.ts +++ b/loopx/control_plane/collaboration/delegation.ts @@ -1,6 +1,6 @@ /** Explicit local execution bindings. Registration/messages alone grant no launch. * These are host observations; canonical task/Turn/acceptance remain authoritative. */ -import type {JsonObject} from "../effect_program.ts"; +import {executionRequestSubject, type JsonObject} from "../effect_program.ts"; import {requireInteger, requireJsonObject, requireStringArray} from "../runtime_decode.ts"; import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; @@ -9,6 +9,8 @@ import {normalizeTodoCompletionValidationDeclaration} from "../todos/completion_ import {readTurnSelectionRejection, turnSelectionRejectionState} from "../turn_driver/selection_rejection.ts"; import { BARE_SHA256_PATTERN, ENVELOPED_SHA256_PATTERN } from "../content_digest.ts"; import {PROJECTION_ENVELOPE_SEAL_REQUEST, sealProjectionEnvelope} from "../projection_envelope.ts"; +import {readRequestControl, requestOriginalDigest, requestRevisionDigest} from "./request_control.ts"; +import {EffectiveAction} from "../quota/effective_action.generated.ts"; import {normalizeCollaborationBrief, planCollaborationRequestPublication} from "./semantic_request.ts"; function requireThat(ok: unknown, message: string): asserts ok { @@ -23,6 +25,24 @@ function text(value: unknown): value is string { * Private declarations must match the current Todo authority, also on readback. */ export function delegationValidationPlan(params: JsonObject): JsonObject { const binding = requireJsonObject(params.binding, "delegation binding"); + if (binding.todo_id == null) { + requireThat(requireJsonObject(params.basis, "canonical validation basis").status === "loaded", "delegation canonical authority unavailable"); + const normalized = normalizeTodoCompletionValidationDeclaration( + binding.request_validation == null ? {} : requireJsonObject(binding.request_validation, "request validation"), { + strict_fields: true, require_command: true, require_canonical_input: true, + }); + if (!normalized.ok) return {todo_id: null, state: "unbound", source: null, + reason: "independent_delegation_validation_required", effects: [], canonical_done: false}; + const declaration = normalized.value; + const effects = [{kind: "caller_validation", validation_command: declaration.validation_command, + validation_argv: declaration.validation_command_argv, validation_label: declaration.validation_label, + validation_timeout_seconds: declaration.validation_timeout_seconds, + validation_declaration_sha256: canonicalAuthoritySha256(declaration), task_repository: null}]; + return {todo_id: null, state: "ready", source: "request_validation", effects, + canonical_done: false, task_basis_sha256: canonicalAuthoritySha256(binding), + observation: {source: "request_validation", basis_sha256: canonicalAuthoritySha256({binding, effects}), + check_count: effects.length, pinned_file_count: 0}}; + } const basis = requireJsonObject(params.basis, "canonical validation basis"); const todo = requireJsonObject(basis.todo, "canonical delegation Todo"); requireThat(basis.status === "loaded" && text(basis.provider_revision) @@ -95,10 +115,11 @@ function currentOracleChecks(basis: JsonObject, effects: JsonObject[]): JsonObje export function delegationCheckedArtifacts(params: JsonObject): JsonObject { const binding = requireJsonObject(params.binding, "delegation binding"); const plan = requireJsonObject(params.plan, "current validation plan"); - requireThat(plan.todo_id === binding.todo_id && plan.state === "ready" && plan.canonical_done === true, + requireThat(plan.todo_id === binding.todo_id && plan.state === "ready" && (plan.canonical_done === true + || binding.todo_id == null && plan.source === "request_validation"), "delegation requires current canonical completion"); const observation = requireJsonObject(plan.observation, "current validation observation"); - requireThat((observation.source === "goal_acceptance" || observation.source === "todo_validation") + requireThat((observation.source === "goal_acceptance" || observation.source === "todo_validation" || observation.source === "request_validation") && typeof observation.basis_sha256 === "string" && BARE_SHA256_PATTERN.test(observation.basis_sha256) && Number.isInteger(observation.check_count) && Number(observation.check_count) > 0 && Number.isInteger(observation.pinned_file_count) && Number(observation.pinned_file_count) >= 0, @@ -146,7 +167,8 @@ export function selectDelegationBinding(params: JsonObject): JsonObject { const binding = rows.find(row => row.id === params.binding_id); requireThat(binding, "delegation binding unavailable"); requireThat(new TextEncoder().encode(JSON.stringify(binding)).length <= 16000, "delegation binding exceeds limit"); - requireThat([binding.id, binding.agent_id, binding.todo_id, binding.workspace].every(text), "binding identity/workspace required"); + requireThat([binding.id, binding.agent_id, binding.workspace].every(text) + && (binding.todo_id == null || text(binding.todo_id)), "binding identity/workspace required"); requireThat(Array.isArray(binding.requesters) && binding.requesters.includes(params.agent_id) && binding.agent_id !== params.agent_id, "caller has no delegation grant"); requireThat(Array.isArray(binding.host_args) && binding.host_args.length > 0 @@ -156,7 +178,7 @@ export function selectDelegationBinding(params: JsonObject): JsonObject { requireThat(Array.isArray(binding.output_refs) && binding.output_refs.length > 0 && binding.output_refs.length <= 20 && binding.output_refs.every(ref => text(ref) && !ref.startsWith("/") && !ref.includes("\\") && !ref.split("/").includes("..")), "bounded relative output refs required"); - return binding; + return binding.todo_id === undefined ? {...binding, todo_id: null} : binding; } /** An operation locator is not proof that a new selection owns its result. @@ -183,7 +205,8 @@ export function verifyDelegationSourceReplay(params: JsonObject): JsonObject { requireThat(text(params.operation_id) && typeof peer.request_id === "string" && BARE_SHA256_PATTERN.test(peer.request_id) && same(identity, {binding, request_id: peer.request_id, - operation_id: params.operation_id, source_request_id: params.source_request_id}), + operation_id: params.operation_id, source_request_id: params.source_request_id, + ...(binding.todo_id == null ? {request_subject: delegationRequestSubject(source, binding)} : {})}), "delegation replay operation identity mismatch"); const brief = normalizeCollaborationBrief(params.brief); requireThat(same(source.brief, brief) && same(peer.brief, brief), "delegation replay brief changed"); @@ -193,11 +216,90 @@ export function verifyDelegationSourceReplay(params: JsonObject): JsonObject { return {matched: true}; } +/** The original request revision and exact operator binding own ordinary work. */ +export function delegationRequestSubject(entry: JsonObject, binding: JsonObject, expected?: unknown): JsonObject { + requireThat(binding.todo_id == null && entry.agent_id === binding.agent_id + && typeof entry.request_id === "string" && BARE_SHA256_PATTERN.test(entry.request_id), + "ordinary delegation requires its original request"); + const control = entry.request_control == null ? null : readRequestControl(entry); + const revision = control?.revisions.at(-1); + requireThat(control?.cancellation == null, "delegation request was cancelled"); + const subject = executionRequestSubject({request_id: entry.request_id, + request_revision: revision?.revision ?? 1, + request_digest: revision ? requestRevisionDigest(entry, revision) : requestOriginalDigest(entry), + execution_scope_sha256: canonicalAuthoritySha256(binding)})!; + if (expected !== undefined) requireThat( + canonicalAuthoritySha256(executionRequestSubject(expected)) === canonicalAuthoritySha256(subject), + "original request revision or scope changed"); + return subject; +} + +/** Called only after the host has re-read the original Inbox/source grant. */ +export function delegationRequestDecision(params: JsonObject): JsonObject { + const subject = executionRequestSubject(params.request_subject); + requireThat(subject !== null, "original request subject required"); + const packet = structuredClone(requireJsonObject(params.decision, "current quota decision")); + // A request never bypasses Stop, budget, health, a true gate or required replan. + if (packet.should_run !== true || packet.effective_action !== EffectiveAction.NORMAL_RUN + || packet.normal_delivery_allowed !== true) return packet; + delete packet.selected_todo; + delete packet.agent_lane_next_action; + delete packet.action_portfolio; + delete packet.task_orchestration_contract; + delete packet.action_selection_qualification; + delete packet.goal_route_hint; + delete packet.next_action_projection_warning; + if (packet.planning_horizon != null) { + const horizon = requireJsonObject(packet.planning_horizon, "planning horizon"); + delete horizon.selected_todo_id; + delete horizon.selection_contract; + if (Array.isArray(horizon.work_items)) for (const raw of horizon.work_items) { + const item = requireJsonObject(raw, "horizon item"); + if (Array.isArray(item.context_reasons)) item.context_reasons = item.context_reasons.filter(reason => + reason !== "related_to_selected" && reason !== "higher_priority_than_selected"); + } + } + packet.recommended_action = "Process the original delegated request and return its validated result."; + const cli = requireJsonObject(requireJsonObject(packet.interaction_contract, "interaction contract").cli_channel, "CLI channel"); + for (const key of ["selection_required", "selection_command", "selection_instruction"]) delete cli[key]; + cli.spend_after_validation = true; + cli.spend_policy = "spend once after independent request validation and durable writeback"; + const contract = requireJsonObject(packet.interaction_contract, "interaction contract"); + const agent = requireJsonObject(contract.agent_channel, "agent channel"); + const work = requireJsonObject(agent.work_context, "work context"); + if (Array.isArray(work.sources)) work.sources = work.sources.filter(raw => + requireJsonObject(raw, "work source").source !== "selected_todo"); + delete work.selected_todo_text_verified; + delete work.selected_todo_authority; + agent.must_attempt = true; + agent.delivery_allowed = true; + agent.quiet_noop_allowed = false; + agent.primary_action = "Process the original delegated request and return its validated result."; + packet.request_subject = subject; + return packet; +} + /** Original work is a captured material, not an implicit workspace convention. * The host has reobserved the operation/grant through the existing owners. * No locator, Host configuration or new authority enters the model input. */ export function delegationTurnWorkContext(params: JsonObject): JsonObject { const binding = selectDelegationBinding(params); + if (params.record === null) { + const call = requireJsonObject(params.turn, "request preview Turn"); + const source = requireJsonObject(params.source_request, "original request"); + requireThat(binding.todo_id == null && call.todo_id == null + && call.agent_id === binding.agent_id && call.workspace === binding.workspace + && text(call.turn_instance_id) && source.goal_id === call.goal_id + && source.agent_id === binding.agent_id && source.execution_binding_id === binding.id + && typeof source.message === "string" && source.message === params.original_message, + "request preview does not match its original source"); + return {request_id: source.request_id, goal_id: source.goal_id, agent_id: source.agent_id, + todo_id: null, turn_instance_id: call.turn_instance_id, + brief: normalizeCollaborationBrief(source.brief), instruction: source.instruction, + request_subject: delegationRequestSubject(source, binding), + source_request: {request_id: source.request_id, message: source.message, + brief: source.brief, instruction: source.instruction}}; + } const record = requireJsonObject(params.record, "delegation record"); const identity = requireJsonObject(record.identity, "delegation identity"); const call = requireJsonObject(params.turn, "delegated Turn"); @@ -222,10 +324,13 @@ export function delegationTurnWorkContext(params: JsonObject): JsonObject { && same(source.brief, brief) && typeof source.message === "string" && source.message === params.original_message, "original delegation message or identity changed"); + if (binding.todo_id == null) requireThat(same(identity.request_subject, + delegationRequestSubject(source ?? peer, binding)), "original request revision or scope changed"); return { operation_id: identity.operation_id, request_id: peer.request_id, goal_id: peer.goal_id, agent_id: peer.agent_id, todo_id: binding.todo_id, turn_instance_id: call.turn_instance_id, brief, instruction: peer.instruction, + ...(binding.todo_id == null ? {request_subject: delegationRequestSubject(source ?? peer, binding)} : {}), ...(source === null ? {} : {source_request: { request_id: source.request_id, message: source.message, brief: source.brief, instruction: source.instruction, @@ -303,7 +408,7 @@ function delegationAcceptanceFacts(todoId: unknown, acceptance: JsonObject | nul if (matching && acceptance?.state === "unbound" && typeof acceptance.reason === "string" && todoValidationReasons.has(acceptance.reason)) { return {acceptance_ready: false, acceptance_reason_code: acceptance.reason, - acceptance_next_action: "review_original_todo_validation"}; + acceptance_next_action: todoId == null ? "review_original_request_validation" : "review_original_todo_validation"}; } if (matching && acceptance?.state === "ready" && filesCurrent === false) { return {acceptance_ready: false, acceptance_reason_code: "validation_files_unavailable", @@ -325,7 +430,7 @@ export function delegationLaneReadiness(params: JsonObject): JsonObject { /** Read the actual dry-run route/profile, never infer readiness from assignment. */ export function delegationPreflight(params: JsonObject): JsonObject { const binding = requireJsonObject(params.binding, "binding identity"); - requireThat([binding.id, binding.agent_id, binding.todo_id].every(text), "binding identities required"); + requireThat([binding.id, binding.agent_id].every(text) && (binding.todo_id == null || text(binding.todo_id)), "binding identities required"); // Missing observations preserve older host compatibility. Filesystem facts // cannot establish canonical authority or executor readiness. if (params.workspace !== undefined) { @@ -386,6 +491,16 @@ export function delegationPreflight(params: JsonObject): JsonObject { + "Promote or repair authority explicitly before retrying; inspection never promotes a provider.", }; } + if (params.request_required === true) return { + schema_version: "loopx_delegation_preflight_v0", binding, state: "turn_blocked", + request_required: true, + dispatch_allowed: false, turn_eligible: false, turn_route: null, acceptance_ready: false, + authority_ready: true, authority_reason: null, authority_state: "promoted", + authority_next_action: "none", promotion_from_surface_allowed: false, + acceptance_reason_code: null, acceptance_next_action: "none", executor: null, + reason: "An ordinary binding needs its original request for preflight; start preserves that request.", + effects: {host_invoked: false, state_written: false, quota_spent: false, scheduler_acknowledged: false}, + }; const preview = requireJsonObject(params.preview, "Turn preview"); const effects = requireJsonObject(preview.effects, "preview effects"); if (preview.ok === false && preview.selection_rejection !== undefined) { @@ -484,13 +599,15 @@ export function delegationInventoryItem(params: JsonObject): JsonObject { requireThat(observation.operation_id === record.operation_id && record.operation_id !== null, "delegation inventory identity mismatch"); requireThat(Object.hasOwn(transitions, String(observation.status)), "invalid delegation observation"); - requireThat([observation.request_id, observation.agent_id, observation.todo_id].every(text), + requireThat([observation.request_id, observation.agent_id].every(text) + && (text(observation.todo_id) || observation.todo_id == null && executionRequestSubject(observation.request_subject) !== null), "delegation request and task identities required"); requireThat(typeof observation.worker_active === "boolean" && typeof observation.recovery_required === "boolean", "current worker observation required"); const result: JsonObject = { record_id: record.record_id, operation_id: observation.operation_id, request_id: observation.request_id, agent_id: observation.agent_id, todo_id: observation.todo_id, + ...(observation.request_subject ? {request_subject: observation.request_subject} : {}), status: observation.status, worker_active: observation.worker_active, recovery_required: observation.recovery_required, }; @@ -601,7 +718,8 @@ export function transitionDelegationObservation(params: JsonObject): JsonObject .every(key => effects[key] === false) && Object.values(effects).every(value => value === false)) return {status: "prepared"}; } - if (to === "accepted") requireThat(params.canonical_done === true + if (to === "accepted") requireThat((params.canonical_done === true + || params.request_validated === true && executionRequestSubject(params.request_subject) !== null) && params.acceptance_ready === true && params.artifacts_current === true, "accepted return requires current canonical completion and artifacts"); if (to === "accepted" && from !== "accepted" && wakesItsConversation(params)) { diff --git a/loopx/control_plane/collaboration/delegation_context.py b/loopx/control_plane/collaboration/delegation_context.py index 43eafafecc..8e8d26e4a8 100644 --- a/loopx/control_plane/collaboration/delegation_context.py +++ b/loopx/control_plane/collaboration/delegation_context.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +from contextlib import contextmanager, nullcontext from pathlib import Path import shlex from typing import Any @@ -101,7 +102,7 @@ def delegated_turn_context_arguments(service, row: dict) -> list[str]: def delegated_turn_context( locator_json: str, *, registry_path: Path, runtime_root: Path, goal_id: str, agent_id: str, todo_id: str | None, turn_instance_id: str | None, - workspace: Path, + workspace: Path, preview: bool = False, ) -> dict[str, Any]: """Read original work through its existing operation, never a model brief. @@ -110,7 +111,6 @@ def delegated_turn_context( The generic work-context owner carries the returned material into capture. """ from ...collaboration_mcp import Delegations - from ...command_invocation import loopx_cli_argv from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle from .inbox import _entry, _root from .peers import _operation_address @@ -119,6 +119,33 @@ def delegated_turn_context( from ..effect_runtime import effect_runtime_result locator = json.loads(locator_json) + preview_fields = {"requester_agent_id", "execution_config", "source_request_id", "binding_id"} + if isinstance(locator, dict) and set(locator) == preview_fields: + if not preview or any(not isinstance(locator[key], str) or not locator[key] for key in preview_fields): + raise ValueError("a source request preview cannot execute or resume a Turn") + from .source_grant_observation import require_delegation_source_execution + service = Delegations(runtime_root, registry_path, goal_id, locator["requester_agent_id"], + Path(locator["execution_config"])) + binding = service.binding(locator["binding_id"], require_active=True) + require_delegation_source_execution(runtime_root, registry_path, goal_id=goal_id, + requester_agent_id=service.agent_id, source_request_id=locator["source_request_id"], + binding=binding, caller_goal_ref=service._caller_goal_ref()) + if workspace.resolve() != Path(binding["workspace"]).resolve(): + raise ValueError("request preview workspace changed") + with collaboration_goal_scope(registry_path, goal_id=goal_id, agents=(service.agent_id, agent_id), + caller_goal_ref=service._caller_goal_ref(), require_active=True) as scope: + source_id = locator["source_request_id"] + source = _entry(runtime_root, goal_id, agent_id, source_id, scope=scope) + decide_collaboration_lifecycle(scope, operation="history_inspect", record=source) + route = _read(_root(runtime_root) / "roundtrips" / (source_id + ".json")) + session, turn = observe_source_chat(runtime_root, route) + ingress, _ = external_source_policy(runtime_root, session, turn) + content = effect_runtime_result("collaboration.delegation.turn_context", { + "config": _read(service.config), "binding_id": binding["id"], "agent_id": service.agent_id, + "record": None, "source_request": source, "original_message": ingress["source_message"], + "turn": {"goal_id": goal_id, "agent_id": agent_id, "todo_id": todo_id, + "turn_instance_id": turn_instance_id, "workspace": binding["workspace"]}}) + return _context_delivery(content, service=service, request_id=source_id) fields = {"requester_agent_id", "execution_config", "operation_id"} if not isinstance(locator, dict) or set(locator) != fields or any( not isinstance(locator[key], str) or not locator[key] for key in fields @@ -158,10 +185,61 @@ def delegated_turn_context( "source_request": source, "original_message": original_message, }) service._bound(row, require_active=True) - command = shlex.join([*loopx_cli_argv(), "--registry", str(registry_path), - "--runtime-root", str(runtime_root), "manager-inbox", "read", "--goal-id", goal_id, - "--agent-id", agent_id, "--request-id", source_id or peer["request_id"]]) - return {"required_reads": [{"command": command, "source": "delegation_work", + return _context_delivery(content, service=service, request_id=source_id or peer["request_id"]) + + +def _request_operation(locator: dict, identity, *, registry_path: Path, runtime_root: Path, workspace: Path): + from ...collaboration_mcp import Delegations + + if set(locator) != {"requester_agent_id", "execution_config", "operation_id"}: + raise ValueError("request execution requires its original delegation operation") + service = Delegations(runtime_root, registry_path, identity.goal_id, locator["requester_agent_id"], + Path(locator["execution_config"])) + path = service.path(locator["operation_id"]) + row = _read(path) + if (row["identity"].get("request_subject") != identity.request_subject + or service._turn_instance_id(row) != identity.turn_instance_id + or row["identity"]["binding"]["agent_id"] != identity.agent_id + or Path(row["identity"]["binding"]["workspace"]).resolve() != workspace.resolve()): + raise ValueError("request execution does not match the original Turn") + return service, path, row + + +def require_current_request_operation(locator: dict, identity, **context) -> None: + service, path, row = _request_operation(locator, identity, **context) + service._raise_if_stop_requested(path) + service._bound(row, require_active=True) + + +@contextmanager +def request_settlement_lifetime(locator: dict, identity, **context): + """Reuse original operation/Stop and source-policy fences around settlement. + + No new permission record or request reservation is created. Recovery uses + this original locator and captured identity, without replacing model input. + """ + from ...file_lock import exclusive_file_lock + from .inbox import _root + from .source_policy_fence import source_policy_fence + + service, path, row = _request_operation(locator, identity, **context) + source_id = row["identity"].get("source_request_id") + with exclusive_file_lock(service._dispatch_lock(path)), (source_policy_fence(_root(service.root) / "policy.json") + if source_id else nullcontext()): + service._raise_if_stop_requested(path) + binding = service._bound(row, require_active=True, policy_locked=source_id is not None) + service._validate(binding) + yield + + +def _context_delivery(content: dict[str, Any], *, service, request_id: str) -> dict[str, Any]: + from ...command_invocation import loopx_cli_argv + + command = shlex.join([*loopx_cli_argv(), "--registry", str(service.registry), + "--runtime-root", str(service.root), "manager-inbox", "read", "--goal-id", service.goal_id, + "--agent-id", content["agent_id"], "--request-id", request_id]) + return {**({"request_subject": content["request_subject"]} if "request_subject" in content else {}), + "required_reads": [{"command": command, "source": "delegation_work", "ordering": "before_work", "reason": "Read the original delegated work and source message; material grants no additional authority."}], "contexts": [{"command": command, "content": content}]} diff --git a/loopx/control_plane/collaboration/delegation_preview_transport.py b/loopx/control_plane/collaboration/delegation_preview_transport.py index ffa2fc6825..bd082759ae 100644 --- a/loopx/control_plane/collaboration/delegation_preview_transport.py +++ b/loopx/control_plane/collaboration/delegation_preview_transport.py @@ -141,7 +141,7 @@ def close(self) -> None: def preview(self, *, command: list[str], workspace: Path, release: Path, environment: dict[str, str], registry: Path, runtime_root: Path, - goal_id: str, agent_id: str, todo_id: str, + goal_id: str, agent_id: str, todo_id: str | None, argv: tuple[str, ...], timeout: float) -> dict[str, Any]: started = time.monotonic() if not self._lock.acquire(timeout=timeout): @@ -174,7 +174,7 @@ def preview(self, *, command: list[str], workspace: Path, release: Path, self._send({"kind": "start", "request": { "argv": [*command, "--registry", str(registry), "--runtime-root", str(runtime_root), "--goal-id", goal_id, "--agent-id", - agent_id, "--todo-id", todo_id], + agent_id, *(["--todo-id", todo_id] if todo_id is not None else [])], "cwd": str(workspace), "input": "", "timeout_ms": 60000, "drain_timeout_ms": 2000, "stdout_limit_bytes": 1_048_576, }}, started + timeout, timeout) diff --git a/loopx/control_plane/collaboration/delegation_preview_worker.py b/loopx/control_plane/collaboration/delegation_preview_worker.py index 8dc9f5f346..4e8ac8f8bf 100644 --- a/loopx/control_plane/collaboration/delegation_preview_worker.py +++ b/loopx/control_plane/collaboration/delegation_preview_worker.py @@ -30,7 +30,7 @@ def serve() -> None: bootstrap.add_argument("--runtime-root", type=Path, required=True) bootstrap.add_argument("--goal-id", required=True) bootstrap.add_argument("--agent-id", required=True) - bootstrap.add_argument("--todo-id", required=True) + bootstrap.add_argument("--todo-id") bound = bootstrap.parse_args() workspace = Path.cwd().resolve() parser = _build_selected_parser("turn") diff --git a/loopx/control_plane/collaboration/delegation_results.py b/loopx/control_plane/collaboration/delegation_results.py index e21098c240..2d913ea638 100644 --- a/loopx/control_plane/collaboration/delegation_results.py +++ b/loopx/control_plane/collaboration/delegation_results.py @@ -58,8 +58,17 @@ def accepted_result(service, binding): def operation_brief(service, row): + from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle + identity = row["identity"] - return _entry(service.root, service.goal_id, identity["binding"]["agent_id"], identity["request_id"])["brief"] + with collaboration_goal_scope(service.registry, goal_id=service.goal_id, agents=(), + caller_goal_ref=service._caller_goal_ref()) as scope: + try: + entry = _entry(service.root, service.goal_id, identity["binding"]["agent_id"], identity["request_id"], scope=scope) + except OSError as exc: + raise ValueError("delegation request brief unavailable for the captured Goal instance") from exc + decide_collaboration_lifecycle(scope, operation="history_inspect", record=entry) + return entry["brief"] class _ResultUseRead: diff --git a/loopx/control_plane/collaboration/delegation_stop_lease.py b/loopx/control_plane/collaboration/delegation_stop_lease.py index 0189aca6bd..91d8cca1c0 100644 --- a/loopx/control_plane/collaboration/delegation_stop_lease.py +++ b/loopx/control_plane/collaboration/delegation_stop_lease.py @@ -38,6 +38,11 @@ def obligation(service, row): recorded = row.get("task_lease") if isinstance(row.get("task_lease"), dict) else {} required = recorded.get("required") is True + if row["identity"]["binding"]["todo_id"] is None: + if required: + raise ValueError("ordinary request cannot erase a required canonical task lease") + service._bound(row) # The saved request must retain its exact operator binding. + return None if not local_authority_is_promoted(runtime_root=service.root, goal_id=service.goal_id): if required: raise ValueError("canonical authority disappeared under a required delegation lease") diff --git a/loopx/control_plane/collaboration/delegation_validation.py b/loopx/control_plane/collaboration/delegation_validation.py index bd9e4c776a..a34ad76538 100644 --- a/loopx/control_plane/collaboration/delegation_validation.py +++ b/loopx/control_plane/collaboration/delegation_validation.py @@ -7,12 +7,18 @@ No validator output or successful declaration read completes a canonical Todo. """ +import json +import stat +import subprocess from pathlib import Path from ...agent_registry import load_goal_from_registry from ...materials import goal_state_path from ..agents.workspace_guard import capture_delivery_workspace from ..effect_runtime import effect_runtime_result +from ..coordination.local_authority import local_authority_is_promoted +from ..turn_driver.lane_fence import turn_lane_liveness +from .inbox import _hash from . import delegation_results from ..turn_driver.journal_store import ( find_loopx_turn_key_by_settlement_identity, load_turn_journal, turn_journal_path, @@ -25,28 +31,196 @@ from ..todos.completion_validation import resolve_private_completion_validation_declaration +def _observe_bound_workspace(path: Path) -> tuple[str, tuple[int, int, Path] | None]: + """Observe a directory and its target identity without exposing its path. + + The binding is operator-owned, but a worktree or symlink can disappear or + change targets while a read-only Turn preview is running. Identity is a + host fact; the shared TypeScript preflight still owns the readiness rule. + """ + + try: + observed = path.stat() + if not stat.S_ISDIR(observed.st_mode): + return "not_directory", None + resolved = path.resolve(strict=True) + target = resolved.stat() + if (observed.st_dev, observed.st_ino) != (target.st_dev, target.st_ino): + return "unavailable", None + return "available", (observed.st_dev, observed.st_ino, resolved) + except FileNotFoundError: + return "missing", None + except NotADirectoryError: + return "not_directory", None + except (OSError, RuntimeError): + return "unavailable", None + + +def inspect_binding(service, binding_id: str, *, source_request_id: str | None = None) -> dict[str, object]: + binding = service.binding(binding_id, require_active=True) + # Host filesystem facts only; the shared TS owner projects readiness. + # Do not expose a path/error body or probe authority in a missing cwd. + workspace_path = Path(binding["workspace"]) + workspace_state, workspace_identity = _observe_bound_workspace(workspace_path) + + def workspace_fault(state: str) -> dict[str, object]: + return effect_runtime_result("collaboration.delegation.preflight", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": state}, + "authority": None, "preview": None, "acceptance": None, + "validation_files_current": False, + }) + + def recheck_workspace() -> dict[str, object] | None: + if service.binding(binding_id, require_active=True) != binding: + raise ValueError("delegation preflight source changed; retry inspection") + current_state, current_identity = _observe_bound_workspace(workspace_path) + if current_state == "available" and current_identity == workspace_identity: + return None + # A replacement directory is not the directory whose authority + # and acceptance were observed at entry. No path or error leaks. + return workspace_fault( + current_state if current_state != "available" else "unavailable" + ) + + def authority_fault(exc: OSError | ValueError) -> dict[str, object]: + fault = recheck_workspace() + if fault is not None: + return fault + # Authority admission is a readiness observation, not a reason for + # inspection to invent a provider launch or collapse into a raw CLI error. + try: + promoted = local_authority_is_promoted( + runtime_root=service.root, + goal_id=service.goal_id, + ) + except (OSError, RuntimeError): + # A mode readback failure is an unavailable authority, never a + # reason to suggest that a fresh promotion should be attempted. + promoted = True + return effect_runtime_result("collaboration.delegation.preflight", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, + "authority": { + "ready": False, + "reason": str(exc), + "state": "unavailable" if promoted else "promotion_required", + "next_action": ( + "repair_canonical_authority" + if promoted + else "preview_reviewed_goal_authority_promotion" + ), + }, + "preview": None, "acceptance": None, "validation_files_current": False, + }) + + if workspace_state != "available": + if service.binding(binding_id, require_active=True) != binding: + raise ValueError("delegation preflight source changed; retry inspection") + return workspace_fault(workspace_state) + assert workspace_identity is not None + try: + acceptance = capture(service, binding) + except (OSError, ValueError) as exc: + return authority_fault(exc) + fault = recheck_workspace() + if fault is not None: + return fault + operation = "inspect-" + _hash(binding_id)[:32] + arguments = ["turn", "run-once", "--goal-id", service.goal_id, + "--agent-id", binding["agent_id"], + *(["--todo-id", binding["todo_id"]] if binding["todo_id"] is not None else []), + "--turn-instance-id", operation, *service._execution_arguments(binding, operation)] + if binding["todo_id"] is None: + if source_request_id is None: + return effect_runtime_result("collaboration.delegation.preflight", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, "authority": {"ready": True}, + "request_required": True}) + arguments += ["--delegation-context-json", json.dumps({ + "requester_agent_id": service.agent_id, "execution_config": str(service.config), + "source_request_id": source_request_id, "binding_id": binding["id"]})] + # Host arguments are operator-owned, but inspection must stay read-only + # even when they contain an abbreviated execution flag or a selector. + from ...cli_runtime import _build_selected_parser, _SelectedParseRejected + + try: + selected = _build_selected_parser("turn").parse_args(arguments) + except (SystemExit, _SelectedParseRejected) as exc: + raise ValueError("invalid delegation Turn arguments") from exc + workspace = workspace_identity[2] + selected_project = Path(selected.project) + if not selected_project.is_absolute(): + selected_project = workspace / selected_project + if (selected.execute or selected.resume_turn_key + or (selected.goal_id, selected.agent_id, selected.todo_id, selected.turn_instance_id) + != (service.goal_id, binding["agent_id"], binding["todo_id"], operation) + or selected_project.resolve() != workspace): + fault = recheck_workspace() + if fault is not None: + return fault + raise ValueError("delegation inspection cannot execute or retarget bound work") + try: + preview = service._cli(binding, *arguments) + except (OSError, ValueError, subprocess.TimeoutExpired): + fault = recheck_workspace() + if fault is not None: + return fault + raise + fault = recheck_workspace() + if fault is not None: + return fault + if preview.get("status") != "preview" and "selection_rejection" not in preview: + raise ValueError(f"delegation Turn preflight unavailable: {preview.get('error') or preview.get('status')}") + try: + current = capture(service, binding) + except (OSError, ValueError) as exc: + # Do not return the first acceptance or an already-read preview + # when current authority cannot be confirmed at the final fence. + return authority_fault(exc) + fault = recheck_workspace() + if fault is not None: + return fault + if acceptance != current or service.binding(binding_id, require_active=True) != binding: + raise ValueError("delegation preflight source changed; retry inspection") + return effect_runtime_result("collaboration.delegation.preflight", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "workspace": {"state": workspace_state}, + "authority": {"ready": True, "reason": None}, + "preview": preview, "acceptance": acceptance["plan"], + "validation_files_current": acceptance["files_current"], + "lane": {"state": turn_lane_liveness(service._lane_target(binding))["state"]}, + }) + + + def capture(service, binding: dict) -> dict: basis = inspect_goal_acceptance( registry_path=service.registry, runtime_root=str(service.root), goal_id=service.goal_id, agent_id=binding["agent_id"], todo_id=binding["todo_id"], validation_workspace_path=Path(binding["workspace"]), ) - todo = basis.get("todo") - if not isinstance(todo, dict): - raise ValueError("delegation canonical Todo unavailable") - goal = load_goal_from_registry(service.registry, service.goal_id) - state_file = goal_state_path(goal) if goal is not None else None - if state_file is None: - raise ValueError("delegation validation workspace unavailable") - declaration = resolve_private_completion_validation_declaration( - canonical_todo=todo, state_file=state_file, runtime_root=service.root, - registry_path=service.registry, goal_id=service.goal_id, - todo_id=binding["todo_id"], role=todo.get("role"), persist_if_resolved=False, - ) - plan = effect_runtime_result("collaboration.delegation.validation_plan", { - "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, - "basis": basis, "declaration": declaration, - }) + if binding["todo_id"] is None: + plan = effect_runtime_result("collaboration.delegation.validation_plan", { + "binding": binding, "basis": basis, "declaration": None, + }) + else: + todo = basis.get("todo") + if not isinstance(todo, dict): + raise ValueError("delegation canonical Todo unavailable") + goal = load_goal_from_registry(service.registry, service.goal_id) + state_file = goal_state_path(goal) if goal is not None else None + if state_file is None: + raise ValueError("delegation validation workspace unavailable") + declaration = resolve_private_completion_validation_declaration( + canonical_todo=todo, state_file=state_file, runtime_root=service.root, + registry_path=service.registry, goal_id=service.goal_id, + todo_id=binding["todo_id"], role=todo.get("role"), persist_if_resolved=False, + ) + plan = effect_runtime_result("collaboration.delegation.validation_plan", { + "binding": {key: binding[key] for key in ("id", "agent_id", "todo_id")}, + "basis": basis, "declaration": declaration, + }) # The configured worker worktree is the host-owned execution context. The # typed plan still owns which validators run; a path-free snapshot lets the # ordinary completion runner verify cross-repository Todo validators. @@ -68,11 +242,20 @@ def validate(service, binding: dict) -> dict: before = capture(service, binding) if before["plan"]["state"] != "ready" or not before["files_current"]: raise ValueError("delegation task acceptance rejected") - results = [run_goal_acceptance_validation_effect( - effect=effect, registry_path=service.registry, goal_id=service.goal_id, - delivery_workspace=before["delivery_workspace"], - validation_workspace_path=Path(binding["workspace"]), - ) for effect in before["plan"]["effects"]] + if binding["todo_id"] is None: + from ..runtime.validation_command import run_caller_validation + results = [run_caller_validation(Path(binding["workspace"]), + validation_argv=effect["validation_argv"], + validation_command=effect["validation_command"], + validation_label=effect["validation_label"], + timeout_seconds=effect["validation_timeout_seconds"]) + for effect in before["plan"]["effects"]] + else: + results = [run_goal_acceptance_validation_effect( + effect=effect, registry_path=service.registry, goal_id=service.goal_id, + delivery_workspace=before["delivery_workspace"], + validation_workspace_path=Path(binding["workspace"]), + ) for effect in before["plan"]["effects"]] after = capture(service, binding) # The TS plan binds this task's work, claim/lifecycle and current rules. # A whole-Goal provider revision may advance for unrelated peer progress. @@ -114,14 +297,21 @@ def committed_revalidation_key(service, row, binding): def record_revalidation_result(service, path, row, binding, result): from ..turn_driver.loop_controller import ValidatedTurnReceipt - receipt = ValidatedTurnReceipt.from_execution(result) + from ..effect_program import SettlementIdentity + subject = ordinary_request_subject(service, row, binding) + identity = (SettlementIdentity(goal_id=service.goal_id, agent_id=binding["agent_id"], + todo_id="", request_subject=subject, turn_instance_id=service._turn_instance_id(row)) + if subject is not None else None) + receipt = ValidatedTurnReceipt.from_execution(result, request_identity=identity) intent = row.get("validation_failure") or {} decision = effect_runtime_result("collaboration.delegation.revalidated", { "from": row["status"], "original_task_failure": intent.get("stage") == "task_postcondition" and intent.get("turn_key") == receipt.turn_key and receipt.turn_key == matching_turn_key(service, row, binding) - and receipt.lineage == {"goal_id": service.goal_id, "agent_id": binding["agent_id"], "todo_id": binding["todo_id"]}, + and receipt.lineage == {"goal_id": service.goal_id, "agent_id": binding["agent_id"], + "todo_id": binding["todo_id"] or "", + **({"request_binding_id": identity.binding_id} if identity is not None else {})}, "committed_progress": receipt.result_kind.value == "validated_progress", "host_reinvoked": result.get("effects", {}).get("host_invoked"), }) @@ -183,6 +373,26 @@ def record_turn_result( service._fenced_write(path, row) +def ordinary_request_subject(service, row: dict, binding: dict, *, require_active: bool = True) -> dict | None: + if binding["todo_id"] is not None: + return None + identity = row["identity"] + from .inbox import _entry + from .goal_instance_scope import collaboration_goal_scope, decide_collaboration_lifecycle + with collaboration_goal_scope(service.registry, goal_id=service.goal_id, agents=(binding["agent_id"],), + caller_goal_ref=service._caller_goal_ref(), require_active=require_active) as scope: + try: + entry = _entry(service.root, service.goal_id, binding["agent_id"], + identity.get("source_request_id") or identity["request_id"], scope=scope) + except OSError as exc: + raise ValueError("delegation request source unavailable for the captured Goal instance") from exc + decide_collaboration_lifecycle(scope, operation="read_record" if require_active else "history_inspect", record=entry) + return effect_runtime_result("collaboration.delegation.request_subject", { + "entry": entry, "binding": binding, + **({"expected": identity["request_subject"]} if "request_subject" in identity else {}), + }) + + def matching_turn_key(service, row: dict, binding: dict) -> str | None: """Find only the journal bound to this operation's settlement identity.""" @@ -192,6 +402,7 @@ def matching_turn_key(service, row: dict, binding: dict) -> str | None: agent_id=binding["agent_id"], todo_id=binding["todo_id"], turn_instance_id=service._turn_instance_id(row), + request_subject=ordinary_request_subject(service, row, binding, require_active=False), ) diff --git a/loopx/control_plane/effect_program.py b/loopx/control_plane/effect_program.py index d2b740ef8a..f283d6a9c1 100644 --- a/loopx/control_plane/effect_program.py +++ b/loopx/control_plane/effect_program.py @@ -6,6 +6,7 @@ from enum import StrEnum from functools import lru_cache from pathlib import Path +from types import MappingProxyType from typing import Any, Generic, Literal, TypeAlias, TypeVar from .effect_runtime import EffectRuntimeRejected, effect_runtime_result @@ -203,6 +204,7 @@ def require_turn_provider_step_kind( class SettlementBindingKind(StrEnum): TODO = "todo" AUTONOMOUS_REPLAN = "autonomous_replan" + REQUEST = "request" UNBOUND = "unbound" @@ -228,6 +230,7 @@ def _settlement_identity_full( todo_id: str | None, turn_instance_id: str, replan_obligation_id: str | None, + request_subject_json: str | None = None, ) -> tuple[dict[str, Any], dict[str, Any]]: try: result = effect_runtime_result( @@ -238,6 +241,7 @@ def _settlement_identity_full( "todo_id": todo_id, "turn_instance_id": turn_instance_id, "replan_obligation_id": replan_obligation_id, + **({"request_subject": json.loads(request_subject_json)} if request_subject_json is not None else {}), }, ) except EffectRuntimeRejected as exc: @@ -258,6 +262,7 @@ class SettlementIdentity: todo_id: str | None turn_instance_id: str replan_obligation_id: str | None = None + request_subject: Mapping[str, Any] | None = None _runtime_identity: Mapping[str, Any] = field( init=False, repr=False, @@ -276,11 +281,14 @@ def __post_init__(self) -> None: self.todo_id, self.turn_instance_id, self.replan_obligation_id, + json.dumps(dict(self.request_subject), sort_keys=True) if self.request_subject is not None else None, ) object.__setattr__(self, "todo_id", identity.get("todo_id")) object.__setattr__( self, "replan_obligation_id", identity.get("replan_obligation_id") ) + subject = identity.get("request_subject") + object.__setattr__(self, "request_subject", MappingProxyType(dict(subject)) if subject is not None else None) object.__setattr__(self, "_runtime_identity", identity) object.__setattr__(self, "_runtime_payload", payload) @@ -296,12 +304,13 @@ def binding_id(self) -> str: def effect_id(self) -> str: return str(self._runtime_identity["effect_id"]) - def as_dict(self) -> dict[str, str]: - return {key: str(value) for key, value in self._runtime_payload.items()} + def as_dict(self) -> dict[str, Any]: + return {**self._runtime_payload, + **({"request_subject": dict(self._runtime_payload["request_subject"])} if self.request_subject is not None else {})} @classmethod def from_runtime_payload(cls, payload: Any) -> SettlementIdentity: - """Decode a TS-owned identity without issuing a second runtime request.""" + """Decode a TS-owned identity; saved requests reuse the strict decoder.""" if not isinstance(payload, Mapping): raise RuntimeError("TypeScript settlement identity shape mismatch") @@ -321,6 +330,16 @@ def from_runtime_payload(cls, payload: Any) -> SettlementIdentity: replan_obligation_id = ( str(payload.get("replan_obligation_id") or "").strip() or None ) + request_subject = payload.get("request_subject") + if request_subject is not None: + # New request identities also enter through saved transaction plans. + # Reuse the typed decoder; Python equality accepts True == 1. + verified = cls(goal_id=str(payload["goal_id"]), agent_id=str(payload["agent_id"]), + todo_id=todo_id, replan_obligation_id=replan_obligation_id, + turn_instance_id=str(payload["turn_instance_id"]), request_subject=request_subject) + if verified.as_dict() != payload: + raise RuntimeError("TypeScript settlement identity shape mismatch") + return verified if bool(todo_id) == bool(replan_obligation_id): raise RuntimeError("TypeScript settlement identity shape mismatch") binding_kind = ( @@ -344,6 +363,7 @@ def from_runtime_payload(cls, payload: Any) -> SettlementIdentity: str(payload["turn_instance_id"]), ) object.__setattr__(instance, "replan_obligation_id", replan_obligation_id) + object.__setattr__(instance, "request_subject", request_subject) object.__setattr__( instance, "_runtime_identity", @@ -353,6 +373,7 @@ def from_runtime_payload(cls, payload: Any) -> SettlementIdentity: "todo_id": todo_id, "turn_instance_id": instance.turn_instance_id, "replan_obligation_id": replan_obligation_id, + **({"request_subject": request_subject} if request_subject is not None else {}), "binding_kind": binding_kind.value, "binding_id": str(binding_id), "effect_id": str(payload["effect_id"]), diff --git a/loopx/control_plane/effect_program.ts b/loopx/control_plane/effect_program.ts index 6fdd0af289..7e468d3642 100644 --- a/loopx/control_plane/effect_program.ts +++ b/loopx/control_plane/effect_program.ts @@ -3,6 +3,8 @@ import { EffectRuntimeRequestError } from "./effect_runtime_errors.ts"; import { isStringLiteral, type JsonObject } from "./runtime_decode.ts"; import { decodeInteractionContract } from "./work_items/interaction_contract.ts"; import { EffectiveAction } from "./quota/effective_action.generated.ts"; +import {BARE_SHA256_PATTERN} from "./content_digest.ts"; +import {canonicalAuthoritySha256} from "./coordination/authority_store_codec.ts"; export { RECEIPT_BOUND_MONITOR_PHASES, @@ -107,6 +109,7 @@ export const TURN_PROVIDER_STEP_KINDS = SETTLEMENT_STEP_KINDS.filter( export const SETTLEMENT_BINDING_KINDS = [ "todo", "autonomous_replan", + "request", "unbound", ] as const; export type SettlementBindingKind = (typeof SETTLEMENT_BINDING_KINDS)[number]; @@ -133,6 +136,29 @@ export interface SettlementIdentityInput { todo_id?: string | null; turn_instance_id: string; replan_obligation_id?: string | null; + request_subject?: ExecutionRequestSubject | null; +} + +/** Identity of already admitted work, never an execution or permission grant. */ +export interface ExecutionRequestSubject extends JsonObject { + request_id: string; + request_revision: number; + request_digest: string; + execution_scope_sha256: string; +} + +export function executionRequestSubject(value: unknown): ExecutionRequestSubject | null { + if (value === undefined || value === null) return null; + if (typeof value !== "object" || Array.isArray(value)) + throw new EffectRuntimeRequestError("request subject must be an object"); + const subject = value as JsonObject; + const digests = ["request_id", "request_digest", "execution_scope_sha256"]; + if (Object.keys(subject).length !== 4 || !digests.every(key => + typeof subject[key] === "string" && BARE_SHA256_PATTERN.test(subject[key] as string)) + || !Number.isSafeInteger(subject.request_revision) || Number(subject.request_revision) < 1) + throw new EffectRuntimeRequestError("request subject requires exact request/revision/digest/scope identity"); + return {request_id: subject.request_id as string, request_revision: subject.request_revision as number, + request_digest: subject.request_digest as string, execution_scope_sha256: subject.execution_scope_sha256 as string}; } export type SettlementIdentity = Readonly<{ @@ -140,9 +166,11 @@ export type SettlementIdentity = Readonly<{ agent_id: string; turn_instance_id: string; effect_id: string; + request_subject?: ExecutionRequestSubject | null; } & ( | { binding_kind: "todo"; binding_id: string; todo_id: string; replan_obligation_id: null } | { binding_kind: "autonomous_replan"; binding_id: string; todo_id: null; replan_obligation_id: string } + | { binding_kind: "request"; binding_id: string; todo_id: null; replan_obligation_id: null } | { binding_kind: "unbound"; binding_id: ""; todo_id: null; replan_obligation_id: null } )>; @@ -439,15 +467,18 @@ export function settlementIdentity( const todoId = truthyString(input.todo_id).trim() || null; const replanObligationId = truthyString(input.replan_obligation_id).trim() || null; - if (todoId && replanObligationId) { + const requestSubject = executionRequestSubject(input.request_subject); + if ([todoId, replanObligationId, requestSubject].filter(Boolean).length > 1) { throw new EffectRuntimeRequestError( - "settlement identity cannot bind both todo_id and replan_obligation_id", + requestSubject ? "settlement identity requires one Todo, replan obligation or request subject" + : "settlement identity cannot bind both todo_id and replan_obligation_id", ); } const common = { goal_id: input.goal_id, agent_id: input.agent_id, turn_instance_id: input.turn_instance_id, + ...(requestSubject ? {request_subject: requestSubject} : {}), }; if (todoId) { return { @@ -469,6 +500,12 @@ export function settlementIdentity( effect_id: `${input.goal_id}:${input.agent_id}:autonomous_replan:${replanObligationId}:${input.turn_instance_id}`, }; } + if (requestSubject) { + const bindingId = canonicalAuthoritySha256(requestSubject); + return {...common, todo_id: null, replan_obligation_id: null, + binding_kind: "request", binding_id: bindingId, + effect_id: `${input.goal_id}:${input.agent_id}:request:${bindingId}:${input.turn_instance_id}`}; + } return { ...common, todo_id: null, @@ -483,7 +520,7 @@ export function settlementIdentityPayload( identityValue: SettlementIdentityInput, ): JsonObject { const identity = settlementIdentity(identityValue); - if (identity.todo_id || !identity.replan_obligation_id) { + if (identity.todo_id || identity.binding_kind === "unbound") { return { schema_version: SETTLEMENT_IDENTITY_SCHEMA_VERSION, effect_id: identity.effect_id, @@ -501,7 +538,8 @@ export function settlementIdentityPayload( turn_instance_id: identity.turn_instance_id, binding_kind: identity.binding_kind, binding_id: identity.binding_id, - replan_obligation_id: identity.replan_obligation_id, + ...(identity.request_subject ? {request_subject: identity.request_subject} + : {replan_obligation_id: identity.replan_obligation_id}), }; } @@ -708,7 +746,10 @@ export function settlementIdentityFromPlan( const replanObligationId = truthyString( identityObject.replan_obligation_id, ).trim(); - if (todoId && replanObligationId) { + let requestSubject: ExecutionRequestSubject | null; + try { requestSubject = executionRequestSubject(identityObject.request_subject); } + catch (error) { return settlementFailed({kind: "invalid_identity", step_kind: "validation", reason: (error as Error).message}); } + if ([todoId, replanObligationId, requestSubject].filter(Boolean).length > 1) { return settlementFailed({ kind: "invalid_identity", step_kind: "validation", @@ -722,6 +763,7 @@ export function settlementIdentityFromPlan( todo_id: todoId || null, turn_instance_id: turnInstanceId, replan_obligation_id: replanObligationId || null, + request_subject: requestSubject, }); // Unbound identities remain useful for non-executable plans, never settlement. if (built.binding_kind === "unbound") { diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d98eb1db88..7a435cacfd 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -7,6 +7,7 @@ import { settlementBindReduce, settlementReceipt, settlementIdentity, + executionRequestSubject, settlementIdentityPayload, settlementPlanPayload, settlementResultPayload, @@ -103,6 +104,7 @@ function settlementIdentityInput( input.replan_obligation_id, `${label}.replan_obligation_id`, ), + ...(input.request_subject === undefined ? {} : {request_subject: executionRequestSubject(input.request_subject)}), }; } @@ -609,6 +611,8 @@ export function createEffectRuntimeHandlers( ], ["collaboration.delegation.binding", lazyHandler(() => import("./collaboration/delegation.ts"), ({selectDelegationBinding}) => selectDelegationBinding)], ["collaboration.delegation.source_replay", lazyHandler(() => import("./collaboration/delegation.ts"), ({verifyDelegationSourceReplay}) => verifyDelegationSourceReplay)], + ["collaboration.delegation.request_decision", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationRequestDecision}) => delegationRequestDecision)], + ["collaboration.delegation.request_subject", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationRequestSubject}) => params => delegationRequestSubject(requiredObject(params.entry, "request entry"), requiredObject(params.binding, "binding"), params.expected))], ["collaboration.delegation.turn_context", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationTurnWorkContext}) => delegationTurnWorkContext)], ["turn.selection.rejection", lazyHandler(() => import("./turn_driver/selection_rejection.ts"), ({projectTurnSelectionRejection}) => projectTurnSelectionRejection)], ["collaboration.delegation.preflight", lazyHandler(() => import("./collaboration/delegation.ts"), ({delegationPreflight}) => delegationPreflight)], diff --git a/loopx/control_plane/quota/effect_program.py b/loopx/control_plane/quota/effect_program.py index 11ac4a77c3..6be53f0c1c 100644 --- a/loopx/control_plane/quota/effect_program.py +++ b/loopx/control_plane/quota/effect_program.py @@ -135,6 +135,7 @@ def build_turn_scoped_cli_settlement_plan( command_prefix: str = "loopx", todo_id: str | None = None, replan_obligation_id: str | None = None, + request_subject: Mapping[str, Any] | None = None, scoped_cli_args: str, lifecycle_actor_args: str, turn_instance_id: str, @@ -144,7 +145,7 @@ def build_turn_scoped_cli_settlement_plan( goal_ref: Mapping[str, object] | None = None, autonomous_replan: bool = False, ) -> SettlementPlan: - if bool(todo_id) == bool(replan_obligation_id): + if sum(bool(value) for value in (todo_id, replan_obligation_id, request_subject)) != 1: raise ValueError( "turn-scoped CLI settlement requires exactly one Todo or autonomous " "replan obligation binding" @@ -160,6 +161,7 @@ def build_turn_scoped_cli_settlement_plan( todo_id=todo_id, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id, + request_subject=request_subject, ) scoped_cli_args = _settlement_actor_args(scoped_cli_args, identity.agent_id) lifecycle_actor_args = _settlement_actor_args(lifecycle_actor_args, identity.agent_id) @@ -168,7 +170,7 @@ def build_turn_scoped_cli_settlement_plan( binding_arg = ( f" --todo-id {shlex.quote(todo_id)}" if todo_id - else f" --replan-obligation-id {shlex.quote(str(replan_obligation_id))}" + else f" --replan-obligation-id {shlex.quote(str(replan_obligation_id))}" if replan_obligation_id else "" ) turn_arg = f" --turn-instance-id {quoted_turn}" goal_ref_arg = ( diff --git a/loopx/control_plane/quota/heartbeat_receipt.py b/loopx/control_plane/quota/heartbeat_receipt.py index 45c66925e2..2c3d7b36d4 100644 --- a/loopx/control_plane/quota/heartbeat_receipt.py +++ b/loopx/control_plane/quota/heartbeat_receipt.py @@ -56,6 +56,15 @@ def _heartbeat_receipt_events( ] +def heartbeat_receipt_request_subject(event: Mapping[str, object] | None) -> dict | None: + """Transport the original request's path-free identity from scalar event details.""" + details = (event or {}).get("details") + if not isinstance(details, Mapping) or "request_id" not in details: + return None + return {key: details.get(key) for key in ( + "request_id", "request_revision", "request_digest", "execution_scope_sha256")} + + def _receipt_settlement_identity( event: Mapping[str, object], ) -> tuple[SettlementBindingKind, str, str] | None: @@ -68,16 +77,17 @@ def _receipt_settlement_identity( details.get("replan_obligation_id") ) effect_id = str(details.get("settlement_effect_id") or "").strip() - if todo_id and replan_obligation_id: + request_subject = heartbeat_receipt_request_subject(event) + if sum(bool(value) for value in (todo_id, replan_obligation_id, request_subject)) > 1: raise HeartbeatReceiptIdentityConflictError( "heartbeat receipt has conflicting Todo and autonomous replan bindings" ) - if effect_id and not todo_id and not replan_obligation_id: + if effect_id and not todo_id and not replan_obligation_id and not request_subject: raise HeartbeatReceiptIdentityConflictError( "heartbeat receipt has an effect identity without a Todo or " "autonomous replan binding; refuse to infer or upgrade it" ) - if not todo_id and not replan_obligation_id: + if not todo_id and not replan_obligation_id and not request_subject: return None identity = SettlementIdentity( goal_id=str(event.get("goal_id") or "").strip(), @@ -85,6 +95,7 @@ def _receipt_settlement_identity( todo_id=todo_id or None, turn_instance_id=str(event.get("run_id") or "").strip(), replan_obligation_id=replan_obligation_id, + request_subject=request_subject, ) if not effect_id: effect_id = identity.effect_id @@ -281,6 +292,7 @@ def ensure_turn_heartbeat_settlement_receipt( "turn_instance_id": identity.turn_instance_id, "todo_id": str(identity.todo_id or ""), "replan_obligation_id": str(identity.replan_obligation_id or ""), + **(dict(identity.request_subject) if identity.request_subject is not None else {}), "settlement_effect_id": identity.effect_id, "stall_observation": "not_applicable", "source": "loopx_turn_run_once", @@ -606,13 +618,15 @@ def heartbeat_receipt_view( details.get("replan_obligation_id") ) effect_id = str(details.get("settlement_effect_id") or "").strip() - if (todo_id or replan_obligation_id) and effect_id: + request_subject = heartbeat_receipt_request_subject(event) + if (todo_id or replan_obligation_id or request_subject) and effect_id: identity = SettlementIdentity( goal_id=str(event.get("goal_id") or ""), agent_id=str(event.get("agent_id") or ""), todo_id=todo_id or None, turn_instance_id=turn_instance_id, replan_obligation_id=replan_obligation_id, + request_subject=request_subject, ) receipt["settlement_identity"] = identity.as_dict() workspace_causality = delivery_workspace_causality_from_event_details( diff --git a/loopx/control_plane/quota/heartbeat_receipt_identity.ts b/loopx/control_plane/quota/heartbeat_receipt_identity.ts index 0166797905..4eaa75d083 100644 --- a/loopx/control_plane/quota/heartbeat_receipt_identity.ts +++ b/loopx/control_plane/quota/heartbeat_receipt_identity.ts @@ -10,7 +10,7 @@ * this rule, so it lives here rather than in either caller. */ import type { JsonObject } from "../effect_program.ts"; -import {settlementIdentity, type SettlementIdentity} from "../effect_program.ts"; +import {executionRequestSubject, settlementIdentity, type SettlementIdentity, type ExecutionRequestSubject} from "../effect_program.ts"; import { EffectRuntimeRequestError } from "../effect_runtime_errors.ts"; import { jsonObject } from "../runtime_decode.ts"; import {nativeChildReportAdmission} from "../capabilities/native_child_admission.ts"; @@ -49,7 +49,7 @@ export function normalizeHeartbeatReplanObligationId( } export interface HeartbeatReceiptBinding extends JsonObject { - binding_kind: "todo" | "autonomous_replan"; + binding_kind: "todo" | "autonomous_replan" | "request"; /** The Todo id or autonomous replan obligation id, per `binding_kind`. */ binding_id: string; /** @@ -77,6 +77,7 @@ export interface HeartbeatReceiptFact extends JsonObject { run_id: string | null; todo_id: string | null; replan_obligation_id: string | null; + request_subject?: ExecutionRequestSubject | null; settlement_effect_id: string | null; closeout_required: boolean; } @@ -92,6 +93,9 @@ export function heartbeatReceiptFactFromEvent( replan_obligation_id: optionalHeartbeatString( eventDetails.replan_obligation_id, ), + ...(!Object.hasOwn(eventDetails, "request_id") ? {} : {request_subject: executionRequestSubject({ + request_id: eventDetails.request_id, request_revision: eventDetails.request_revision, + request_digest: eventDetails.request_digest, execution_scope_sha256: eventDetails.execution_scope_sha256})}), settlement_effect_id: optionalHeartbeatString( eventDetails.settlement_effect_id, ), @@ -115,17 +119,18 @@ export function heartbeatReceiptBinding( fact.replan_obligation_id, ); const declaredEffectId = optionalHeartbeatString(fact.settlement_effect_id); - if (declaredTodoId && replanObligationId) { + const requestSubject = executionRequestSubject(fact.request_subject); + if ([declaredTodoId, replanObligationId, requestSubject].filter(Boolean).length > 1) { throw receiptIdentityConflict( "heartbeat receipt has conflicting Todo and autonomous replan bindings", ); } - if (declaredEffectId && !declaredTodoId && !replanObligationId) { + if (declaredEffectId && !declaredTodoId && !replanObligationId && !requestSubject) { throw receiptIdentityConflict( "heartbeat receipt has an effect identity without a Todo or autonomous replan binding; refuse to infer or upgrade it", ); } - if (!declaredTodoId && !replanObligationId) return null; + if (!declaredTodoId && !replanObligationId && !requestSubject) return null; // A Todo-binding the settlement authority cannot address is not a binding we // may act on: acting on the raw string would create a recovery obligation // whose identity no other reader can reproduce, and dropping it would let a @@ -141,12 +146,10 @@ export function heartbeatReceiptBinding( agent_id: agentId, todo_id: todoId, turn_instance_id: fact.run_id ?? "", - replan_obligation_id: replanObligationId, + replan_obligation_id: replanObligationId, request_subject: requestSubject, }); return { - binding_kind: identity.binding_kind === "todo" - ? "todo" - : "autonomous_replan", + binding_kind: identity.binding_kind as HeartbeatReceiptBinding["binding_kind"], binding_id: identity.binding_id, settlement_effect_id: declaredEffectId, identity_key: `${identity.binding_kind}\u0000${identity.binding_id}\u0000${ diff --git a/loopx/control_plane/quota/settlement_phase.ts b/loopx/control_plane/quota/settlement_phase.ts index f2c867aaa6..4be61f77ba 100644 --- a/loopx/control_plane/quota/settlement_phase.ts +++ b/loopx/control_plane/quota/settlement_phase.ts @@ -263,7 +263,7 @@ export type ReceiptBoundReplayPhase = (typeof RECEIPT_BOUND_REPLAY_PHASES)[number]; export interface ReceiptBoundReplaySettlementState { - binding_kind?: "todo" | "autonomous_replan" | "unbound"; + binding_kind?: "todo" | "autonomous_replan" | "request" | "unbound"; /** A validated writeback discharges the binding without Todo completion. */ writeback_completes_binding?: boolean; completion_receipt_present: boolean; @@ -278,7 +278,7 @@ export interface ReceiptBoundReplaySettlementState { export function receiptBoundReplayPhase( state: ReceiptBoundReplaySettlementState, ): ReceiptBoundReplayPhase { - const bindingComplete = state.binding_kind === "autonomous_replan" || + const bindingComplete = state.binding_kind === "autonomous_replan" || state.binding_kind === "request" || state.writeback_completes_binding === true ? state.durable_writeback_present : state.completion_receipt_present || diff --git a/loopx/control_plane/quota/settlement_readback.ts b/loopx/control_plane/quota/settlement_readback.ts index 17555a7f66..d2c52cbb4b 100644 --- a/loopx/control_plane/quota/settlement_readback.ts +++ b/loopx/control_plane/quota/settlement_readback.ts @@ -11,7 +11,7 @@ import { effectIdsMatch, settlementBindReduce, settlementFailed, - settlementIdentity, + executionRequestSubject, settlementIdentity, settlementIdentityPayload, settlementPure, settlementReceipt, @@ -575,7 +575,7 @@ function findWriteback( isTurnScopedSettlementOutcome( run.delivery_outcome, run.progress_observation, - identity.todo_id ?? identity.replan_obligation_id, + identity.todo_id ?? identity.replan_obligation_id ?? (identity.request_subject ? identity.binding_id : null), ) ) ?? null; } @@ -852,6 +852,15 @@ function resolveIdentity( request.replan_obligation_id, ); let turnInstanceId = request.turn_instance_id; + let requestSubject = null; + if (!todoId && !replanObligationId && agentId && turnInstanceId) { + try { + const original = effectiveHeartbeatReceipt(events, {goal_id: request.goal_id, agent_id: agentId, turn_instance_id: turnInstanceId}); + requestSubject = executionRequestSubject(original ? heartbeatReceiptFactFromEvent(original).request_subject : null); + } catch (error) { + return failedIdentity((error as Error).message, "identity_mismatch"); + } + } if (request.infer_turn_instance_id) { if ( !agentId || @@ -894,7 +903,7 @@ function resolveIdentity( const fact = heartbeatReceiptFactFromEvent(guard); const originalTodoId = normalizeTodoId(fact.todo_id); const originalReplanId = normalizeReplanObligationId(fact.replan_obligation_id); - if (!originalTodoId && !originalReplanId) { + if (!originalTodoId && !originalReplanId && !requestSubject) { return failedIdentity("the original Turn guard has no settlement binding", "receipt_unbound"); } if ((request.todo_id !== null && (todoId === null || todoId !== originalTodoId)) || @@ -905,7 +914,7 @@ function resolveIdentity( todoId = originalTodoId; replanObligationId = originalReplanId; } - if (!agentId || !turnInstanceId || Boolean(todoId) === Boolean(replanObligationId)) { + if (!agentId || !turnInstanceId || [todoId, replanObligationId, requestSubject].filter(Boolean).length !== 1) { return failedIdentity( "turn-scoped settlement requires agent_id, turn_instance_id, and exactly one todo_id or replan_obligation_id", "invalid_identity", @@ -916,7 +925,7 @@ function resolveIdentity( agent_id: agentId, todo_id: todoId, turn_instance_id: turnInstanceId, - replan_obligation_id: replanObligationId, + replan_obligation_id: replanObligationId, request_subject: requestSubject, }); let receiptEvent: JsonObject | null; try { @@ -935,7 +944,7 @@ function resolveIdentity( const receiptReplanObligationId = normalizeReplanObligationId( receiptDetails.replan_obligation_id, ); - if (receiptTodoId === null && receiptReplanObligationId === null) { + if (receiptTodoId === null && receiptReplanObligationId === null && heartbeatReceiptFactFromEvent(receiptEvent).request_subject == null) { // A same-turn guard that ran before any work item was chosen commits a // receipt with no settlement binding, and the documented wake order (guard, // then select) produces exactly that state. This read model never binds -- @@ -1187,6 +1196,7 @@ function readQuotaSettlementFromRequest( todo_id: optionalString(identityResult.value.todo_id), turn_instance_id: String(identityResult.value.turn_instance_id), replan_obligation_id: optionalString(identityResult.value.replan_obligation_id), + request_subject: executionRequestSubject(identityResult.value.request_subject), }); const events = indexedEvents( snapshot, diff --git a/loopx/control_plane/quota/settlement_workspace_causality.ts b/loopx/control_plane/quota/settlement_workspace_causality.ts index 5f98a629ae..3270b1409d 100644 --- a/loopx/control_plane/quota/settlement_workspace_causality.ts +++ b/loopx/control_plane/quota/settlement_workspace_causality.ts @@ -49,7 +49,7 @@ type DeliveryWorkspaceCausalityOperation = export interface SettlementWorkspaceRequirement extends JsonObject { schema_version: typeof SETTLEMENT_WORKSPACE_REQUIREMENT_SCHEMA_VERSION; - settlement_binding_kind: "todo" | "autonomous_replan"; + settlement_binding_kind: "todo" | "autonomous_replan" | "request"; requirement: DeliveryWorkspaceRequirement; source: string; reason: string; @@ -154,7 +154,7 @@ export function resolveSettlementWorkspaceRequirement( ): SettlementWorkspaceRequirement { if ( settlementBindingKind !== "todo" && - settlementBindingKind !== "autonomous_replan" + settlementBindingKind !== "autonomous_replan" && settlementBindingKind !== "request" ) { throw new EffectRuntimeRequestError( "settlement_binding_kind must be todo or autonomous_replan", @@ -170,13 +170,14 @@ export function resolveSettlementWorkspaceRequirement( reason: causality.reason, }; } - if (settlementBindingKind === "autonomous_replan") { + if (settlementBindingKind === "autonomous_replan" || settlementBindingKind === "request") { return { schema_version: SETTLEMENT_WORKSPACE_REQUIREMENT_SCHEMA_VERSION, settlement_binding_kind: settlementBindingKind, requirement: "not_required", source: "typed_settlement_identity", - reason: "autonomous_replan_is_non_repository_control_plane_work", + reason: settlementBindingKind === "request" ? "request_uses_operator_bound_workspace" + : "autonomous_replan_is_non_repository_control_plane_work", }; } if (hasCompleteLegacySettlementReceipts(legacySettlementEvidence)) { diff --git a/loopx/control_plane/quota/turn_envelope.py b/loopx/control_plane/quota/turn_envelope.py index 7fe75dc784..8c9a334bf4 100644 --- a/loopx/control_plane/quota/turn_envelope.py +++ b/loopx/control_plane/quota/turn_envelope.py @@ -103,6 +103,16 @@ def turn_envelope_replan_settlement_identity( return dict(identity) +def turn_envelope_scoped_settlement_identity( + envelope: Mapping[str, Any], *, turn_instance_id: str | None, +) -> dict[str, Any] | None: + identity = _turn_envelope_result("scoped_settlement_identity", envelope=dict(envelope), + turn_instance_id=turn_instance_id).get("identity") + if identity is not None and not isinstance(identity, Mapping): + raise RuntimeError("TypeScript Turn settlement identity must be an object") + return dict(identity) if identity is not None else None + + def build_turn_envelope( payload: Mapping[str, Any], *, diff --git a/loopx/control_plane/quota/turn_envelope.ts b/loopx/control_plane/quota/turn_envelope.ts index bfcf7c5c9d..66d36ac857 100644 --- a/loopx/control_plane/quota/turn_envelope.ts +++ b/loopx/control_plane/quota/turn_envelope.ts @@ -1468,6 +1468,8 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { if (operation === "native_context_lifecycle") return updateNativeContextLifecycle(request); if (operation === "context_recovery") return restoreContextDelivery(request, LEGACY_QUOTA_CONTEXT_FIELDS); if (operation === "bind_capture") return bindTurnDecisionCapture(request); + if (operation === "scoped_settlement_identity") return {identity: turnEnvelopeScopedSettlementIdentity( + requireJsonObject(request.envelope, "envelope"), request.turn_instance_id)}; if (operation === "replan_settlement_identity") return {identity: turnEnvelopeReplanSettlementIdentity( requireJsonObject(request.envelope, "turn_envelope"), request.turn_instance_id, )}; @@ -1483,28 +1485,35 @@ export function evaluateTurnEnvelope(value: unknown): JsonObject { /** A scoped planning identity comes from the signed quota plan, never a Host * session or a missing Todo. Journal validation reuses this exact binding rule. */ -export function turnEnvelopeReplanSettlementIdentity(envelope: JsonObject, turnInstanceId?: unknown): JsonObject | null { +export function turnEnvelopeScopedSettlementIdentity(envelope: JsonObject, turnInstanceId?: unknown): JsonObject | null { assertSupportedQuotaPacket(envelope); const writeback = object(envelope.writeback); const plan = Object.keys(object(writeback.settlement_plan)).length ? object(writeback.settlement_plan) : {identity: writeback.settlement_identity}; const raw = object(plan.identity); - if (raw.replan_obligation_id == null && raw.binding_kind !== "autonomous_replan") return null; + if (raw.replan_obligation_id == null && raw.request_subject == null && raw.binding_kind !== "autonomous_replan") return null; const parsed = settlementIdentityFromPlan({settlement_plan: plan}); if (parsed.failure !== null) throw new EffectRuntimeRequestError(parsed.failure.reason); const identity = parsed.value; const signature = object(envelope.action_signature); if (envelope.schema_version !== TURN_ENVELOPE_SCHEMA_VERSION - || envelope.effective_action !== EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED + || (identity.binding_kind === "request" ? envelope.effective_action !== EffectiveAction.NORMAL_RUN + : envelope.effective_action !== EffectiveAction.AUTONOMOUS_REPLAN_REQUIRED) || selectedWorkTodoId({selected_todo: object(envelope.action).selected_todo, task_orchestration_contract: envelope.task_orchestration_contract}) !== null - || identity.binding_kind !== "autonomous_replan" + || !["autonomous_replan", "request"].includes(identity.binding_kind) || identity.goal_id !== envelope.goal_id || identity.agent_id !== envelope.agent_id || turnInstanceId != null && identity.turn_instance_id !== turnInstanceId - || identity.replan_obligation_id !== object(envelope.replan_action_packet).obligation_id + || identity.binding_kind === "autonomous_replan" && identity.replan_obligation_id !== object(envelope.replan_action_packet).obligation_id || signature.matches !== true || signature.source_hash !== signature.envelope_hash || signature.envelope_hash !== canonicalHash(turnEnvelopeActionSignatureDocument(envelope))) { throw new EffectRuntimeRequestError("Turn replan settlement identity does not match its signed quota admission"); } return settlementIdentityPayload(identity); } + +/** Persisted planning callers retain their narrow compatibility entrypoint. */ +export function turnEnvelopeReplanSettlementIdentity(envelope: JsonObject, turnInstanceId?: unknown): JsonObject | null { + const identity = turnEnvelopeScopedSettlementIdentity(envelope, turnInstanceId); + return identity?.binding_kind === "autonomous_replan" ? identity : null; +} diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 2d136dca81..a78a9d4ea3 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -155,8 +155,13 @@ class CliOutputCommandClassification: # Retain complete routes/context and bounded host-path headroom; # line, per-Todo/fixed growth and all other ceilings stay unchanged. "crowded": {"json": 42_000, "markdown": 10_500}, - # Matched canonical base/head both emit 24,820 JSON characters. - "multi_agent": {"json": 25_500, "markdown": 7_000}, + # The unchanged 18-Todo / 3-Agent fixture on main ffc90182b and + # this candidate both emit 25,836 JSON characters (96 matched + # surface/mode measurements, no semantic or route differences). + # Preserve complete decision facts; calibrate only this synthetic + # presentation ceiling. The historical 25,500 failure remains; + # line/growth guards and production transport limits are unchanged. + "multi_agent": {"json": 26_500, "markdown": 7_000}, }, max_lines={ "small": {"json": 600, "markdown": 190}, diff --git a/loopx/control_plane/turn_driver/driver.py b/loopx/control_plane/turn_driver/driver.py index 0fc5fefeb6..ed9e5ec6e7 100644 --- a/loopx/control_plane/turn_driver/driver.py +++ b/loopx/control_plane/turn_driver/driver.py @@ -20,7 +20,7 @@ from ..quota.turn_envelope import ( assert_turn_envelope_consumer_version, _turn_envelope_result, - turn_envelope_replan_settlement_identity, + turn_envelope_scoped_settlement_identity, ) from ..scheduler.execution_context import ( scheduler_execution_context_for_turn, @@ -101,7 +101,7 @@ def selected_turn_todo(envelope: Mapping[str, Any]) -> dict[str, Any]: return _mapping(action.get("selected_todo")) -def _receipt_bound_replan_identity( +def _receipt_bound_scoped_identity( envelope: Mapping[str, Any], *, turn_instance_id: str | None, ) -> SettlementIdentity | None: """Transport the quota owner's signed binding; never invent an obligation. @@ -110,7 +110,7 @@ def _receipt_bound_replan_identity( planning needs the existing scoped identity from the complete settlement plan, as well as its matching semantic obligation and original Turn. """ - raw = turn_envelope_replan_settlement_identity(envelope, turn_instance_id=turn_instance_id) + raw = turn_envelope_scoped_settlement_identity(envelope, turn_instance_id=turn_instance_id) if raw is None: return None return SettlementIdentity.from_runtime_payload(raw) @@ -147,7 +147,10 @@ def _turn_lineage( "action_hash": action_hash, } if settlement_identity is not None: - lineage["replan_obligation_id"] = str(settlement_identity.replan_obligation_id) + if settlement_identity.request_subject is not None: + lineage["request_binding_id"] = settlement_identity.binding_id + elif settlement_identity.replan_obligation_id is not None: + lineage["replan_obligation_id"] = settlement_identity.replan_obligation_id return lineage @@ -160,6 +163,8 @@ def _session_plan( session_scope: str = "agent", ) -> tuple[dict[str, Any], str | None]: identity_fields = session_identity_fields(session_scope) + if lineage.get("request_binding_id") and iteration_context_policy == "fresh": + identity_fields = ("goal_id", "agent_id") context_policy = { "schema_version": LOOPX_ITERATION_CONTEXT_POLICY_SCHEMA_VERSION, "mode": iteration_context_policy, @@ -179,7 +184,7 @@ def _session_plan( "context_policy": context_policy, }, None if (not all(lineage.get(field) for field in (*identity_fields, "action_hash")) - or not (lineage.get("todo_id") or lineage.get("replan_obligation_id"))): + or not (lineage.get("todo_id") or lineage.get("replan_obligation_id") or lineage.get("request_binding_id"))): return { "schema_version": LOOPX_TURN_SESSION_BINDING_SCHEMA_VERSION, "action": "reject", @@ -236,7 +241,7 @@ def reconcile_failed_turn_session_request( envelope = _mapping(request.get("turn_envelope")) selected_todo = selected_turn_todo(envelope) try: - settlement_identity = (None if selected_todo else _receipt_bound_replan_identity( + settlement_identity = (None if selected_todo else _receipt_bound_scoped_identity( envelope, turn_instance_id=None, )) except (ValueError, RuntimeError) as exc: @@ -411,16 +416,17 @@ def build_loopx_turn_plan( selected_todo = selected_turn_todo(envelope) settlement_identity = None identity_error = None - if route is LoopXTurnRoute.REPLAN_REQUIRED: + if route is LoopXTurnRoute.REPLAN_REQUIRED or (route is LoopXTurnRoute.READY_FOR_HOST and not selected_todo): try: - settlement_identity = _receipt_bound_replan_identity( + settlement_identity = _receipt_bound_scoped_identity( envelope, turn_instance_id=turn_instance_id, ) except (ValueError, RuntimeError) as exc: identity_error = str(exc) route = LoopXTurnRoute.CONTRACT_ERROR if (not selected_todo and identity_error is None - and (settlement_identity is None or session_scope != "agent")): + and (settlement_identity is None or (session_scope != "agent" + and not (settlement_identity.request_subject is not None and iteration_context_policy == "fresh")))): route = LoopXTurnRoute.BLOCKED lineage = _turn_lineage(envelope, selected_todo=selected_todo, settlement_identity=settlement_identity, projection=projection) diff --git a/loopx/control_plane/turn_driver/executor.py b/loopx/control_plane/turn_driver/executor.py index ad0f412862..f25d8ae889 100644 --- a/loopx/control_plane/turn_driver/executor.py +++ b/loopx/control_plane/turn_driver/executor.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from contextlib import AbstractContextManager, nullcontext from collections.abc import Callable, Mapping, Sequence from functools import partial from pathlib import Path @@ -1458,6 +1459,7 @@ def run_loopx_turn_once( terminal_closeout_resolver: TurnEffectResolver | None = None, scheduler: Scheduler | None = None, post_settlement: PostSettlement | None = None, + settlement_admission: Callable[[], AbstractContextManager] | None = None, admit_start: Callable[[Mapping[str, Any]], dict[str, Any]] | None = None, confirm_start: Callable[[], None] | None = None, goal_admission: FirstPartyHostGoalAdmission | None = None, @@ -1750,37 +1752,38 @@ def finish_recovery(payload: dict[str, Any]) -> dict[str, Any]: if terminal is not None: return finish_recovery(terminal) - settled = _typed_settlement_stage( - plan, - result, - completed_phases=completed_phases, - journal=journal, - persist_journal=persist_journal, - effects=effects, - writeback=writeback, - completion_writeback=completion_writeback, - completion_intent=completion_intent, - terminal_closeout=terminal_closeout, - spend=spend, - effect_resolvers=turn_effect_resolvers( - writeback=writeback_resolver, - spend=spend_resolver, - terminal_closeout=terminal_closeout_resolver, - ), - scheduler=scheduler, - post_settlement=post_settlement, - source_effects=( - goal_admission.turn_effect_admission( - turn_key=turn_key, - journal_path=journal_path, - ) - if goal_admission is not None - else None - ), - persist_source_journal=( - persist_source_journal - if goal_admission is not None and goal_admission.enabled - else None - ), - ) + with settlement_admission() if settlement_admission is not None else nullcontext(): + settled = _typed_settlement_stage( + plan, + result, + completed_phases=completed_phases, + journal=journal, + persist_journal=persist_journal, + effects=effects, + writeback=writeback, + completion_writeback=completion_writeback, + completion_intent=completion_intent, + terminal_closeout=terminal_closeout, + spend=spend, + effect_resolvers=turn_effect_resolvers( + writeback=writeback_resolver, + spend=spend_resolver, + terminal_closeout=terminal_closeout_resolver, + ), + scheduler=scheduler, + post_settlement=post_settlement, + source_effects=( + goal_admission.turn_effect_admission( + turn_key=turn_key, + journal_path=journal_path, + ) + if goal_admission is not None + else None + ), + persist_source_journal=( + persist_source_journal + if goal_admission is not None and goal_admission.enabled + else None + ), + ) return finish_recovery(settled) diff --git a/loopx/control_plane/turn_driver/journal_store.py b/loopx/control_plane/turn_driver/journal_store.py index 80a9dd4339..16638a7f21 100644 --- a/loopx/control_plane/turn_driver/journal_store.py +++ b/loopx/control_plane/turn_driver/journal_store.py @@ -140,8 +140,9 @@ def find_loopx_turn_key_by_settlement_identity( *, goal_id: str, agent_id: str, - todo_id: str, + todo_id: str | None, turn_instance_id: str, + request_subject: Mapping[str, Any] | None = None, ) -> str | None: """Find one validated history; unreadable or conflicting history blocks retry.""" @@ -152,6 +153,7 @@ def find_loopx_turn_key_by_settlement_identity( "agent_id": agent_id, "todo_id": todo_id, "turn_instance_id": turn_instance_id, + **({"request_subject": dict(request_subject)} if request_subject is not None else {}), }) except EffectRuntimeConflict as exc: # Preserve the public ambiguity exception; the native owner also diff --git a/loopx/control_plane/turn_driver/loop_controller.py b/loopx/control_plane/turn_driver/loop_controller.py index 1576187c89..f78262be85 100644 --- a/loopx/control_plane/turn_driver/loop_controller.py +++ b/loopx/control_plane/turn_driver/loop_controller.py @@ -30,7 +30,7 @@ from collections.abc import Mapping from typing import Any, Literal -from ..effect_program import SettlementStepKind +from ..effect_program import SettlementIdentity, SettlementStepKind from .driver import LoopXTurnRoute, _typed_route, selected_turn_todo from .host_failure import ( host_failure_retry_available, @@ -163,6 +163,7 @@ def __init__( "goal_id": str(lineage.get("goal_id") or ""), "agent_id": str(lineage.get("agent_id") or ""), "todo_id": str(lineage.get("todo_id") or ""), + **({"request_binding_id": lineage["request_binding_id"]} if lineage.get("request_binding_id") else {}), } self.turn_key = turn_key self.host_failure = dict(host_failure or {}) or None @@ -172,7 +173,7 @@ def __init__( @classmethod def from_execution( - cls, execution_payload: Mapping[str, Any] + cls, execution_payload: Mapping[str, Any], *, request_identity: SettlementIdentity | None = None, ) -> ValidatedTurnReceipt: execution = _mapping(execution_payload) if execution.get("schema_version") != LOOPX_TURN_EXECUTION_SCHEMA_VERSION: @@ -206,7 +207,13 @@ def from_execution( "Turn execution and receipt" ) lineage = _mapping(receipt.get("lineage")) - if not all(lineage.get(k) for k in ("goal_id", "agent_id", "todo_id")): + if request_identity is not None: + expected = {"goal_id": request_identity.goal_id, "agent_id": request_identity.agent_id, + "todo_id": "", "request_binding_id": request_identity.binding_id} + if (request_identity.request_subject is None or lineage != expected + or receipt.get("settlement_effect_id") != request_identity.effect_id): + raise ValueError("validated receipt does not match its original request identity") + elif not all(lineage.get(k) for k in ("goal_id", "agent_id", "todo_id")): raise ValueError( "validated turn receipt is missing goal/agent/todo lineage" ) diff --git a/loopx/control_plane/turn_driver/transaction.py b/loopx/control_plane/turn_driver/transaction.py index 12b0e31b63..2b9fad11e6 100644 --- a/loopx/control_plane/turn_driver/transaction.py +++ b/loopx/control_plane/turn_driver/transaction.py @@ -240,13 +240,16 @@ def _settlement_identity( "todo_id": str(identity.get("todo_id") or ""), "effect_id": str(identity.get("effect_id") or ""), } - if identity.get("replan_obligation_id") is not None: + if identity.get("replan_obligation_id") is not None or identity.get("request_subject") is not None: try: parsed = SettlementIdentity.from_runtime_payload(identity) except RuntimeError: errors.append("transaction plan is missing its typed settlement identity") else: - normalized["replan_obligation_id"] = str(parsed.replan_obligation_id) + if parsed.request_subject is not None: + normalized["request_binding_id"] = parsed.binding_id + else: + normalized["replan_obligation_id"] = str(parsed.replan_obligation_id) elif not all(normalized.values()): errors.append("transaction plan is missing its typed settlement identity") return normalized @@ -355,6 +358,8 @@ def validate_loopx_turn_receipt( "todo_id": settlement_identity["todo_id"], **({"replan_obligation_id": settlement_identity["replan_obligation_id"]} if settlement_identity.get("replan_obligation_id") else {}), + **({"request_binding_id": settlement_identity["request_binding_id"]} + if settlement_identity.get("request_binding_id") else {}), }, "settlement_effect_id": settlement_identity["effect_id"] or None, "result_kind": kind.value if kind is not None else None, diff --git a/loopx/control_plane/turn_driver/turn_journal.ts b/loopx/control_plane/turn_driver/turn_journal.ts index 3ab05a7c27..442c5f295a 100644 --- a/loopx/control_plane/turn_driver/turn_journal.ts +++ b/loopx/control_plane/turn_driver/turn_journal.ts @@ -15,7 +15,7 @@ import { parseExactGoalRef } from "../goals/goal_instance_identity.ts"; import { preparedAttemptViolation } from "./turn_journal_attempt_contract.ts"; import { recordedTurnEffects, type RecordedTurnEffects } from "./turn_journal_effect_readback.ts"; import { selectedWorkTodoId } from "../work_items/interaction_contract.ts"; -import { turnEnvelopeReplanSettlementIdentity } from "../quota/turn_envelope.ts"; +import { turnEnvelopeScopedSettlementIdentity } from "../quota/turn_envelope.ts"; export const TURN_JOURNAL_INSPECTION_SCHEMA_VERSION = "loopx_turn_journal_inspection_v1"; @@ -269,9 +269,9 @@ function typedSettlementIdentityState( }); let bindingMatches = selectedTodoId !== null && parsed.value.binding_kind === "todo" && parsed.value.todo_id === selectedTodoId && parsed.value.binding_id === selectedTodoId; - if (selectedTodoId === null && parsed.value.binding_kind === "autonomous_replan") { + if (selectedTodoId === null && ["autonomous_replan", "request"].includes(parsed.value.binding_kind)) { try { - bindingMatches = turnEnvelopeReplanSettlementIdentity(envelope, expectedTurnInstance)?.effect_id + bindingMatches = turnEnvelopeScopedSettlementIdentity(envelope, expectedTurnInstance)?.effect_id === parsed.value.effect_id; } catch { bindingMatches = false; // Corrupt evidence remains a refusal, never a surrogate task. diff --git a/loopx/control_plane/turn_driver/turn_journal_query.ts b/loopx/control_plane/turn_driver/turn_journal_query.ts index 24e33e540a..6e24de74f3 100644 --- a/loopx/control_plane/turn_driver/turn_journal_query.ts +++ b/loopx/control_plane/turn_driver/turn_journal_query.ts @@ -186,7 +186,8 @@ export async function findTurnJournalBySettlement(params: JsonObject): Promise<{ const identity = decodeIdentity(settlementIdentityPayload({ goal_id: requireNonEmptyString(params.goal_id, "goal_id"), agent_id: requireNonEmptyString(params.agent_id, "agent_id"), - todo_id: requireNonEmptyString(params.todo_id, "todo_id"), + todo_id: params.todo_id == null ? null : requireNonEmptyString(params.todo_id, "todo_id"), + request_subject: params.request_subject as import("../effect_program.ts").ExecutionRequestSubject | undefined, turn_instance_id: requireNonEmptyString(params.turn_instance_id, "turn_instance_id"), })); const evidence = await querySettlementJournal(params.runtime_root, identity, null); diff --git a/loopx/semantics/production.py b/loopx/semantics/production.py index 5f0a9a894a..a7574660a5 100644 --- a/loopx/semantics/production.py +++ b/loopx/semantics/production.py @@ -261,9 +261,8 @@ def probe_settlement_binding_production(vocabulary: dict[str, Any]) -> list[Prod can actually produce; a successful call does. The module and the function are fixed here rather than named by registry - data, so the registry cannot select an arbitrary callable. The four cases - are the builder's whole binding domain: a todo binding, a replan binding, - neither, and the pair it must refuse. + data, so the registry cannot select an arbitrary callable. Exercise each + binding, the unbound input and every conflicting combination. """ from pathlib import Path as _Path @@ -276,6 +275,10 @@ def probe_settlement_binding_production(vocabulary: dict[str, Any]) -> list[Prod expected = { 'todo': {'todo_id': 't1'}, 'autonomous_replan': {'replan_obligation_id': 'r1'}, + 'request': {'request_subject': { + 'request_id': 'a' * 64, 'request_revision': 1, + 'request_digest': 'b' * 64, 'execution_scope_sha256': 'c' * 64, + }}, 'unbound': {}, } if set(expected) != set(vocabulary['values']): @@ -283,7 +286,11 @@ def probe_settlement_binding_production(vocabulary: dict[str, Any]) -> list[Prod root = _Path(__file__).resolve().parents[2] probes = [expected[value] for value in vocabulary['values']] - probes.append({'todo_id': 't1', 'replan_obligation_id': 'r1'}) + from itertools import combinations + bindings = [expected[key] for key in ('todo', 'autonomous_replan', 'request')] + conflicts = [dict(item for binding in group for item in binding.items()) + for count in (2, 3) for group in combinations(bindings, count)] + probes.extend(conflicts) completed = subprocess.run( ['node', '--no-warnings', '--experimental-strip-types', str(root / 'scripts/settlement_binding_witness.mts')], @@ -305,6 +312,7 @@ def probe_settlement_binding_production(vocabulary: dict[str, Any]) -> list[Prod goal_id='g', agent_id='a', turn_instance_id='t', todo_id=expected[value].get('todo_id'), replan_obligation_id=expected[value].get('replan_obligation_id'), + request_subject=expected[value].get('request_subject'), ) if bridged.binding_kind is not SettlementBindingKind(value): raise ValueError( @@ -312,9 +320,8 @@ def probe_settlement_binding_production(vocabulary: dict[str, Any]) -> list[Prod rows.append(Production(site, SETTLEMENT_IDENTITY_LINE, 'input_witness', frozenset({value}), False)) - refused = observed[-1] - if refused.get('ok'): - raise ValueError('settlement_binding_kind: builder accepted both bindings at once') + if any(result.get('ok') for result in observed[len(expected):]): + raise ValueError('settlement_binding_kind: builder accepted conflicting bindings') return rows diff --git a/loopx/semantics/vocabulary_v0.json b/loopx/semantics/vocabulary_v0.json index 6057512317..468fb17415 100644 --- a/loopx/semantics/vocabulary_v0.json +++ b/loopx/semantics/vocabulary_v0.json @@ -550,6 +550,7 @@ "values": [ "todo", "autonomous_replan", + "request", "unbound" ], "input_producer": "loopx/control_plane/effect_program.ts::settlementIdentity", @@ -559,7 +560,8 @@ "value_notes": { "todo": "Chosen by settlementIdentity when the todo id is non-blank after trimming; supplying both a todo id and a replan obligation id throws before this point. It fixes the effect id to the goal, agent, todo and turn instance form, and is re-derived when a persisted identity payload is decoded.", "autonomous_replan": "Chosen when the todo id is blank and the replan obligation id is non-blank after trimming, so the turn settles a replan obligation rather than a selected Todo. The effect id gains an explicit autonomous_replan segment instead of a Todo segment.", - "unbound": "Chosen when both ids are blank, naming a goal, agent and turn with no work-item binding at all. It is reachable mainly because some constructors pass an empty string rather than validating, and every validating entry path refuses it; readback also reports it as failure detail to describe a heartbeat receipt that carries neither binding." + "request": "Chosen for an already admitted original request with its id, revision, content digest and operator execution-scope digest. Its canonical subject hash binds the effect and native Turn lineage; this identity grants no execution authority and creates no Todo or replan obligation.", + "unbound": "Chosen when no Todo, replan or request subject is present, naming a goal, agent and turn with no work-item binding. Validating execution paths refuse it; readback reports it for a heartbeat receipt carrying no binding." } }, "settlement_failure_kind": { diff --git a/loopx/state_refresh.py b/loopx/state_refresh.py index abf4d99dd9..b172e508a2 100644 --- a/loopx/state_refresh.py +++ b/loopx/state_refresh.py @@ -344,6 +344,8 @@ def build_state_refresh_record( if settlement_identity: record["settlement_identity"] = settlement_identity.as_dict() record["turn_instance_id"] = settlement_identity.turn_instance_id + if settlement_identity.request_subject is not None: + record["request_subject"] = dict(settlement_identity.request_subject) if settlement_identity.todo_id: record["todo_id"] = settlement_identity.todo_id if settlement_identity.replan_obligation_id: @@ -782,10 +784,23 @@ def refresh_state_run( if explore_result is not None: from .capabilities.explore.result_writeback import normalize_result_attachment normalized_explore_result = normalize_result_attachment(explore_result) + request_work_id = None + if not todo_id and not normalized_replan_obligation_id and turn_instance_id and normalized_agent_id: + # Reuse only this exact committed guard. Missing input cannot select a + # Todo or manufacture a request; the original receipt owns restoration. + from .control_plane.quota.heartbeat_receipt import find_heartbeat_receipt, heartbeat_receipt_request_subject + request_registry = load_registry(registry_path) + request_runtime = resolve_runtime_root(request_registry, runtime_root_override, registry_path=registry_path) + request_receipt = find_heartbeat_receipt(request_runtime, goal_id=safe_goal_id, + agent_id=normalized_agent_id, turn_instance_id=turn_instance_id, goal_ref=goal_ref) + request_subject = heartbeat_receipt_request_subject(request_receipt) + if request_subject is not None: + request_work_id = SettlementIdentity(goal_id=safe_goal_id, agent_id=normalized_agent_id, + todo_id=None, turn_instance_id=turn_instance_id, request_subject=request_subject).binding_id normalized_progress_observation = ( normalize_progress_observation( progress_observation, - work_item_id=todo_id or normalized_replan_obligation_id, + work_item_id=todo_id or normalized_replan_obligation_id or request_work_id, ) if progress_observation is not None else None @@ -800,7 +815,7 @@ def refresh_state_run( turn_scoped_settlement_qualified = qualifies_turn_scoped_settlement( normalized_delivery_outcome, normalized_progress_observation, - work_item_id=todo_id, + work_item_id=todo_id or request_work_id, replan_obligation_id=normalized_replan_obligation_id, ) # A refusal has to name the typed input the writer still owes; the diagnosis @@ -811,7 +826,7 @@ def refresh_state_run( else explain_turn_scoped_settlement_gap( normalized_delivery_outcome, normalized_progress_observation, - work_item_id=todo_id, + work_item_id=todo_id or request_work_id, replan_obligation_id=normalized_replan_obligation_id, ) or TURN_SCOPED_SETTLEMENT_GAP_FALLBACK diff --git a/scripts/settlement_binding_witness.mts b/scripts/settlement_binding_witness.mts index e3de4cf188..9f3dfb1181 100644 --- a/scripts/settlement_binding_witness.mts +++ b/scripts/settlement_binding_witness.mts @@ -4,11 +4,12 @@ // reports what the builder actually returns, including the rejection. The probe // inputs arrive on stdin so the registry cannot select an arbitrary callable -- // the module and the function are fixed here, in tracked code. -import {settlementIdentity, settlementIdentityPayload} from "../loopx/control_plane/effect_program.ts"; +import {settlementIdentity, settlementIdentityPayload, type ExecutionRequestSubject} from "../loopx/control_plane/effect_program.ts"; type Probe = { todo_id?: string | null; replan_obligation_id?: string | null; + request_subject?: ExecutionRequestSubject | null; }; const request = JSON.parse(await new Response(process.stdin).text()) as {probes: Probe[]}; @@ -19,6 +20,7 @@ const results = request.probes.map(probe => { turn_instance_id: "t", todo_id: probe.todo_id ?? null, replan_obligation_id: probe.replan_obligation_id ?? null, + ...(probe.request_subject == null ? {} : {request_subject: probe.request_subject}), }; try { const identity = settlementIdentity(input); diff --git a/tests/architecture/test_content_digest_single_owner.py b/tests/architecture/test_content_digest_single_owner.py index 9ae7c28e32..d855dd023d 100644 --- a/tests/architecture/test_content_digest_single_owner.py +++ b/tests/architecture/test_content_digest_single_owner.py @@ -167,6 +167,7 @@ "loopx.capabilities.issue_fix.outcome_projection", "loopx.capabilities.issue_fix.reviewer_notification", "loopx.capabilities.machine_configuration.store", + "loopx.capabilities.manager_context.execution", "loopx.capabilities.manager_context.roundtrip", "loopx.capabilities.manager_context.tracking", "loopx.capabilities.periodic_report.adapters", diff --git a/tests/architecture/test_semantic_settlement_binding_witness.py b/tests/architecture/test_semantic_settlement_binding_witness.py index 3ee4af7fbf..f4973d2e8a 100644 --- a/tests/architecture/test_semantic_settlement_binding_witness.py +++ b/tests/architecture/test_semantic_settlement_binding_witness.py @@ -2,8 +2,8 @@ Enumerating an owner proves that a value is spelled somewhere. It does not prove a legal input can make the runtime emit it. This vocabulary's evidence -is the shipped TypeScript builder run against the four inputs that make up -its whole binding domain, re-checked through the Python bridge that reaches +is the shipped TypeScript builder run against valid and conflicting inputs, +re-checked through the Python bridge that reaches the same code. These tests fail when the witness stops executing the real builder, when the @@ -33,6 +33,8 @@ CASES = ( ({"todo_id": "t1"}, "todo"), ({"replan_obligation_id": "r1"}, "autonomous_replan"), + ({"request_subject": {"request_id": "a" * 64, "request_revision": 1, + "request_digest": "b" * 64, "execution_scope_sha256": "c" * 64}}, "request"), ({}, "unbound"), ) @@ -75,6 +77,7 @@ def test_the_python_bridge_reaches_the_same_builder(probe: dict, expected: str) turn_instance_id="t", todo_id=probe.get("todo_id"), replan_obligation_id=probe.get("replan_obligation_id"), + request_subject=probe.get("request_subject"), ) assert identity.binding_kind is SettlementBindingKind(expected) @@ -120,10 +123,12 @@ def test_the_witness_refuses_a_value_set_it_does_not_cover() -> None: def test_the_wire_payload_shape_is_recorded_rather_than_assumed() -> None: """``binding_kind`` is not on every payload; changing that must be visible.""" - todo, replan, unbound = _run_probe([probe for probe, _ in CASES]) + todo, replan, request, unbound = _run_probe([probe for probe, _ in CASES]) assert todo["payload_schema_version"] == "quota_settlement_identity_v0" assert todo["payload_has_binding_kind"] is False assert unbound["payload_schema_version"] == "quota_settlement_identity_v0" assert unbound["payload_has_binding_kind"] is False assert replan["payload_schema_version"] == "quota_settlement_identity_v1" assert replan["payload_has_binding_kind"] is True + assert request["payload_schema_version"] == "quota_settlement_identity_v1" + assert request["payload_has_binding_kind"] is True diff --git a/tests/control_plane/test_delegation_request_subject.py b/tests/control_plane/test_delegation_request_subject.py new file mode 100644 index 0000000000..0480f47040 --- /dev/null +++ b/tests/control_plane/test_delegation_request_subject.py @@ -0,0 +1,365 @@ +"""An ordinary request keeps its identity without inventing a canonical task.""" +# ruff: noqa: F811 - import the existing parametrized real-backend fixture +import json +import sys +from pathlib import Path + +import pytest + +from test_local_delegation import HOST, brief + +from test_local_delegation import service as delegation_service # noqa: F401 - shared real File/SQLite fixture + +ORIGINAL_MESSAGE = "求 17+25 的结果,保留原消息。\n" * 100 + "END 🧭" + + +def test_operator_binding_does_not_require_a_precreated_todo(delegation_service): + _, runner = delegation_service + config = json.loads(runner.config.read_text()) + config["bindings"][0].pop("todo_id") + runner.config.write_text(json.dumps(config)) + + binding = runner.binding("analysis") + assert binding["agent_id"] == "analyst" + assert binding.get("todo_id") is None + + +def _ordinary_binding(delegation_service): + root, runner = delegation_service + config = json.loads(runner.config.read_text()) + binding = config["bindings"][0] + binding.pop("todo_id") + binding["request_validation"] = { + "validation_command": None, + "validation_command_argv": [sys.executable, "-c", + "import json; from pathlib import Path; assert json.loads(Path('output.json').read_text()) == {'answer': 42}"], + "validation_label": "Independent arithmetic result", + "validation_timeout_seconds": 10, + } + runner.config.write_text(json.dumps(config)) + source = HOST.replace("delegation = json.loads((workspace / 'DELEGATION.json').read_text())", + "delegation = next(s['content'] for s in envelope['work_context']['sources'] if s.get('source') == 'delegation_work')") + source = source.replace("print(json.dumps(build_result", """ +(workspace / 'output.json').write_text(json.dumps({'answer': 17 + 25})) +(workspace / 'received-request.json').write_text(json.dumps(request)) +print(json.dumps(build_result""") + (root / "fixture-host.py").write_text(source) + return root, runner, runner.binding("analysis") + + +def _exact_goal_service(delegation_service): + from loopx.collaboration_mcp import Delegations + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + + root, runner = delegation_service + payload = json.loads(runner.registry.read_text()) + payload.update(profile_id="source_session_v1", session_bindings=[], session_receipts=[], lifetime_receipts=[]) + payload["goals"][0]["goal_instance_id"] = "ginst_" + "a" * 32 + # Replace only this disposable fixture's legacy document using the v2 writer. + runner.registry.unlink() + with source_session_registry_transaction(runner.registry, operation="exact-request-fixture", + create=lambda: payload) as transaction: + transaction.commit(transaction.payload_copy()) + return root, Delegations(runner.root, runner.registry, runner.goal_id, runner.agent_id, runner.config) + + +def test_original_request_executes_and_settles_without_changing_todos(delegation_service, monkeypatch): + root, runner, binding = _ordinary_binding(delegation_service) + before = runner._cli(binding, "todo", "list", "--goal-id", runner.goal_id)["todos"] + monkeypatch.setattr(runner, "_spawn", lambda operation: None) + initial = runner.start("analysis", "ordinary-request", brief()) + assert initial["todo_id"] is None + runner.execute("ordinary-request") + result = runner.read("ordinary-request") + assert result["status"] == "accepted", json.dumps(result, indent=2) + assert result["request_id"] == initial["request_id"] + assert runner._cli(binding, "todo", "list", "--goal-id", runner.goal_id)["todos"] == before + workspace = Path(binding["workspace"]) + assert (workspace / "host-invocations").read_text() == "1" + received = json.loads((workspace / "received-request.json").read_text()) + identity = received["turn_envelope"]["writeback"]["settlement_plan"]["identity"] + assert identity["binding_kind"] == "request" + assert identity["request_subject"]["request_id"] == initial["request_id"] + assert "todo_id" not in identity + assert "replan_obligation_id" not in identity + from loopx.control_plane.quota.settlement import read_heartbeat_settlement + def settlement(): + return read_heartbeat_settlement(runner.root, goal_id=runner.goal_id, agent_id=binding["agent_id"], + todo_id=None, turn_instance_id=identity["turn_instance_id"], registry_path=runner.registry, goal_ref=runner._caller_goal_ref()) + original = settlement() + assert original.identity.value.request_subject == identity["request_subject"] + from loopx.control_plane.quota.heartbeat_receipt import heartbeat_receipt_view + guard_view = heartbeat_receipt_view(original.heartbeat_receipt, + turn_instance_id=identity["turn_instance_id"], status="settled") + assert guard_view["settlement_identity"] == identity + assert "settlement_binding_owed" not in guard_view + assert original.settlement.failure is None + assert original.writeback_run is not None and original.spend_run is not None + runner.execute("ordinary-request") + assert settlement().spend_run == original.spend_run + assert (workspace / "host-invocations").read_text() == "1" + assert runner._cli(binding, "todo", "list", "--goal-id", runner.goal_id)["todos"] == before + + +@pytest.mark.parametrize("with_todo", [False, True], ids=["original-request", "existing-todo"]) +def test_exact_goal_request_preparation_replay_and_runtime_boundary(delegation_service, monkeypatch, with_todo): + from loopx.control_plane.collaboration import delegation_results, delegation_validation + from loopx.control_plane.collaboration.inbox import _read + + if with_todo: + root, runner = delegation_service + binding = runner.binding("analysis") + else: + root, runner, binding = _ordinary_binding(delegation_service) + root, runner = _exact_goal_service((root, runner)) + monkeypatch.setattr(runner, "_spawn", lambda operation: None) + initial = runner.start("analysis", "exact-request", brief()) + row = _read(runner.path("exact-request")) + assert initial["status"] == "prepared" and initial["todo_id"] == binding["todo_id"] + subject = delegation_validation.ordinary_request_subject(runner, row, binding) + assert subject == (None if with_todo else initial["request_subject"]) + assert delegation_results.operation_brief(runner, row) == brief() + assert runner.start("analysis", "exact-request", brief()) == initial + # The exact profile currently admits collaboration/lifecycle only. Its + # native runtime rejection must stay visible and must not launch a Host. + runner.execute("exact-request") + rejected = runner.read("exact-request") + assert rejected["status"] == "rejected" + assert "lifecycle-only profile source_session_v1" in rejected["error"] + runner.execute("exact-request") + assert runner.read("exact-request") == rejected + assert not (Path(binding["workspace"]) / "host-invocations").exists() + + +@pytest.mark.parametrize("new_service", [False, True], ids=["captured-service", "restarted-service"]) +def test_recreated_goal_cannot_execute_the_original_request(delegation_service, monkeypatch, new_service): + from loopx.collaboration_mcp import Delegations + from loopx.control_plane.projects.registry_codec import source_session_registry_transaction + + root, runner, binding = _ordinary_binding(delegation_service) + root, runner = _exact_goal_service((root, runner)) + historical = runner + monkeypatch.setattr(runner, "_spawn", lambda operation: None) + initial = runner.start("analysis", "recreated-request", brief()) + with source_session_registry_transaction(runner.registry, operation="recreated-request-fixture") as transaction: + payload = transaction.payload_copy() + payload["goals"][0]["goal_instance_id"] = "ginst_" + "b" * 32 + transaction.commit(payload) + if new_service: + runner = Delegations(runner.root, runner.registry, runner.goal_id, runner.agent_id, runner.config) + runner.execute("recreated-request") + result = historical.read("recreated-request") + assert result["status"] == "rejected" + expected_error = "unavailable" if new_service else "historical_mutation_forbidden" + assert expected_error in result["error"] + assert not (Path(binding["workspace"]) / "host-invocations").exists() + assert result["request_id"] == initial["request_id"] + + +@pytest.fixture +def ordinary_source(delegation_service, monkeypatch, request): + from loopx.capabilities.manager_context import deliver, execution + from test_manager_context_execution import source + + root, runner, binding = _ordinary_binding(delegation_service) + if getattr(request, "param", None) == "codex": + from test_loopx_turn_codex_cli import _fake_codex + executable, log = _fake_codex(root) + monkeypatch.setenv("FAKE_CODEX_LOG", str(log)) + host_source = executable.read_text().replace('output_path = pathlib.Path', f''' +from loopx.control_plane.collaboration.peers import read_inbox, return_result +from loopx.control_plane.collaboration.inbox import acknowledge +from loopx.capabilities.manager_context.roundtrip import report +root = pathlib.Path({str(root)!r}) +assert {json.dumps(ORIGINAL_MESSAGE, ensure_ascii=False)[1:-1]!r} in prompt +pathlib.Path('output.json').write_text(json.dumps({{'answer': 17 + 25}})) +for item in read_inbox(root / 'runtime', root / 'registry.json', {runner.goal_id!r}, 'analyst')['items']: + acknowledge(root / 'runtime', {runner.goal_id!r}, 'analyst', item['request_id'], 'adopt', 'Read complete native request.') + if item.get('source_kind') == 'peer': + return_result(root / 'runtime', {runner.goal_id!r}, 'analyst', item['request_id'], '42') + else: + report(root / 'runtime', {runner.goal_id!r}, 'analyst', item['request_id'], 'conclusion', '42') +output_path = pathlib.Path''') + executable.write_text(host_source) + config_data = json.loads(runner.config.read_text()) + config_data["bindings"][0]["host_args"] = ["--host", "codex-cli", "--codex-bin", str(executable)] + runner.config.write_text(json.dumps(config_data)) + binding = runner.binding("analysis") + data = json.loads(runner.registry.read_text()) + config = Path(data["goals"][0]["repo"]) / ".loopx/config/delegations.json" + config.parent.mkdir(parents=True, exist_ok=True) + config.write_bytes(runner.config.read_bytes()) + data["goals"][0]["spawn_policy"] = {"execution_config": ".loopx/config/delegations.json"} + runner.registry.write_text(json.dumps(data)) + values = source(runner.root, runner.registry, goal_id=runner.goal_id, + agent_id="analyst", requester="lead", binding="analysis", + original_message=ORIGINAL_MESSAGE) + store, session, turn, request, receipt, policy = values + deliver(runner.root, runner.registry, session=session, turn=turn, request=request, source_store=store) + host = root / "fixture-host.py" + host.write_text(host.read_text().replace("print(json.dumps(build_result", """ +from loopx.capabilities.manager_context.roundtrip import report +original = delegation['source_request'] +acknowledge(root / 'runtime', envelope['goal_id'], actor, original['request_id'], 'adopt', 'Read the original requested arithmetic.') +report(root / 'runtime', envelope['goal_id'], actor, original['request_id'], 'conclusion', 'The independently checked result is 42.') +print(json.dumps(build_result""")) + monkeypatch.setattr(execution.Delegations, "_spawn", lambda *_: None) + submitted = execution.dispatch(runner.root, runner.registry, session=session, turn=turn, + request=request, receipt=receipt, execution_allowed=lambda: True) + assert submitted["submitted"] and submitted["status"] == "prepared", submitted + worker, binding = execution._service(runner.root, runner.registry, { + "goal_id": runner.goal_id, "agent_id": "analyst", "requester_agent_id": "lead", "binding_id": "analysis"}) + return root, worker, binding, values, submitted["operation_id"] + + +@pytest.mark.parametrize("ordinary_source", ["codex"], indirect=True) +def test_request_uses_the_actual_codex_launch_and_original_settlement(ordinary_source): + root, worker, _, _, operation = ordinary_source + worker.execute(operation) + assert worker.read(operation)["status"] == "accepted", worker.read(operation) + worker.execute(operation) + assert len((root / "codex-argv.jsonl").read_text().splitlines()) == 1 + + +def test_original_chat_request_returns_without_creating_a_task(ordinary_source): + from test_local_delegation import demo + from loopx.capabilities.manager_context.roundtrip import drain + + root, worker, binding, (store, session, turn, request, receipt, _), operation = ordinary_source + before = demo.canonical_tasks(root) + worker.execute(operation) + result = worker.read(operation) + assert result["status"] == "accepted", result + assert result["todo_id"] is None + assert result["request_subject"]["request_id"] == receipt["request_id"] + received = json.loads((Path(binding["workspace"]) / "received-request.json").read_text()) + content = next(s["content"] for s in received["turn_envelope"]["work_context"]["sources"] + if s.get("source") == "delegation_work") + assert content["source_request"]["message"] == ORIGINAL_MESSAGE + assert content["source_request"]["brief"] == request["brief"] + store.update_turn(session["session_id"], turn["turn_id"], status="completing", + response={"context_handoff_receipt": receipt}) + store.finalize_managed_turn_completion(session["session_id"], turn["turn_id"]) + sends = [] + def transport(route, *args, **kwargs): + sends.append(route["session_id"]) + return {"message_id": "original-request-return", "reply_verified": True} + drain(worker.root, worker.registry, store, transport) + drain(worker.root, worker.registry, store, transport) + assert sends == [session["session_id"]] + worker.execute(operation) + assert (Path(binding["workspace"]) / "host-invocations").read_text() == "1" + assert demo.canonical_tasks(root) == before + + +def test_original_request_runs_with_an_empty_canonical_task_population(ordinary_source): + from canonical_authority_fixture import advance_canonical_fixture_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from test_local_delegation import demo + root, worker, _, _, operation = ordinary_source + # Only this disposable fixture is advanced, through the real provider CAS. + state = root / "project" / "ACTIVE_GOAL_STATE.md" + state.write_text(state.read_text().replace("- Validate synthetic evidence.", "")) + advance_canonical_fixture_authority(worker.root, worker.goal_id, + build_todo_runtime_shadow_projection(goal_id=worker.goal_id, handoff_mode="soft_claim", todos=[]), + operation_id="empty-request-fixture") + assert demo.canonical_tasks(root) == {} + worker.execute(operation) + assert worker.read(operation)["status"] == "accepted", worker.read(operation) + assert demo.canonical_tasks(root) == {} + + +def test_request_revocation_after_host_holds_settlement_and_recovers_original_turn(ordinary_source): + from loopx.control_plane.collaboration.inbox import _read, _root, _write + from test_local_delegation import demo + + root, worker, binding, (_, session, _, _, _, policy), operation = ordinary_source + before = demo.canonical_tasks(root) + original = json.loads(json.dumps(policy)) + policy["sources"][session["channel_id"]]["execution_bindings"] = [] + host = root / "fixture-host.py" + host.write_text(host.read_text().replace("print(json.dumps(build_result", + f"(root / 'runtime' / '.local' / 'manager-context' / 'policy.json').write_text({json.dumps(json.dumps(policy))})\nprint(json.dumps(build_result")) + worker.execute(operation) + held = _read(worker.path(operation)) + assert held["status"] == "turn_returned", held + journals = list((worker.root / "goals" / worker.goal_id / "turns").glob("*.json")) + assert len(journals) == 1 + journal = _read(journals[0]) + assert journal["completed_phases"] == ["host_execute", "typed_result", "validation"] + assert journal["task_validation"]["ok"] + assert demo.canonical_tasks(root) == before + _write(_root(worker.root) / "policy.json", original) + worker.execute(operation) + assert worker.read(operation)["status"] == "accepted" + assert _read(journals[0])["turn_key"] == journal["turn_key"] + assert _read(journals[0])["completed_phases"].count("quota_spend") == 1 + assert (Path(binding["workspace"]) / "host-invocations").read_text() == "1" + assert demo.canonical_tasks(root) == before + + +def test_busy_request_retains_its_identity_and_runs_after_restart(ordinary_source): + from loopx.control_plane.turn_driver.lane_fence import turn_lane_singleflight + from loopx.collaboration_mcp import Delegations + from test_local_delegation import demo + + root, worker, binding, _, operation = ordinary_source + before = demo.canonical_tasks(root) + identity = json.loads(worker.path(operation).read_text())["identity"] + with turn_lane_singleflight(runtime_root=worker.root, goal_id=worker.goal_id, + plan={"turn_envelope": {"agent_id": binding["agent_id"]}}): + worker.execute(operation) + row = json.loads(worker.path(operation).read_text()) + assert row["status"] == "prepared" and row["identity"] == identity + assert not (Path(binding["workspace"]) / "host-invocations").exists() + restarted = Delegations(worker.root, worker.registry, worker.goal_id, worker.agent_id, worker.config) + restarted.execute(operation) + restarted.execute(operation) + assert restarted.read(operation)["status"] == "accepted" + assert json.loads(worker.path(operation).read_text())["identity"] == identity + assert (Path(binding["workspace"]) / "host-invocations").read_text() == "1" + assert demo.canonical_tasks(root) == before + + +def test_lost_request_reply_recovers_without_repeating_host(ordinary_source, monkeypatch): + import subprocess + from test_local_delegation import demo + + root, worker, binding, _, operation = ordinary_source + before = demo.canonical_tasks(root) + record = worker._record_turn_result + def lose_reply(*_args, **_kwargs): + raise subprocess.TimeoutExpired("committed request reply", 1) + monkeypatch.setattr(worker, "_record_turn_result", lose_reply) + worker.execute(operation) + assert json.loads(worker.path(operation).read_text())["status"] == "running" + monkeypatch.setattr(worker, "_record_turn_result", record) + worker.execute(operation) + assert worker.read(operation)["status"] == "accepted" + assert (Path(binding["workspace"]) / "host-invocations").read_text() == "1" + assert demo.canonical_tasks(root) == before + + +def test_failed_request_rechecks_the_original_artifact_without_repeating_host(ordinary_source): + root, worker, binding, _, operation = ordinary_source + host = root / "fixture-host.py" + host.write_text(host.read_text().replace("17 + 25", "17 + 24")) + worker.execute(operation) + rejected = worker.read(operation) + assert rejected["status"] == "rejected" and rejected["task_failure"]["stage"] == "task_postcondition", rejected + (Path(binding["workspace"]) / "output.json").write_text(json.dumps({"answer": 42})) + worker.revalidate(operation) + worker.execute(operation) + assert worker.read(operation)["status"] == "accepted" + assert (Path(binding["workspace"]) / "host-invocations").read_text() == "1" + + +def test_stop_before_request_host_keeps_all_tasks_and_effects_unchanged(ordinary_source): + from test_local_delegation import demo + root, worker, binding, _, operation = ordinary_source + before = demo.canonical_tasks(root) + worker.stop(operation, execute=True) + worker.execute(operation) + assert not (Path(binding["workspace"]) / "host-invocations").exists() + assert demo.canonical_tasks(root) == before + assert not list((worker.root / "goals" / worker.goal_id / "turns").glob("*.json")) diff --git a/tests/control_plane_ts/content_digest_single_owner.test.ts b/tests/control_plane_ts/content_digest_single_owner.test.ts index 09cf4a9e65..b5e096e78a 100644 --- a/tests/control_plane_ts/content_digest_single_owner.test.ts +++ b/tests/control_plane_ts/content_digest_single_owner.test.ts @@ -94,6 +94,7 @@ const DECLARED_UNFOLDABLE: Record = { /** Every module that reads the owner, pinned so that dropping an import is loud. */ const CANONICAL_CONSUMERS = [ + "control_plane/effect_program.ts", "control_plane/agents/supervisor_event_append.ts", "control_plane/capabilities/change_quality_validation.ts", "control_plane/capabilities/content_media.ts", diff --git a/tests/control_plane_ts/delegation.test.ts b/tests/control_plane_ts/delegation.test.ts index 301e796f03..4aea31d7f2 100644 --- a/tests/control_plane_ts/delegation.test.ts +++ b/tests/control_plane_ts/delegation.test.ts @@ -244,7 +244,7 @@ test("same explicit grant contract applies to a coordinator and an ordinary memb test("malformed operator binding fails before launch", () => { for (const patch of [{timeout_seconds: 0}, {timeout_seconds: 5000}, {output_refs: ["../secret"]}, - {output_refs: ["/secret"]}, {host_args: []}, {todo_id: null}]) { + {output_refs: ["/secret"]}, {host_args: []}, {todo_id: ""}]) { assert.throws(() => selectDelegationBinding({...params, config: {...params.config, bindings: [{...binding, ...patch}]}})); } @@ -517,6 +517,29 @@ test("inventory paging is bounded and never interprets a missing result as accep assert.equal(unavailable.artifacts, undefined); }); +test("ordinary request preflight retains its original subject and validation repair", () => { + const ordinary = {...binding, todo_id: null}; + const effects = {host_invoked: false, state_written: false, quota_spent: false, scheduler_acknowledged: false}; + const waiting = delegationPreflight({binding: ordinary, workspace: {state: "available"}, + authority: {ready: true}, request_required: true}); + assert.equal(waiting.state, "turn_blocked"); + assert.equal(waiting.request_required, true); + assert.equal(waiting.dispatch_allowed, false); + assert.deepEqual(waiting.effects, effects); + const params = {binding: ordinary, validation_files_current: false, + acceptance: {todo_id: null, state: "unbound", reason: "independent_delegation_validation_required"}, + preview: {dry_run: true, status: "preview", effects, + route: {kind: "ready_for_host", would_invoke_host: true, selected_todo_id: null}, + managed_executor: {executor: "generic-cli", available: true, unavailable_reason: null, execution_profile: null}}}; + const refused = delegationPreflight(params); + assert.equal(refused.state, "acceptance_unavailable"); + assert.equal(refused.acceptance_next_action, "review_original_request_validation"); + assert.equal(refused.dispatch_allowed, false); + const todo = delegationPreflight({...params, binding, acceptance: {...params.acceptance, todo_id: binding.todo_id}, + preview: {...params.preview, route: {...params.preview.route, selected_todo_id: binding.todo_id}}}); + assert.equal(todo.acceptance_next_action, "review_original_todo_validation"); +}); + test("preflight separates task admission, acceptance binding and runtime availability", () => { const effects = {host_invoked: false, state_written: false, quota_spent: false, scheduler_acknowledged: false}; const preview = {dry_run: true, status: "preview", effects, diff --git a/tests/control_plane_ts/effect_program.test.ts b/tests/control_plane_ts/effect_program.test.ts index 7b80ad19b9..bd957cd107 100644 --- a/tests/control_plane_ts/effect_program.test.ts +++ b/tests/control_plane_ts/effect_program.test.ts @@ -70,10 +70,14 @@ function identityTypes(identity: BoundSettlementIdentity): void { const todo: string = identity.todo_id; const replan: null = identity.replan_obligation_id; void [todo, replan]; - } else { + } else if (identity.binding_kind === "autonomous_replan") { const todo: null = identity.todo_id; const replan: string = identity.replan_obligation_id; void [todo, replan]; + } else { + const request: "request" = identity.binding_kind; + const todo: null = identity.todo_id; + void [request, todo]; } // @ts-expect-error a validated identity cannot be rebound through mutation identity.todo_id = "other"; diff --git a/tests/control_plane_ts/request_settlement_identity.test.ts b/tests/control_plane_ts/request_settlement_identity.test.ts new file mode 100644 index 0000000000..fec5f0c80a --- /dev/null +++ b/tests/control_plane_ts/request_settlement_identity.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {settlementIdentity, settlementIdentityFromPlan, settlementIdentityPayload} from "../../loopx/control_plane/effect_program.ts"; + +const subject = {request_id: "a".repeat(64), request_revision: 1, + request_digest: "b".repeat(64), execution_scope_sha256: "c".repeat(64)}; +const scope = {goal_id: "fixture-goal", agent_id: "receiver", turn_instance_id: "original-turn"}; + +test("ordinary request identity preserves the original revision and execution scope", () => { + const identity = settlementIdentity({...scope, request_subject: subject}); + assert.equal(identity.binding_kind, "request"); + assert.equal(identity.todo_id, null); + assert.equal(identity.replan_obligation_id, null); + const wire = settlementIdentityPayload(identity); + assert.equal(wire.schema_version, "quota_settlement_identity_v1"); + assert.deepEqual(wire.request_subject, subject); + const parsed = settlementIdentityFromPlan({settlement_plan: {identity: wire}}); + assert.equal(parsed.failure, null); + assert.equal(parsed.value?.effect_id, identity.effect_id); + for (const change of [{request_revision: 2}, {request_id: "d".repeat(64)}, + {request_digest: "d".repeat(64)}, {execution_scope_sha256: "d".repeat(64)}]) { + assert.notEqual(settlementIdentity({...scope, request_subject: {...subject, ...change}}).effect_id, + identity.effect_id); + } +}); + +test("a request cannot impersonate a Todo or planning obligation, or change a retained effect", () => { + assert.throws(() => settlementIdentity({...scope, todo_id: "todo_existing", request_subject: subject})); + assert.throws(() => settlementIdentity({...scope, replan_obligation_id: "replan-aaaaaaaaaaaaaaaa", request_subject: subject})); + const wire = settlementIdentityPayload({...scope, request_subject: subject}); + for (const request_subject of [{...subject, request_revision: true}, {...subject, request_revision: 0}, + {...subject, request_revision: 1.5}, {...subject, extra: "authority"}, {...subject, request_digest: "missing"}]) { + const parsed = settlementIdentityFromPlan({settlement_plan: {identity: {...wire, request_subject}}}); + assert.equal(parsed.failure?.kind, "invalid_identity"); + } + const changed = settlementIdentityFromPlan({settlement_plan: {identity: { + ...wire, request_subject: {...subject, request_revision: 2}}}}); + assert.equal(changed.failure?.kind, "invalid_identity"); +});