- 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>
A worker leads a process group of its own, so it outlives a server
crash. The restarted server released the run's lease from outside and
launched a resume while that worker could still be running: a worker
whose lease is released keeps acting until its next write, beside its
successor. A delete likewise dropped the lease without knowing the
worker was gone.
Now each worker holds a lock on `worker.lock` in its run's scratch
directory for its whole life, taken before anything else. It is a POSIX
record lock: the kernel frees it only when the worker exits, and names
the process that holds it. Before the server ends a lease from outside
(the relaunch after a restart or a store interruption, and a delete), it
kills whatever process still holds the lock, with its process group, and
waits until the lock is free. A worker that finds the lock held does not
start.
- fabro-proc: ProcessLock::try_hold and stop_lock_holder, tested with
this test binary as the holding process.
- fabro-config: RunScratch::worker_lock_path.
- New scenario: a worker that outlives the server is gone before the
resume's worker launches, and the run succeeds once. It fails without
the server-side stop.
A host stage process runs in a process group of its own and still
outlives its killed worker, as it did at a worker crash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The docs deployment has failed on every main push since the checkpoint
endpoint was removed: the API navigation still listed
`GET /api/v1/runs/{id}/checkpoint`, and Mintlify refuses to build a
navigation entry the OpenAPI spec no longer has. Drop the entry.
`mintlify validate` also rejected the settings reference, where MDX read
the value type `table<string, array<string>>` as a JSX tag. The options
reference generator now writes angle-bracket types as code, and the
reference is regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
Servers have always moved a local artifact store under the storage
directory at startup, whatever `local.root` said. Browser-wizard installs
write `local.root = "<storage>/objects"`, so honoring that root would move
their store and hide every artifact already written, with nothing to
migrate it. Restore the storage-directory override for local roots and
leave honoring custom roots to a change that migrates existing objects.
Installer metadata still goes through the override, so it lands where the
server reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Petri removes routing jumps (lithoscomputer/petri: RouteDecision::Jump and
the `jump` kind of `route.applied`); nothing ever produced one. The web
app no longer reads a `jump` kind, shows a "Jumped" reason or an "Edge
type: Jump" row, and the stream round-trip test uses an edge record with
its derived transition and back.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
- 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>
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>
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>
`lib/components/fabro-pebble-sandbox` moved into pebble as
`pebble_coding_agent::sandbox_driver` (lithoscomputer/pebble#27): the
`Environment` over a driver handle (`SandboxEnvironment`, was
`PebbleSandbox`), the `SandboxExec` policy, the port routes, and
`display_for_log`. The pebble pin moves to that branch head, 6d03b3b,
with the `sandbox-driver` feature on (`sandbox-driver-test-util` for the
server's tests, which take `MockSandbox` from pebble now). Nothing in the
crate was Fabro's by design; what was Fabro's stays: `SecretRedactor`
moves to `fabro-redact` as pebble's `Redactor` over `redact_string`, and
the log renderer takes it where a driver failure is rendered.
`fabro-petri` hands pebble types to Petri's crates, so Petri must pin the
same pebble revision: the petri pins move to lithoscomputer/petri#36
(9ee3f85), which pins pebble at the same head. Both re-pin to the pebble
merge commit together once #27 merges.
The 14 pebble commits between the pins fold the session projection's
lifetime tallies into `SessionProjection::totals` (and `PromptDelta`'s
into a flattened `totals`, which renames the prompt's `subagents` key to
`subagent_counts`, as the projection's already was). The stage progress
fold, the runs handler, the OpenAPI schema, the generated client model,
and the round-trip test follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four small duplications in the projection fold: a StageCompletion
literal built three times from an attempt's status, a StageModelUsage
literal built three times with no request controls, the pause and
unpause status arithmetic written four times across the coordinator and
lifecycle folds, and a bare Conclusion built beside the full one. Each is
now one function: completion() in the engine fold, StageModelUsage::new,
RunStatus::paused and RunStatus::unpaused beside blocked_reason (with
settle_control for the pending control they clear), and
Conclusion::outcome_only.
One case reads differently: a coordinator RunPaused that lands on a run
already paused behind a block now keeps that block for the unpause, as
the lifecycle Paused already did, instead of dropping it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Seven crates' files each carried the same four-line lock function that
recovers a poisoned mutex. fabro_util::sync::lock is that function, once;
the copies in fabro-petri and fabro-server are gone. fabro-template's
helper panics on poison instead, a different policy, and is left as is.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The worker's environment carried only the host, Docker and Daytona plugin
variables from the server's own environment, so a run on a third-party
provider kind never learned where its plugin was, although the server
kept `[server.sandbox.providers.<kind>]` plugin settings for its own
attach. The launch spec now derives `PETRI_SANDBOX_<KIND>_PLUGIN` and
`PETRI_SANDBOX_<KIND>_SHA256` from every enabled kind's plugin settings,
and `PETRI_SANDBOX_PLUGIN_DEV=1` when any of them sets `dev`, set after
the allowlist so the settings win over an ambient variable of the same
name and the allowlist stays the fallback.
The server's default plugin binary name is `sandbox-driver-<kind>`, the
name Petri looks up, since one executable serves both sides.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fabro's own DOT parser was left with one job after create-time compile
moved to Petri: walking a workflow's file references for the bundler and
the workflow-version store, and reading a name, a goal and two counts.
Petri's frontend parses the same language, so the parser goes and a small
crate reads the graph through Petri's.
`fabro-dot` is that crate: `WorkflowGraph::parse` over
`petri_frontend_attractor::dot` and its semantic model (defaults applied,
subgraphs flattened, chains expanded), `references(position)` as the one
walker over the static-reference vocabulary (each reference with its node
and position, file references checked to be template-free), and
`normalize_for_graphviz`, the re-emit of Fabro DOT with dotted attribute
keys quoted, which the SVG render needs. It sits beside `fabro-petri`
rather than inside it because `fabro-petri` depends on `fabro-workflow`,
which depends on `fabro-workflow-version`: the version store cannot reach
`fabro-petri` without a cycle, and the bundler should not pull the engine
in to read a graph.
Deleted: `fabro-graphviz`'s lexer, grammar, AST, semantic pass and
`parse_ast` (1,829 lines, plus the `nom` dependency); the DOT model in
`fabro-types::graph` (`Graph`, `Node`, `Edge`, `AttrValue`,
`shape_to_handler_type`), with only `ReferenceKind` kept, moved to
`fabro_types::reference`; `fabro-template`'s `visit_graph_references` and
the `GraphReference`/`GraphPosition` types, with the template-syntax rule
(`validate_static_reference`) kept there; the pull-request body's DOT
fallback summary, which was unreachable because the DOT source only
travels with the run spec whose display graph the summary already reads.
`fabro-graphviz` is now the render alone, over `fabro-dot`.
Parity: the old and new walkers were run over every `.fabro` and `.dot`
file in the repository (118) before the deletion. Every reference set is
identical. Five files differ in what Petri reads more correctly: a
backslash before a newline inside a quoted string is a line continuation
(four files, inline prompt text only), and a node named only by an edge
counts as a node (`test/edge_only_node.fabro`, 3 nodes rather than 2, so
the `fabro validate` snapshot moves). The checked-in bundles' shapes and
references are pinned by a snapshot in `fabro-dot`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The rest of the change whose deletions the previous commit carries (its
`git add` stopped at an already-removed path): `fabro_types::RunGraph`
and the `fabro-petri` builder that reads it off the admitted graph, the
server's create, validate, preflight and render paths on Petri's check
alone, the consumers moved to the new shape, the OpenAPI `RunGraph`
schemas with their parity tests, the regenerated TS client, and the
docs naming Petri's diagnostic codes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GET /runs/{id}/checkpoint duplicated what /state serves; the hidden
fabro parse command had no user; records, run_status, outcome, and
usage_rollup in fabro-workflow only re-exported fabro_types. The
importers now name fabro_types directly. format_cost keeps its two
callers (the pull request body and the CLI stage display) and moves to
fabro_types::usage; the usage rollup tests move beside the function in
fabro-types, with test_usage in its test support.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Artifacts reach the blob table through the hooks, so the POST on
/runs/{id}/stages/{stageId}/artifacts, its octet-stream and multipart
handlers, the RequireStageArtifact extractor, the client's upload
functions, and the generated TypeScript operation go. The spec loses
the operation, the multipart variant writeRunBlob never served, and
the batch manifest schemas; fabro-types loses ArtifactUpload, the
batch upload's only input type. Every list and download path stays,
and the server tests seed the artifact store directly to cover them.
fabro-server drops multer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A worker whose server is killed outright retried its control-stream
connect forever at a 5 s capped backoff, so it never finalized, never
stopped its sandbox, and lived until reboot. The worker now tracks the
start of each run of continuous connection failure and gives up after
60 s (10 s once its parent is pid 1), through the existing fatal
control-loss path that interrupts interviews and cancels the run. After
that fatal fires, the runner keeps driving the cancelled pipeline for a
bounded grace so `conclude` can stop the sandbox before the process
exits, instead of dropping the pipeline future mid-flight.
The test harness's stale-daemon reaper only matched `fabro server`
titles bound under `/tmp/.tmp*`, but `TestContext` roots are
`.ft-<label>-*` under `std::env::temp_dir()`, so daemons bound there
were never reaped. The regex now also matches sockets below a `.ft-`
directory component under any parent, and the unit test covers both
roots plus real-looking paths and `.ft-` outside a directory component.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The worker control bus was publish-only: the steer and interrupt
endpoints answered 202 once the control was forwarded, and a refusal
showed up only later as a `run.notice` on the run's stream.
A steer or an interrupt now carries a request id. The worker answers it
over the control stream it arrived on with `{request_id, outcome}`,
where the outcome is `delivered` (with the stage's label) or `refused`
(with the code and the reason). The server keeps the outstanding
requests in a registry and waits up to 5 s for the answer: the endpoint
answers 202 `{"outcome":"delivered","stage":…}`, 409 with the refusal's
code (`no_live_turn`, `no_such_stage`, `steer_refused`,
`interrupt_refused`) and message, or 202 `{"outcome":"pending"}` when
the worker gave no answer in time. The `run.notice` record on refusal
stays, under the same code, so a steer to a stage that is not running is
now `no_such_stage` there too. Pause and unpause are unchanged.
The in-process test path answers a steer or an interrupt from the run's
own controls at once. `FABRO_TEST_CONTROL_ACKS_MUTED=1` on the server
mutes the worker's answers, so a test can see the pending fallback.
`fabro steer` prints the worker's answer, and a refusal is its error.
The OpenAPI spec documents the 202 body and the 409 codes; the Rust and
TypeScript clients are regenerated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`GET /runs/{id}/timeline` lists the run's checkpoints with their Petri
positions, stages, commits and diff summaries, and its fork origin.
`POST /runs/{id}/fork` resolves a target on that timeline, creates the
new run, seeds it through `fabro_petri::fork` and queues it in resume
mode, so its worker restores the checkpoint into a fresh workspace and
continues from the position; a position inside a parallel branch is
refused with 400 before the run exists. `POST /runs/{id}/rewind` is that
fork of a terminal run followed by the source's archive and its
`run.superseded` record (207 when the archive fails); `POST
/runs/{id}/retry` forks a terminal run at its last checkpoint, rerunning
the stage that failed. The projection carries `forked_from`. The Rust
and TypeScript clients gain the four calls.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`fabro_petri::fork` is the seam rewind, fork and retry are built on (plan
item F5.1): `fork` calls Petri's `host::fork_from` over the server's run
store to seed the new run's records up to a checkpoint's position, writes
the source's checkpoint records for every kept attempt under the new run
at their positions, seeds the new run's snapshot repository per workspace
with those checkpoints' refs alone (fetched from the source's repository
under its run scratch), and records the new run branch
(`fabro/run/<new id>` from the position's commit) with its Git identity.
`check` refuses a position Petri would refuse (an unknown execution, or
one inside a child invocation) before anything is written; `stage_labels`
reads the projector's fold state so a timeline can label checkpoints and
resolve `node@visit` targets.
The resume then restores the fresh workspace itself: `scope_acquired` now
brings a host workspace to its durable snapshot too (verified after a
restart, restored from the seeded repository for a fork), through
`recovery::bring_host_to`, which restores a directory with no history
instead of resetting it. The projection folds `forked_from` from the
fork's `run.started`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Petri 639ce3e added `ControlService::interrupt_firing` and the `LiveTurns`
capability a host installs beside the pause hooks. Fabro now drives it:
`RunControls::interrupt(stage, text)` resolves its stage the way a steer
does (a label, a node name, or the run's one live agent stage) and stops
that stage's current model turn, keeping the session; the text, when
given, is the stage's next input. `engine::run` installs the live-turn set
as a runtime capability, so without it no interrupt could ever land.
The worker maps `run.interrupt` and `run.interrupt_then_steer`, both of
which now carry an optional `stage`, to that call. The control bus is
one-way, so a refusal is recorded the way a refused steer is: a
`run.notice` on the run's stream whose code says why (`no_live_turn` when
Petri refuses a stage with no turn in flight, `no_such_stage`,
`interrupt_refused`).
The server's `POST /runs/{id}/interrupt` and `POST /runs/{id}/steer` with
`interrupt=true` forward the control and answer 202, replacing the 501
`interrupt_unsupported` stub. The interrupt endpoint takes an optional
body (`stage`, `text`), refuses a finished run with 409
`run_not_interruptible`, and forwards an interrupt of a blocked run, since
an agent stage may be running a turn beside the question and the worker
judges each stage itself. `fabro events --pretty` prints the delivered
interrupt and the stage's `attractor.turn.interrupted` report.
Verified on the twin: `fabro steer --interrupt` during a long tool call
ends the turn, the text is the agent's next request, and the stream
carries the `$interrupt` record and the interrupted-turn report; an
interrupt of a gate stage is refused with `no_live_turn` and the gate's
question is untouched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Move the lithos-llm pin from 55add459 to 43a42ac28e9d9bcf40a91abc02be4f12ca274ebb,
and the three Pebble pins from a39f43e to 67c9f48, Pebble `main`, which pins
that same lithos-llm revision so Cargo holds one lithos-llm crate. lithos-llm
`main` (ca19fac) is one commit further; that commit touches only its nightly
workflow, so this pin stays on the revision Pebble unifies with.
The `openai`, `anthropic`, `gemini`, and `openai-compatible` features are
gone upstream; each expanded to `runtime`, which `bedrock` implies, so the
four names leave the fabro-llm feature list. Every other manifest already
names `runtime`.
The catalog schema now names one adapter and many codecs per provider.
`adapter` defaults to `http`, `codecs = [...]` replaces `codec` and defaults
to `["openai-chat"]`, and the loader rejects the old `codec` key and the four
protocol-named adapter ids. Every inline catalog in tests and docs moves to
the new shape: the `openai-compatible` + `openai-chat` pair is dropped as the
default, `adapter = "openai"` + `codec = "openai-responses"` becomes
`codecs = ["openai-responses"]`, and the one test that swaps in a custom
adapter id now adds the line instead of replacing one. The settings
reference, the API schema's `Provider.adapter` description, and the SDK page
describe the new fields; the three `docs/superpowers/plans/` files that show
the old shape are dated, unchecked historical plans and are left as they are.
The implied agent profile for an operator provider that declares none used
to read the removed protocol adapter ids; it now reads the provider's first
codec (Anthropic Messages and Gemini map to their harnesses, the `bedrock`
adapter to Anthropic, everything else to OpenAI), with a test for the codec
path.
Absorbing the rest of the range: OpenRouter and Fireworks now ship enabled,
so the two fabro-llm tests that used OpenRouter as the disabled fixture use
`bedrock-openai`, and the docs and comments that said the two ship disabled
are corrected. The built-in catalog grew past 100 enabled model rows
(Vercel, TypeSafe, and the enabled OpenRouter and Fireworks rosters), so the
pagination shape test walks `page[offset]` to the last page instead of
assuming one page fits.
`cargo update -p` on the four crates also re-resolved a few already-locked
edges to match the lithos-llm lockfile: `windows-sys` 0.61.2/0.60.2 ->
0.59.0 under dirs-sys, errno, nu-ansi-term, quinn-udp, rustix,
rustls-platform-verifier, tempfile, terminal_size, and winapi-util;
`windows-core` 0.61.2 -> 0.62.2 under iana-time-zone; `errno` 0.2.8 ->
0.3.14 under signal-hook-registry; and `indexmap` 2.13.0 as a new public
dependency of lithos-llm. No package version was added or removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`SteerRunRequest` takes an optional `stage`: the label the projection
shows (`node@visit`, or `node/e<execution>@visit` when two executions
share one) or the node's name. The server passes it on the worker control
message; the worker's `RunControls` resolves a label to the live agent
firing and steers that firing, and a node name through Petri's own
live-stage index. Unnamed, the one-live-agent rule stays, and the refusal
now names the live stages by their labels. `fabro steer --stage` sets it.
A controls scenario runs two agent stages side by side, sees the unnamed
steer refused with both named, and steers each apart, one over the API
and one through the flag.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`RunSandboxInstance` carries `ready_duration_ms` from the root scope's
`scope.acquired` and `retained` from its `scope.released`, so the view
says how long the sandbox took and whether it still exists after the run.
The OpenAPI schema, the TypeScript client and the web sandbox tab's
overview show both; the host sandbox scenario asserts them on a real run.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every intra-doc link `cargo doc --workspace --no-deps` warned on now
resolves or is plain code: the private constant and helper, the removed
`InterpString::resolve`, the lithos `Message`, the sandbox-driver facets,
the `RunOptions::git_author` the cutover removed, and the stale
`platform_record_for` paragraph on the platform records. The `[@REF]`
segment of `fabro run`'s help text is allowed as help, not a link. A
`Rustdoc` job runs the same command with `-D warnings`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`run events --pretty` reads the flattened `git.identity` fields, and
shows a `run.diff` record as its summary and an `artifact.collected`
record as its path and size. The snapshot filters redact the base commit a
`Branch:` line names. The CLI snapshots now carry the `Base:` line, the
`run.branch` and `git.identity` stream items, the dry run's simulated
response and the two response files a dry run dumps.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
No store sits behind `[server.slatedb]` any more: the section leaves the
settings layer, the resolved server settings, the defaults, the API schema,
the TypeScript client, the install wizard and the docs, and `fabro install`
probes the bucket for the `artifacts/` prefix alone. A settings file that
still carries the section is rewritten once at startup by a temporary
migration that removes it with a backup beside the file.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`artifact.collected {execution, firing, attempt, path, blob, bytes, digest}`
records one file a stage left in its workspace, with the bytes in the blob
table; `run.diff {base_sha, head_sha, diff_summary, patch_blob}` records
the run branch against its base; `run.branch` names the workspace the
branch was created in. `RunProjection.artifacts` lists the collected
files, and `parse_blob_ref_encoded` reads Petri's `#json` marker so a
reader knows whether a blob is text or a JSON value.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Petri a5906f6 records where each scope's sandbox ran (`scope.acquired`,
`scope.failed`) and how its lease was released (`scope.released`). The
projection folds the root invocation's records into `Run.sandbox`:
`initializing` from `run.started`, `ready` with the `RunSandboxInstance`
(the provider, Petri's `host` as Fabro's `local`, the provider's id, the
image and snapshot, the working directory) from `scope.acquired`, `failed`
from `scope.failed`; the retention outcome is kept in the fold state, since
the view has no field for it. Ask Fabro reconnect and `sandbox cp`,
`preview` and `ssh` reach the run's sandbox again.
A local reconnect designates the recorded working directory again when the
host provider does not know the id: the provider mints a registry-only id
for a workspace path too long for a path-derived one, and that registry
belongs to the run's worker. The stream listing redacts its items the way
the attached stream does, so a client that pages after a stream sees the
same items.
Pins move to Petri a5906f6 (run format 6, engine log v11, event contract
4). The attach stream snapshot is re-recorded with the new record and a
filter for the host provider's minted ids; `sandbox cp` reads an upload
back through the run's workspace, which is no longer the target folder.
Server scenario tests prove the projected instance on the host and Docker
providers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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>
Step 4 of the legacy executor deletion, fourth commit: with no writer
and no reader left, the legacy event log goes.
- `fabro-types`: `run_event` (`EventBody`, `RunEvent` and every props
struct), `EventEnvelope` and the `RunEventDetail*` types are deleted.
What the projection and the API still use moves out of the event
vocabulary: `AgentEventProps`, `AgentSessionActivatedProps`,
`AgentToolsAvailableProps`, `StagePromptProps`, `SessionCapability`
and the coding event names to `agent_props`; `RunNoticeLevel` and
`RunNoticeCode` to `notice`; `InterviewOption` beside the question
types; `RunRunnableSource` beside the run status. `Checkpoint` is
what Fabro records for a Petri run: `timestamp`, `current_node`,
`git_commit_sha`; the conclusion's stage summaries derive from the
projection's stages instead of the checkpoint's node maps.
- `fabro-store`: the Slate bridge (`RunDatabase`, the Slate `Database`,
`keys`, `record`, `EventPayload`) and the reducer (`run_state`) are
deleted. `Database` is the blob table and the run summary store over
one pool; the blob store is SQLite only; the run summary store keeps
the `runs` row a projector writes and lists, and finds the pull
request creation candidates over `platform_records`; `build_summary`
and `projected_usage` live in `run_summary`. The SlateDB dependency
is gone. Test fixtures build the store from its two SQLite stores.
- `fabro-workflow`: the `event` module (the `Event` enum, its
conversion, sink, emitter, redaction, stored fields and names),
`runtime_store`, `StageScope` and the legacy seeding test helpers are
deleted; the tests that seeded legacy runs read platform records or
a projection instead.
- `fabro-sandbox` owns `GitRetryReason`.
- The server builds the store without an object store; the legacy
`POST /runs/{id}/events` tests go, an interrupt answers
`interrupt_unsupported` in the tests as it does in the handler, and
the tests that read a run back through the Slate handle read its
projection or its platform records. The projection folds a block
that lands while the run is paused as the pause's prior block, and a
pause or unpause clears the pending control it answers; a control
request's check-and-append holds a per-run lock so two concurrent
cancels record one request.
- The CLI's final output is the response of the last stage that
produced one; the workflow tests read completed nodes from the
succeeded stages.
- The spec's `RunCheckpoint` carries the three fields the type keeps.
Still failing until the next commits: the CLI tests that seed runs
through `POST /runs/{id}/events` or wait for legacy event names, and
the two Ask Fabro resume tests (the sandbox instance gap).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Step 4 of the legacy executor deletion, third commit: the legacy event
API and every reader of it go, so that the next commits can delete the
event log, its reducer and the types beneath them.
The API:
- `GET /runs/{id}/events` pages the run stream only
(`PaginatedRunStreamList` by `after`); the legacy `since_seq`,
`before_seq` and `order` cursors, the `oneOf` envelope, the legacy
`EventEnvelope`, `PaginatedEventList`, `RunEvent`, `EventSeq`,
`AppendEventResponse` and `RunEventDetailResponse` schemas,
`POST /runs/{id}/events`, `GET /runs/{id}/events/{seq}` and
`GET /runs/{id}/stages/{stageId}/events` are deleted. `GET
/runs/{id}/attach` and `GET /attach` frame `RunStreamItem`s only.
- The Rust and TypeScript clients regenerate; the removed models leave
the TypeScript package.
The readers:
- `fabro-client` drops the legacy run event listing, tail and attach
methods and `RunEventStream`; `list_run_stream_until` bounds a stream
read.
- `fabro-tool`'s `fabro_run_events` lists, searches and details the run
stream: `after` is the exclusive `stream_seq` cursor, `event_id` the
item's id, filters match the item's name and `recorded_at`.
- `fabro-dump` writes the stream to `events.jsonl`; `fabro dump` reads
it.
- The CLI's progress renderer keeps only what the run stream drives:
the legacy event conversion, the sandbox and setup displays and their
styles go. `fabro system events` prints stream items.
- The server's demo mode folds its agent fixture straight into the
session projection and answers the attach stub with a stream item;
the demo stage events endpoint is gone.
- The web app: every run is a Petri run. The legacy event hooks,
renderer props, stage popover summary, run phases derivation and
live-event payload handling are deleted or ported to `RunStreamItem`;
toasts and board refreshes read the stream's platform records.
- Tests: the legacy API round trips and pagination tests are deleted;
the CLI's MCP, attach and system event mocks serve stream pages; the
CLI test helpers read stream items.
Still failing until the later commits: the CLI tests seeded through
`POST /runs/{id}/events`, the server tests over the legacy store, and
the legacy type tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Step 4 of the legacy executor deletion, second commit. Ask Fabro's
sessions were the last writer of `run_events`: a session's creation, its
turns and their messages, tool calls and endings went into the run's
legacy event log, keyed by the run's sequence. They now have a log of
their own.
- `run_session_events` (migration `2026091802`): one row per session
event, numbered per session from 1, with the owning run, the turn, the
event name and its properties. `RunSessionEventStore` appends under the
write lock, lists a session from a sequence, names a session's owner
from its creation event, deletes a run's sessions with the run, and
publishes each committed event to its subscribers.
- `fabro_types::SessionEvent`: `seq`, `session_id`, `run_id`, `ts` and a
flattened body (`event` naming the kind, `properties` its fields), with
the same event names and property shapes the legacy events carried,
so the web app and the CLI read the same JSON. The property structs
move to `session_event`; `run_event::session` re-exports them under
their old names until the legacy event log goes.
- The API: `GET /sessions/{id}/events` pages `PaginatedSessionEventList`
by the session's own sequence, `GET /sessions/{id}/attach` replays and
streams `SessionEvent` frames (subscribed before the replay, so no
event falls between the two), the turn stream carries the same frames,
and an interrupt answers with the recorded event. The session
projection folds `SessionEvent`s; the legacy `find_session_owner` over
`run_events` is gone.
- The CLI's `run ask` and the web app's session stream read
`SessionEvent`; the web runtime no longer accepts the nested legacy
envelope shape.
The two session resume tests in the server keep failing for a reason
this commit does not touch: Ask Fabro reconnects to the run's sandbox
from the projection's sandbox instance, which the Petri projection does
not carry yet (`VIEWS.md`, the `scope.acquired` gap).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Petri judges a workflow at admission, so Fabro's lint rules go:
`fabro-validate` (its 35 rules and the `LintRule` trait) is deleted, and
with it `fabro-acp` (only a rule and two legacy executor tests used it),
the model-resolution transform, the legacy `create`, `compile_create_run`
and `materialize_create_run` stages, and `fabro-graphviz`'s `condition`
and `fidelity` modules. `Diagnostic`, `RelatedDiagnostic` and `Severity`
move to `fabro_types::diagnostic`, the one shape every diagnostic takes.
Validation is now the same question the create handler asks. A new
server module, `petri_check`, builds Petri's check request from a
workflow bundle and the run's settings (every workflow of the bundle at
its bundle-relative path, the inputs, the run variables, the launch),
runs the check, and maps the diagnostics; Fabro adds one rule of its
own, `fabro.model.no_ready_provider`, refusing a model node when no
provider is ready. Admission, the validate and preflight endpoints and
the offline `fabro validate` all go through it:
- `validate_prepared_manifest` runs Fabro's structural pass (parse and
transform, whose diagnostics stay) and then Petri's check, on the
blocking pool from the handlers;
- the offline `validate_manifest` checks with no model client and with
an unbound input as a warning (`CheckRequest.unbound_is_warning`), so
a workflow validates before its inputs exist; a collected workflow
before upload checks with unbound inputs as errors, as before;
- preflight resolves each LLM node's selector against the ready
providers and the catalog for its probe, as the deleted transform did,
and no longer probes a model Petri refused;
- the graph render endpoint needs only the structural pass;
- a run manifest now carries its `[run.goal] file`, which Petri reads
from the bundle as it does for a version.
The transforms keep the authored model selector (`sonnet` stays
`sonnet`): Petri pins the catalog model in the admitted graph, not in
the graph Fabro displays or in the settings snapshot. Tests assert that,
and the CLI's validate, preflight and graph snapshots carry Petri's
diagnostics (`attractor.no_start`, `attractor.undeclared_node`,
`attractor.bad_on_failure`, ...) in place of the lint rules' text.
Known gaps, Petri's side: a `workflow.toml` whose `[run.environment]`
names an environment the server catalog defines but the file does not
is refused (`unsupported.workflow_toml.run.environment`), as admission
already refused it; an unbound input inside an included template
partial is a render error rather than the unbound-input warning.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>