Commit graph

73 commits

Author SHA1 Message Date
Scott Werner
daf82929a0 Adopt Petri skill expansion boundaries 2026-10-14 17:34:34 -04:00
Scott Werner
ac84eccfcc
Merge pull request #1034 from fabro-sh/codex/run-client-headers
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Rustdoc (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Format (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Sandbox providers (Docker) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Process titles (musl) (push) Waiting to run
Restore run session headers using scoped model clients
2026-10-14 16:21:13 -04:00
Scott Werner
8780cb1bf6 Scope run model clients with session headers 2026-10-14 12:44:52 -04:00
Scott Werner
560687e76b Restore graph-wide stall watchdog in Fabro execution 2026-10-14 11:20:00 -04:00
Scott Werner
10e0884079
Merge pull request #1024 from fabro-sh/codex/run-git-identity
Propagate run Git identity to commands and agents
2026-10-13 13:46:11 -04:00
Scott Werner
81d63a0683 Track Petri's root usage breakdown by model
Bump Petri to the commit that reports `pebble.usage_by_model`, the root
session's usage by the route that spent it, and add an end-to-end test:
an agent stage on a scripted model whose primary answers once and then
fails mid-prompt keeps every token Petri reported, and files the
primary's share and the fallback's share under their own models.

Only Petri moves; Pebble and sandbox-driver stay at their locked
revisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:38:02 -04:00
Scott Werner
dc30d9c1fa Propagate run Git identity to commands and agents 2026-10-10 08:53:50 -04:00
Scott Werner
04309977e5 Commit checkpoint failures through required finalization
A failed checkpoint cancels the run, so Petri recorded it as cancelled
and the worker overrode its own outcome in memory. Runs that checkpoint
now declare required finalization, and finalize_run rejects with
checkpoint_failed before publishing. The projection and the engine
outcome report a cancelled finish carrying that failure as a workflow
failure with the checkpoint's message, and the in-memory override is
gone. A run whose checkpoint failed is never published.

Retry a host failure that storage rejects with a short backoff, so a
brief storage fault does not leave the run active and holding its
scheduler slot. A failure that never commits still leaves the run's
status alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:00:01 -04:00
Scott Werner
599b674ec1 Build required publication on Petri main's finalization
Track Petri main now that required run finalization has merged there,
read workflow execution from the engine state, resume the failed-publication
regression with its admitted workflow, and keep the worker lifecycle helper
usable for rejected appends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:45:25 -04:00
Scott Werner
3443a923c0 Carry required publication into fork declarations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:45:25 -04:00
Scott Werner
59f9088b04 Preserve committed outcomes during host failure cleanup
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:45:24 -04:00
Scott Werner
21d4db6766 Commit publication failures through required run finalization 2026-10-08 12:45:24 -04:00
Scott Werner
e889f9978d Simplify the store-fault handling
- stop_lock_holder returns the stopped pid as Option<u32> instead of a
  LockHolder enum that only wrapped it
- stop_previous_worker uses with_context, and one run_scratch helper
  replaces three spellings of the run's scratch path
- relaunch builds its runnable record through run_records::runnable,
  moved out of the lifecycle handler so both callers share it
- WorkerExit derives success from its exit code instead of storing both
- the scenario tests share one worker-pid lookup and wait loop
- small readability fixes in the engine's resume arm and the resume test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:07:36 -04:00
Scott Werner
f12065e6ae Adapt recovery to current resume callers and API errors 2026-10-06 12:29:34 -04:00
Bryan Helmkamp
37116758fa Resume a Petri run whose store failed, as after a crash
Petri now ends a run's lifetime at its first failed store write: it
records nothing after it, fails no firing for it, and returns
CoordinatorError::StoreFailed. The run is not over; the next lifetime
resumes it from what the store holds. Fabro read that error as an
unfinished run and failed it.

- engine: RunError::StoreFailed, returned without reading the record
  back, and Conclusion::Interrupted for it.
- worker: an interrupted run gets no terminal lifecycle record; the
  worker exits with EX_TEMPFAIL (75, the new ExitClass::Interrupted).
- server: WorkerExit carries the exit code. An interrupted worker's run
  goes back to the scheduler in resume mode through the relaunch a
  restart takes (lease release, recovery, start_requested + runnable),
  now shared with reconcile_on_startup. The in-process path does the
  same. A run is resumed at most MAX_STORE_INTERRUPTIONS (3) times per
  server; the next interruption fails it. A pending cancel, a run that
  ended or was deleted, and a shutdown also end it as before.

Tests: an engine run over a store whose first lease write fails is
interrupted with no finish, and a resume finishes it; the server
relaunches an interrupted worker in resume mode, fails the run after the
bound, and fails a worker that exits 1 as before; exit code 75.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:29:34 -04:00
Bryan Helmkamp
3c395f9e6e Start a Petri run again when its creation was cut short
Petri now refuses to resume a run whose creation a crash cut short (the
key is stored, the root invocation is not) with HostError::NotStarted,
and starts it again when the host runs it under the same key. The engine
used its own guard, check_resumable, which failed the run with
NothingToResume instead.

Execution::Resume now carries the admitted graphs, and a resume Petri
answers with NotStarted starts the run from them. The worker loads the
graphs in resume mode too, as does the server's in-process path. The
guard and RunError::NothingToResume are gone. New test:
a_resume_of_a_run_that_never_started_starts_it_again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:29:34 -04:00
Scott Werner
b401ac0a10 Bind CLI model flags as Petri's launch model and settings as its default
Petri now treats `petri.launch_model` and `petri.launch_provider` as the
model a run's flags ask for, above the file layers and the graph's
defaults. A host's last-resort default moved to `petri.default_model` and
`petri.default_provider`. Pin Petri at the merge of that change and bind
to it: the explicit `--model`/`--provider` flags go to the launch
variables, and the model the settings resolved (or the catalog default)
goes to the default variables.

`Launch` now names the two pairs `model`/`provider` and
`default_model`/`default_provider`, matching Petri, in place of the
separate override fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:28:27 -04:00
Scott Werner
4e71554773 Apply CLI model and provider overrides to agent execution 2026-10-02 11:28:16 -04:00
Scott Werner
500cd25814 Keep network policy off host runs and tidy the network tests
The host provider manages no networking and refuses any policy but its
default, so sending the resolved AllowAll to local runs failed every
non-dry-run local execution. Apply the run's policy only on container
backends; dry runs, which always use the host backend, are covered by the
same check.

Also fold the Docker environment test helper into one that takes a typed
network mode, share the probe setup between the live Docker and Daytona
network tests, count canary hits per mode, and bind the run environment
once in the worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:17:17 -04:00
Scott Werner
cd5f56a4ed Enforce resolved network policy for Docker and Daytona runs 2026-10-01 17:12:03 -04:00
Scott Werner
6e0ef2c402 Keep Git checkpoints for runs on host workspaces
Checkpoints were only enabled for runs with a GitHub source, so local
folder runs, empty Local runs and dry runs stopped committing. `fabro
diff` then failed for them, and their checkpoint, run branch and diff
records disappeared from the event stream.

A run whose workspace is on the host now commits checkpoints there
again, without pushing, as on main. Docker and Daytona runs with no
GitHub source still record execution checkpoints without Git commits,
so a sandbox image without `git` cannot fail the run.

The scenario tests for crash recovery go back to asserting commits. A
workspace deleted while the run is down now fails the resumed run,
since the server keeps no copy to restore it from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:01:53 -04:00
Scott Werner
23591d550f Reuse cached GitHub installation tokens for run fetches and pushes
The worker minted one read token at launch and a fresh push token for
every checkpoint. The read token expired an hour into a run, so a
workspace acquired later fetched with a dead credential. Minting per
push put every push in GitHub's token-replication window, where a token
minted moments earlier is rejected with 404 "Repository not found".

The worker now keeps two InstallationTokenSource caches for the run, a
read-only one for fetches and a contents: write one for pushes. Each
fetch and push resolves through its source, which reuses one token until
it nears expiry and then mints the next. Petri's RunSource asks a
SourceCredentials provider on every fetch instead of holding a fixed
credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:01:13 -04:00
Scott Werner
aad954693e Keep checkpoints and run branch pushes in the sandbox 2026-10-01 13:01:13 -04:00
Scott Werner
886065957c Simplify GitHub checkout and run publication
- Share one credential header helper between the in-sandbox fetch and
  the run branch push
- Keep only the target branch, goal, and model on the publisher instead
  of a full run spec copy
- Load the worker's LLM catalog once, and mint the read token only when
  the run checks something out
- Pass the source explicitly to checkpoint fetch helpers, dropping
  unreachable branches, and reuse has_object in has_commit
- Move the run patch into the publication instead of cloning it, and
  build it only when a publisher exists
- Add test fixture helpers for file sources and recording publishers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:00:26 -04:00
Scott Werner
0250b40586 Publish a successful run from its run_finished hook
Pushing the run branch and opening the pull request now happen in the
run's worker, in Fabro's run_finished hook, after the last stage and
before the run's terminal record, as the legacy publish step did. A
failed push or pull request fails the run with publish_failed instead of
leaving a warning on a run that already succeeded.

fabro-petri gains a RunPublisher the hooks call for a successful run with
its run branch, final commit, snapshot repository and patch; the worker's
GitHub publisher pushes from the snapshot repository with a push token it
mints at that moment, opens the pull request its settings ask for, and
records it. The worker resolves the server's GitHub credentials itself
for both the read-only checkout token and the push token, so the server
no longer hands it a clone credential or publishes after the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:00:26 -04:00
Scott Werner
5c6195a352 Check GitHub targets out in the sandbox and publish their run branch
Since the Petri cutover, a run with a GitHub target started in an empty
workspace, nothing pushed its run branch to GitHub, and nothing asked for
the automatic pull request when it succeeded.

Fabro's hooks now check a fresh run's GitHub target out inside the
sandbox when Petri hands them the scope, before the first stage: the
workspace fetches the selected commit, tag or branch at the run's clone
depth, with a read-only token the server resolves at each worker launch.
The worker scrubs the token from its environment at startup and presents
it only to the fetch, so it never lands in the repository or its remote.
The files belong to the sandbox user, so git accepts them.

The same checkout seeds the workspace's snapshot repository with the
starting commit. Checkpoint bundles from a shallow clone then import, a
stage's own commits never make a bundle carry the source's history, a
restore into a fresh sandbox fetches the base again and applies the run's
commits, and a fork carries the base with its checkpoints.

When a successful GitHub-target run ends, the server pushes its final
commit from the snapshot repository to fabro/run/<id> with its own write
credentials, then, when the run changed files and asks for one, records
the pull request request for the existing creation supervisor. A failed
push or request is a warning notice on the run. The manual pull request
endpoint shares the request step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:00:26 -04:00
Scott Werner
cae2a7fcdd Inherit Petri container defaults and preserve resource overrides 2026-09-29 11:26:08 -04:00
Scott Werner
c6464fb646 Restore Daytona container runs and configured resources 2026-09-28 23:02:34 -04:00
Scott Werner
d26b2324f0 Merge remote-tracking branch 'origin/main' into codex/restore-configured-artifact-storage
# Conflicts:
#	lib/apps/fabro-cli/tests/it/scenario/artifacts.rs
2026-09-28 16:27:30 -04:00
Scott Werner
7f55dd87b5 Harden artifact capture writes
- The store checks that captured bytes match their digest in every build,
  hashing on a blocking thread, and the upload handler relies on that
  check instead of hashing a second time.
- Capture bytes travel as `Bytes` from the hooks through the client and
  the store, so uploads and retries share one buffer.
- The artifact writer takes the run ID from the hooks, so objects are
  stored under the run their records name.
- Concurrent captures of the same file and content wait on one another,
  so the file is uploaded and recorded once.
- When a record append fails, the hooks re-read the run's captures and
  treat a record that did land as done, so a lost response does not
  record the capture twice.
- An upload that finishes after its run was deleted removes itself,
  instead of leaving an object nothing references.
- Listing a run's stage artifacts skips everything under `captures/`, so
  an unexpected object there cannot fail the listing or the ZIP.
- The capture record derives its `digest` key from its source instead of
  storing it twice, still writing and checking it on the wire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:30:36 -04:00
Scott Werner
c7176bd572 Fix platform blockers in nightly releases 2026-09-28 11:37:15 -04:00
Scott Werner
2e500b0e2f Simplify artifact capture writer and storage helpers
- The artifact writer takes the digest the hooks already computed, so
  captured bytes are hashed once on each side, and the dead integrity
  error goes away.
- The writer is a required part of HooksSpec, not an optional field on
  RunRequest, so a run with capture globs always has a writer and the
  no-writer error goes away.
- ArtifactStore routes put/get and the capture methods through shared
  put_at/get_at helpers.
- The upload handler parses the digest with parse_blob_hash_path before
  any store reads, and builds its size-limit message from the constant.
- The default local artifact root comes from one helper used by both
  config resolution and the storage-dir override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:32:13 -04:00
Scott Werner
a1a98c69d0 fix: address review of the in-process sandbox providers
- Keep plugin-era Daytona lease fingerprints: read only DAYTONA_API_URL and
  DAYTONA_ORGANIZATION_ID (no URL alias, no placement target), and stop
  forwarding DAYTONA_SERVER_URL and DAYTONA_TARGET to the worker.
- Take the Docker fingerprint and network from this process's DOCKER_HOST,
  the endpoint the Docker client actually connects to; make the provider
  configuration's fields private.
- Return an error instead of panicking when Petri supplies no Host registry.
- Run deletion reads the Daytona key only for a Daytona run, and a forced
  or restarted delete goes on when the secret store fails, as it does for
  every other prune failure.
- Stop putting DAYTONA_API_KEY in the worker's environment; the worker reads
  it from the vault. Give the worker's Daytona client the shared HTTP client.
- Fork, rewind and retry no longer read the vault: a fork acquires no sandbox.
- Remove the dead worker plugin forwarding and document that runs execute
  only on the built-in providers.
- Build every Petri runtime through providers::standard_runtime or
  bare_runtime, with a Clippy lint against Runtime::standard/bare.
- Share the Docker require-or-skip policy in fabro-test, tighten the Host
  scope assertion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:31:37 -04:00
Scott Werner
8a09e8fa08 refactor: tidy the in-process sandbox provider wiring
Load the Daytona key for fork and prune through one AppState method
instead of two copied vault reads, and pass the sandbox configuration
into runtime_spec rather than building it and overwriting it. The
worker reuses the CLI's process_env_var lookup.

Share one Docker availability check and the backend-requirement
variable through fabro-test, drop the built-in plugin path and pin
constants nothing reads any more, and let enabled_plugins() exclude the
bundled kinds itself. Refresh the comments and the spawn_env test that
still described built-in plugins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:29:29 -04:00
Scott Werner
6ac6d5495f fix: run built-in sandbox providers in process
Register lazy Host, Docker, and Daytona factories for Petri execution,
fork, and prune. Share server provider configuration, preserve lease
fingerprints, and source Daytona credentials from the vault.

Remove built-in plugin setup and skip gates; add a release-mode worker
and prune regression to catch the failure that blocked nightly builds.

Co-Authored-By: Codex <noreply@openai.com>
2026-09-24 17:14:47 -04:00
Scott Werner
a1926c8632 Restore configured artifact storage for workflow captures 2026-09-24 13:53:46 -04:00
Bryan Helmkamp
1fda9633e4
Bind the run's resolved goal into Petri's check
An intent's goal override, and a `[run.goal]` layer, reached the run's
display graph and its settings but not Petri's check, so the agent stages
executed with the workflow's own goal while the run showed the override.
The launch now carries the run's resolved goal (the settings' inline
`run.goal`, layered as the create path layers it) as Petri's
`petri.launch_goal` compile variable, which Petri binds over the bundle's
`[run] goal` and the graph's own `goal`, so admission's frozen plan carries
the goal the run shows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 03:48:35 -04:00
Bryan Helmkamp
12483e081e
Plan recovery through a Planner and share the run's Git settings with the hooks
RecoveryRequest::for_run and HooksSpec::for_run derived the author, the
identity source, the checkpoint settings and host_workspaces from the
run namespace with the same expressions. RunGitSettings, in checkpoint.rs
beside RunWorkspaces, is that derivation once; both specs carry it.

recovery::plan nested the "recorded, else found and reconciled" lookup
two loops deep and tracked a found flag over a tuple list. A Planner
holds the records, the workspace lookup and the recorded checkpoints,
and its methods read in order: targets, snapshot_of, reconcile_record,
newest. Candidate names the (key, sha) pair, and the Target struct that
duplicated its key's execution is gone; last_finish yields the
CheckpointKey itself, whose Display the failure reason now uses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:15:11 -04:00
Bryan Helmkamp
4216364e92
Split the projector into the pass, its ordering rule, its stream and its wake-up
projector.rs mixed the commit rule with things that are not the pass:
the ordering rule and its tests, the in-process signalling store, the
stream table's rows and reads, and six readers documented "for a test".
projector/mod.rs now holds the pass alone; order.rs, signalling.rs and
stream.rs hold the rest; the test readers (rebuild, stored_projection,
stored_stream, stored_platform_records, event_json) live in test_support
behind the test-support feature, as the test-support boundary rule asks,
and the two test callers reach them there. The fault injection and the
cache's test-only reader are gated the same way, so neither ships.

pass() reads as three steps: at_head, read_new_events (a NewEvents
struct in place of a tuple) and stream::stream_rows, which rebuild
shares instead of repeating the fold loop. PassReport::skipped and
PassReport::contended replace the zero-filled literals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-20 15:10:03 -04:00
Bryan Helmkamp
6082f82950
Fix the gate findings in the prune change
Clippy's absolute-paths lint on the rendered prune error, the sync
directory reads the prune test documents, and a redundant rustdoc link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 15:02:20 -04:00
Bryan Helmkamp
c7aa50c943
Delete a run's sandboxes through Petri's lease ledger
Run deletion called the driver's `provider.delete(id)` under the run's
`petri.run` scope, a delete of Fabro's own over a sandbox whose lease
record Petri owns. It now goes the way `petri sandbox prune` goes:
`fabro_petri::prune` builds the run's Petri runtime over the server's
store (the run key, the run directory, the sandbox backend) and calls
Petri's prune, which opens the run for writing, checks each lease's
provider fingerprint, writes the delete intent and the tombstone beside
the run's other records, and lets each provider remove its managed
workspace, a host workspace included.

A run a live process holds answers 409 unless the delete is forced; a
lease Petri could not prune answers 409 with the problem text, or is
warned and skipped under force or a delete that already started. The
server drops the worker's handles before the prune, on the store
instance the prune opens, so the lease a stopped worker held is released
first. The projection reads only the coordinator and execution logs, so
the resource records change nothing it reports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:54:03 -04:00
Bryan Helmkamp
b1d95faa57
Hand Petri the server's environment and MCP catalogs
Petri's Fabro frontend refused a bundle naming an environment it did not
declare and every MCP catalog reference, so the fixtures declared
`[environments.local]` and the server's catalogs never reached Petri.

Pin Petri at c874b86, where the frontend reads `[environments.<id>]` and
`[run.environment]` from every settings layer (bundle over project over the
host's layer, key by key), takes the environment a launch selected over the
layers, and resolves `[run.agent.mcps.<name>] id = "..."` against a catalog
the host binds. The server hands Petri its environment catalog as
`[environments.<id>]` tables of the settings layer it already passes, the
intent's environment as the launch's selection (`Launch::environment`, as
the intent overrides the bundle in Fabro's own resolution), and its MCP
catalog as `RuntimeSpec::mcp_catalog_toml`, one inline entry per definition
keyed by id. Offline validation hands Petri the seeded catalog the same
way, so `fabro validate` accepts `[run.environment] id = "local"`.

The fixtures drop the `[environments.local]` tables they carried for this;
the secrets test keeps its own, on purpose. Scenario tests cover a bundle
naming a catalog environment (its image lowered, and run on Docker when the
plugin and a daemon are there), a bundle's own table winning key by key,
the server refusing an unknown environment before Petri, and a catalog MCP
reference whose tool the agent session lists (an echo server under
`test/mcp/`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:08:08 -04:00
Bryan Helmkamp
572f89a6f2
Read the tools, question details, script and condition off Petri's records
The Fabro halves of D1, D2 and D3. A stage's `agent_tools` is the union, by
name, of the `attractor.tools` payloads its native sessions record, with
`invoked` flipped by the envelope's `ToolCallStarted`; the payload carries
Petri's origin category, so Pebble's category is `subagent` for a sub-agent
tool and `other` for the rest. A pending question carries each option's
description and preview and the question's context, and its reference as
the review target when Fabro's validation admits it; the interview dock and
the Q&A renderer show the previews beside the descriptions, and the attach
prompt prints both under each choice. The web's command view reads the
script from the node's `meta.script`, the decision renderer the matched
condition from `meta.edges[edge].condition`, and `run events --pretty`
prints the condition on the transition line and one line per session
naming its tool count. The command view notes what the output capture did
not keep, from the final `step.finished` loss metrics.

The web fixtures are recaptured at the pin, so they carry the new facts.
VIEWS.md loses the two gap rows Petri filled and names the sources; the
README's list of what the fold leaves default shrinks to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 22:17:15 -04:00
Bryan Helmkamp
58df7473f8
Declare the batch sizes ahead of the projection tests' statements
The nightly clippy gate denies an item after a statement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 20:34:55 -04:00
Bryan Helmkamp
1ed30db1d6
Keep a live run's replay and view between projector passes
Each projector pass replayed the run whole through `replay_since` to rebuild
the engine and invocation state the derivation needs, so a pass cost the
run's length. The projector now keeps, per live run and behind the run's
pass lock, Petri's `RunReplay` and the view as the last committed pass left
it (`projector/cache.rs`), and a pass advances the replay over the records
past the ones it consumed: it reads and folds only the new records. The
SQLite store answers `read_from` with `seq >= ?`, and the signalling store
forwards it.

The rules hold as before. Records first: the cache moves only after the view
transaction commits, and a pass that commits nothing (a platform record
landed under it, a fault before the transaction) keeps the events it
derived as pending for the next pass. The cache is never checkpointed and
never a source of facts: it is dropped when the run records its finish,
after ten idle minutes, when the stored view moves under it, when the run is
deleted, and with the process; the first pass after that rebuilds it by a
full replay, filtered to the held positions. A torn tail fails the advance,
which leaves the replay where it stood, so the view holds and the pass is
retried. `PassReport::replayed_records` says how many records a pass fed
through the derivation.

Two projection tests: the records of a parallel run land in batches and
every pass replays at most its batch, with the finished run's cache dropped;
a restart and the idle period drop the cache and the next pass replays the
run so far once, then only its new records, and the view equals the rebuild
throughout. `test_support` exposes whether a cache is held and the idle
sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 19:58:03 -04:00
Bryan Helmkamp
baa2fc8b5c
Keep every workspace under Petri's retention and say why
Petri's retention decides whether a released workspace is kept or removed.
Fabro's lifecycle settings decide whether a sandbox keeps running after
the run and whether a delete may remove it; none asks for removal at the
run's end, and the sandbox tab, `fabro cp`, the run's delete and the
sandbox scenarios read the container after the run. So the mapping is
`Retention::Always` for every setting, named once as `engine::RETENTION`
with the reasoning, instead of a per-setting function that released a
finished sandbox.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 18:39:58 -04:00
Bryan Helmkamp
2407ce3d8d
Fold artifacts, diffs, blob references and dry-run responses into the projection
The projection lists every `artifact.collected` record under the stage's
label, carries a checkpoint's patch blob as its `blob://` reference on the
stage and the checkpoint, and takes `run.diff` as the conclusion's diff,
whichever side of the run's finish it arrives on. A command's `stdout`
from `step.finished` becomes the stage's output, so an offloaded output
shows as its reference rather than the live log's bytes, and a simulated
prompt or agent stage carries the stub's text as its response.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 16:00:11 -04:00
Bryan Helmkamp
67ba595b01
Record the run branch, identity, artifacts and diffs from the hooks
The commit that creates a workspace's run branch records `run.branch`
(the base commit, or the first checkpoint in a workspace with no history)
and `git.identity`. Every checkpoint record after the first carries the
stage's diff from its parent commit, with the patch as a text blob. After
the checkpoint record, the transition hook lists the stage's workspace
through the scope's environment, on the host and in a sandbox alike, and
collects every file under `[run.artifacts] include` into the blob table as
an `artifact.collected` record, skipping a file already collected under
the same path and digest. At the run's end the hooks diff the branch's
last checkpoint against its base in the snapshot repository and record
`run.diff`.

`engine::retention` maps the environment's lifecycle settings onto
Petri's workspace retention instead of always keeping every workspace:
`preserve`, `stop_on_terminal = false` and the local provider keep them,
anything else keeps a failed scope's only. The hooks docs no longer name a
redundant link target, so rustdoc passes with warnings denied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 16:00:11 -04:00
Bryan Helmkamp
4942788297
Drop the run_events table and narrow the runs row
Every run is a Petri run whose history is `petri_records` and
`platform_records`, so the legacy run event log has no reader left.
The new migration drops `run_events` (with its indexes), the two
one-time activation tables, and rebuilds `runs` without the columns
only that log wrote or read: `source_last_seq` and the six token and
file-count columns nothing read, as VIEWS.md records. Pre-cutover
development runs are discarded, as decided; the surviving columns of
existing rows are copied across.

fabro-db loses the three migration consts of the dropped schema and the
session-owner preflight that inspected `run_events`, and gains
`DROP_RUN_EVENTS_MIGRATION_SQL` so fixtures that install the runs
schema reach the production shape. The run summary upsert binds only
the surviving columns.

Tests: the `run_events` schema, query-plan and preflight tests are
deleted; `runs_schema_has_its_final_shape_without_the_legacy_event_log`
pins the final columns and indexes, and
`dropping_the_event_log_keeps_the_run_rows` migrates a database left by
an older binary and checks the run row survives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 14:37:18 -04:00
Bryan Helmkamp
3e8b2ebfcc
Run the server's lifecycle over platform records instead of run_events
Step 4 of the legacy executor deletion, first commit of several: step 4
spans commits because the legacy event log and its consumers cannot go
in one compiling change. This commit moves every writer off `run_events`;
the reducer, `EventBody`, the Slate bridge and the API's event types
still exist for the readers the next commits port or delete.

Writers:
- The server records a run's lifecycle (submitted, runnable, starting,
  running, blocked, paused, control requests and effects, the terminal
  status), its title, parent link, archive state, notices and pull
  request state as platform records (`fabro_store::platform_records`),
  through the new `server::run_records` module. Every append wakes the
  projector and waits for its pass, so the read that follows a write
  holds the record.
- Pull request creation is recorded as `pull_request.requested`,
  `pull_request.created`, `pull_request.failed`, `pull_request.linked`
  and `pull_request.unlinked`; the projection folds them into the run's
  pull request and creation state.
- Answers to questions are recorded as `interview.answered` with the
  answering principal and the answer text; the interview adapter no
  longer posts legacy `interview.*` events (`QuestionSink` is now an
  optional observer).
- The worker (`fabro run __run-worker`) records its lifecycle, notices
  and pause state over `HttpPlatformRecords`; `HttpRunStore` for the
  legacy event log and the worker's `run_store` are gone.
- `persist_created_run` appends `run.created` and `run.submitted`.

Readers:
- A stream follower (`server::stream_follower`) follows each live run's
  stream (Petri events and platform records), folds lifecycle records
  into the in-memory run state, forwards items to the global attach
  broadcast, and syncs blocked and paused from the projection.
- Slack posts questions from the projection's pending interviews,
  finishes them on `interview.answered` or `question_expired`, and sends
  lifecycle notifications with `notification.sent` dedupe.
- `GET /runs/{id}/events` and the attach endpoints serve only the run
  stream; the per-event, per-stage and `POST /runs/{id}/events`
  endpoints and their tests are deleted.
- `Database::load_run_projection` reads the Petri projection only.

Deleted with the writers:
- The SQLite blob and run-history activation migrations and their
  legacy Slate imports (`legacy_blob_import`, `legacy_run_history_import`,
  the activation backup): a greenfield server has no Slate history to
  import, and the run-history verification refused to start a server
  whose runs have no legacy events.
- `fabro-workflow`'s `operations::archive` and `operations::run_store`.
- The server's legacy-event unit tests and the CLI's `HttpRunStore` tests.

The in-process answer transport is now set after the starting and
running records land, not gated on the live status still being
`Starting` (the records already moved it).

The manifest validation test for a `run.agent.mcps.<name>` catalog
reference now expects `unsupported.workflow_toml.run.agent.mcps.reference`:
Petri's Fabro frontend has no server catalog to resolve it against.

Legacy readers still fail their tests until the next commits: the
reducer and Slate tests in fabro-store, the fabro-workflow create tests
that read the run back through the legacy store, the CLI tests seeded
through `POST /runs/{id}/events`, the CLI's legacy attach and render
paths, the sessions API, the OpenAPI conformance test, and the web
fixtures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:32:06 -04:00