Choose the private summary volume when opening a task runtime

Merged
VA
2d ago
+382-31

Problem

native_media_boundary::live_original_options_and_actual_native_parts_reach_adapter_without_conversion, in the Harness integration target main, has failed on main since #711:

left: Failed { message: "unauthorized: task has no owner-bound content writer" }

Bisect on bb1 with the canonical native features: 108fa5e52 (#711) fails (0 passed, 1 failed). Its parent bfbaaf2b3 (#773) passes (1 passed).

Root cause

#711 moved native compaction summaries out of the host journal. They are now published through the task context into summaries/<operation>/<step>.txt, via TaskContext::stage_private_file_once. That needs a private writer, which FilesystemTaskRuntime got only from the optional builder with_private_volume.

#711 updated LocalHarness, which calls the builder, and its own canonical test. The retained native-media scenario opens a FilesystemTaskRuntime whose stock turn compacts ("Replay, compaction summary, parent response and child response"). It never called the builder, so compaction failed mid-turn. An earlier fixture-only attempt (e90f054cc, on old #641) bound the outer bundle's writer, which is the wrong runtime, and still failed.

Fix

  • FilesystemTaskRuntime::open (and from_host) takes private: Option<&VolumeRef>. When it is Some, open binds it with the same owner, grant and native-custody checks.
  • with_private_volume is deleted. Its body is now the private bind_private_volume, called only by from_host.
  • LocalHarness passes its scratch volume.
  • The browser runtime, the retained native-media runtime and the canonical compaction test pass their agent-private volume (the browser since review round 1).
  • Every other caller passes None explicitly, each with an unchanged binding:
    • The Pi runtime: Pi tasks publish into their created scratch volume, and its authority forbids task grants on journal storage.
    • The example and the existing fixtures that never compact.

Class: an optional builder step that a compacting runtime could forget, failing only mid-turn. The private destination is now a required argument at the runtime's single constructor, so no caller can omit the decision, and the builder that allowed it is gone. LocalHarness no longer depends on remembering a chained call. Review round 1 closes the remaining gap: with None, compaction is off by default, and arming it explicitly is rejected at turn entry. Binding a native runtime to its journal volume by default isn't possible, because Pi forbids task grants on journal storage.

Deleted: the public with_private_volume builder and its two chained calls.

Review round 1: compaction needs a validated private writer

The review found that browser model commands also bind a TaskContext. With threshold compaction, enough history and no output volume, the browser still reached stage_private_file_once mid-turn and failed with "task has no owner-bound content writer".

  • Capability. TaskContext::private_summaries returns PrivateSummaries, a crate-private type built only after validation. It accepts, in order: the task's created private content, the runtime's private volume, or an output binding. The writer must be owner-bound and write-authorized, and must be an AgentPrivate volume owned by an agent. stage_private_file_once takes the capability as an argument, so summary staging cannot run without one.
  • Entry gate. verify_task_context_binding runs at every turn, model-step, tool, prefix and native entry, and from with_task_context. It now requires that capability whenever the policy is Threshold. Arming compaction without a private writer fails before any model dispatch, instead of when a summary comes due.
  • Default. stock_execution keeps the default Threshold only where the capability resolves, and uses Disabled otherwise. A runtime opened with None therefore runs without compaction unless the caller explicitly arms it. Arming it there is rejected at entry.
  • Browser. The WASM runtime now passes its authorized private journal volume, Some(&options.volume), which also holds stock-turn summaries.
  • Why there is no full compile-time typestate. Executor::execute_with_context binds arbitrary contexts at run time, and task-created private content exists only per task. "Has a private writer" therefore isn't known when the runtime is constructed. The single run-time choke point plus the capability-typed staging call covers every path that can stage a summary.

Fallout fixed in this round:

  • Three executor unit tests bind contexts that have no writer. They don't exercise compaction, so they now say Disabled explicitly.
  • The direct-executor task-workflow case and the providers run_stock_turn fixture arm Threshold, so they now open with their private volume.

Browser fixtures repaired:

  • browser-task-fixture.mjs and browser-task.html called issueScopeForAgent, which #710 removed, so every browser task page failed before running. They now call issueScope(id, caps, agent). browser-task.html passes again.
  • The existing browser-task-model.html still fails on main for other stale reasons: it lacks the stock turn's receive input and the context manifest required since #694. That is out of scope here.

Verification (bb1, canonical native features filesystem-local,native-execution,terminal,grpc,test-support,search-http,tui)

  • Browser regression (browser-task-compaction.html, registered in browser-qualify.mjs; headless Chrome 131, harness wasm dev build):
    • It runs a stock turn of two tool steps whose results cross the threshold. A summary request and the response follow, with no output volume selected.
    • It asserts the final text and exactly 4 model requests. It also asserts that the big result is gone from the final request, which starts with a summaries/… file on the agent-owned private volume.
    • PASS.
    • Negative control: the same page, against a build whose browser runtime passes None, fails at turn entry with 0 model requests.
  • Native negative control: canonical_compaction opens without the private volume and arms Threshold. It gets Unauthorized before any new model dispatch (request count stays 2). It passes; the positive canonical scenario passes too.
  • Real-path control: --test main passed 44 of 44 in 30.7 s (build 605 s). The native-media test passes. Main fails it with the exact message above (43 passed, 1 failed on 2ecd4f21a, and the same in #882's CI run).
  • Open-time negative control: local::tests::root_output_requires_task_grants_and_exclusive_configuration now asserts that opening without grants on the private volume is Unauthorized. Output denial is still checked separately, against the ungranted journal volume. The test passes.
  • Owner matrix: the existing private_content_owner_matrix property and its broken-policy control are unchanged and pass.
  • Other targets:
    • --test task-workflow passed 23 of 23 in 0.47 s.
    • acyclic-harness-providers --lib stock passed 5 of 5 in 0.60 s.
    • cargo check -p acyclic-plugin --lib --tests passed.
    • WASM browser --lib with -Dwarnings passed.
    • The Windows GNU canonical --lib --tests check passed. It includes the approved-native-process example, whose missed call site the first Windows run caught.
  • Pre-existing failures, unchanged: --lib filters filesystem::task_runtime:: durable_host::tests:: local::tests:: give 133 passed and 8 failed, both with this PR and on baseline main 2ecd4f21a. The failures are future-size bounds (execution_and_worker_futures_stay_bounded, native_stock_model_original_lost_ack…) and local-harness tests sharing the box's ~/.local/share/acyclic state ("core state namespace is in use", "cannot close an unfinished local turn"). One failure differs between runs: goals on base, command_files here, both from that shared state. These need their own follow-up and are not touched here.
  • Round 1 gates:
    • acyclic-harness-providers --lib passed 224 of 224.
    • --test task-workflow passed 23 of 23. --test main passed 44 of 44. --lib executor:: canonical_compaction passed 84 of 84.
    • WASM browser --lib with -Dwarnings passed. The Windows GNU canonical --lib --tests check passed.
    • A wider --lib sweep covers executor:: compaction filesystem::task_runtime:: filesystem::memory:: local::inheritance:: local::admission::. It fails the same 11 tests at #898's previous head daf32cd2e as with this change (future sizes, shared local state, memory-summary and poll-sweep coverage). local-recursive-fork deep:: (items examined) fails on both too.

Review round 2: direct summary admission

StockExecutor::summarize could dispatch a summary request before rejecting a writerless task: its internal summary session disables recursive compaction, so the policy-dependent entry gate was skipped.

The shared summary path now resolves PrivateSummaries before admission, independently of compaction policy, and passes the validated capability together with its task context into publication. Publication no longer resolves the writer after model dispatch. This closes the class across direct summaries, in-turn summaries, summary-file turns and automatic compaction.

A shrinking property varies summary history and text, checking both public summary entry points under Disabled and Threshold after binding a writerless context with Disabled. Every case must return Unauthorized with zero model requests and no journal writes. A negative control recreates the former ordering through the real internal summary session and model-step path: exactly one request is dispatched before private-writer resolution returns Unauthorized.

Validation on bb1 at signed commit 00156319d, using canonical native features filesystem-local,native-execution,terminal,grpc,test-support,search-http,tui:

  • Focused library binary (executor:: canonical_compaction private_content_owner_matrix): 87 passed, 0 failed, 4.10 s (4.116 s wall).
  • Task-workflow binary: 23 passed, 0 failed, 1.74 s (1.766 s wall).
  • Combined focused Linux test build: 394.165 s.
  • Windows GNU cargo check --locked -p acyclic-harness -p acyclic-harness-providers -p acyclic-plugin --lib --tests --features filesystem-local,native-execution,terminal,grpc,test-support,search-http,tui --target x86_64-pc-windows-gnu: passed, 298.118 s.

Main conflict resolution (2026-10-18)

Updated to main ddc5cf325 in signed merge 870963d22. The LocalHarness conflict preserves main's erased task-file binding and concrete projection-file binding. The private-volume choice remains in the runtime constructor, replacing the removed builder call.

Focused validation through Q:/lanes/bs.ps1, canonical native features filesystem-local,native-execution,terminal,grpc,test-support,search-http,tui:

  • Executor/compaction/owner matrix: 101 passed, 4.76 s (4.777 s wall), including the summary admission regression and negative control.
  • Task-workflow: 23 passed, 1.40 s (1.412 s wall).
  • Retained native-media regression: 1 passed, 0.37 s.
  • Local task-file write/follow/reopen: 1 passed, 0.26 s.
  • Windows GNU --lib --tests check for Harness, providers and plugin: passed, 413.602 s.
  • Combined focused Linux test build: 1076.001 s. Built test binaries ran directly; no CI wait.

Decisions

  • Preserve main's two task-file bindings when resolving the LocalHarness conflict. Update the pushed branch with a signed merge as required by the lane's history rules.
  • Resolve the private summary capability in the shared summary entry regardless of compaction policy, and carry it into publication. This keeps existing public APIs and prevents a nested Disabled summary session from deferring authorization until after dispatch.
  • Option rather than a required &VolumeRef, because Pi deliberately has no runtime-level private volume.
  • Fixtures that never compact pass None, keeping their open-time grant requirements unchanged. Only the two compacting fixtures bind.

🤖 Generated with Claude Code