Place the run branch and git identity records with their checkpoint

`run.branch` and `git.identity` are written by the checkpoint that creates
the run branch, before that firing's finish is appended, and carried no
position, so the stream ordered them by the millisecond clock: on either
side of the finish from one run to the next. Both now take that
checkpoint's stage position, and the existing ordering rule places them
after the firing's finish and before its routes, beside its checkpoint
record. The two CLI snapshots that had each recorded one of the two
orders now record the one order every run produces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-18 20:38:22 -04:00
parent 29441e4ddc
commit 34819050e3
No known key found for this signature in database
5 changed files with 70 additions and 47 deletions

View file

@ -1531,40 +1531,6 @@ fn attach_json_errors_without_prompting_for_human_input() {
{
"run_id": "[ULID]",
"stream_seq": 21,
"kind": "platform",
"id": "[EVENT_ID]",
"recorded_at": "[EPOCH_MS]",
"item": {
"seq": 7,
"recorded_at": "[EPOCH_MS]",
"record": {
"kind": "run.branch",
"run_branch": "fabro/run/[ULID]",
"base_sha": "[DIGEST]",
"workspace": "invocation-0-scope-0"
}
}
},
{
"run_id": "[ULID]",
"stream_seq": 22,
"kind": "platform",
"id": "[EVENT_ID]",
"recorded_at": "[EPOCH_MS]",
"item": {
"seq": 8,
"recorded_at": "[EPOCH_MS]",
"record": {
"kind": "git.identity",
"name": "Fabro",
"email": "noreply@fabro.sh",
"source": "default"
}
}
},
{
"run_id": "[ULID]",
"stream_seq": 23,
"kind": "petri",
"id": "execution 0/7/0",
"recorded_at": "[EPOCH_MS]",
@ -1656,7 +1622,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"run_id": "[ULID]",
"stream_seq": 24,
"stream_seq": 22,
"kind": "petri",
"id": "execution 0/8/0",
"recorded_at": "[EPOCH_MS]",
@ -1736,7 +1702,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
{
"run_id": "[ULID]",
"stream_seq": 25,
"stream_seq": 23,
"kind": "petri",
"id": "execution 0/8/1",
"recorded_at": "[EPOCH_MS]",
@ -1805,6 +1771,48 @@ fn attach_json_errors_without_prompting_for_human_input() {
}
}
},
{
"run_id": "[ULID]",
"stream_seq": 24,
"kind": "platform",
"id": "[EVENT_ID]",
"recorded_at": "[EPOCH_MS]",
"item": {
"seq": 7,
"recorded_at": "[EPOCH_MS]",
"record": {
"kind": "run.branch",
"run_branch": "fabro/run/[ULID]",
"base_sha": "[DIGEST]",
"workspace": "invocation-0-scope-0"
},
"position": {
"execution": 0,
"firing": 1
}
}
},
{
"run_id": "[ULID]",
"stream_seq": 25,
"kind": "platform",
"id": "[EVENT_ID]",
"recorded_at": "[EPOCH_MS]",
"item": {
"seq": 8,
"recorded_at": "[EPOCH_MS]",
"record": {
"kind": "git.identity",
"name": "Fabro",
"email": "noreply@fabro.sh",
"source": "default"
},
"position": {
"execution": 0,
"firing": 1
}
}
},
{
"run_id": "[ULID]",
"stream_seq": 26,

View file

@ -1185,9 +1185,9 @@ async fn a_finished_petri_run_reads_back_through_the_cli() {
[CLOCK] Engine: petri run started
[CLOCK] ▶ start
[CLOCK] │ checkout: [TEMP_DIR]/petri-workspace is not a Git repository; the workspace starts empty
[CLOCK] ✓ start [DURATION]
[CLOCK] Branch: fabro/run/[ULID] from [SHA]
[CLOCK] Git identity: Fabro <noreply@fabro.sh> default
[CLOCK] ✓ start [DURATION]
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ▶ say
[CLOCK] start → say continue

View file

@ -101,7 +101,8 @@ Every adapter the integration plan describes lands here.
`hooks` writes the platform records Petri cannot: `run.branch` and
`git.identity` when the first checkpoint creates the run branch (the base
commit is the workspace's `HEAD` before the branch, or that first commit in
a workspace with no history), `checkpoint` after every route with the
a workspace with no history; both carry that checkpoint's stage position, so
the stream places them with its finish), `checkpoint` after every route with the
stage's diff from its parent commit (`diff_summary`, and the patch as a
text blob under `patch_blob`), `artifact.collected` for every file under
`[run.artifacts] include` a stage left in its workspace (the bytes go to

View file

@ -75,8 +75,8 @@ toasts. `RunProjection` (`GET /runs/{id}/state`) serves `attach`, `inspect`,
| stages summary | `Conclusion.stages` | derived from the Stages section | stage |
| diff | `Run.diff`, `Conclusion.diff`, `Checkpoint`'s diff | platform record `checkpoint {diff_summary, patch_blob}`; the final one is the run's | stage |
| final commit | `Conclusion.final_git_commit_sha` | the last platform record `checkpoint {git_commit_sha}` | stage |
| run branch, base sha | `StartRecord.run_branch`, `base_sha` | platform record `run.branch {run_branch, base_sha}` | run |
| Git identity | `RunProjection.git_identity` | platform record `git.identity {name, email, source}` | run |
| run branch, base sha | `StartRecord.run_branch`, `base_sha` | platform record `run.branch {run_branch, base_sha}`, positioned on the checkpoint that created the branch | run |
| Git identity | `RunProjection.git_identity` | platform record `git.identity {name, email, source}`, positioned with `run.branch` | run |
| pull request | `Run.pull_request`, `RunProjection.pull_request`, `pull_request_creation` | see Platform | run |
| current question | `Run.current_question` | see Questions | question |
| sandbox | `Run.sandbox`, `RunProjection.sandbox` | see Sandbox | invocation |
@ -428,7 +428,7 @@ record where Fabro does.
| tools available to an agent | `agent_tools`, the insights sidebar's tool list | a `custom attractor.tools {node, firing, attempt, session, tools[] {name, description, source, category}}` from the native backend once per session, where it calls the `HostTools` builders; Pebble's `SessionStarted` carries only the provider and model |
| question option `description` and `preview`, `context_display` | the interview dock, the human Q&A renderer | optional fields on Petri's `QuestionOption` (`description`, `preview`) and `Question` (`context`), set by the human gate from the edge attributes Fabro's lowering already reads |
| who answered | `interview.completed` `actor`, Slack attribution | platform record `interview.answered {question, principal, channel}` written by Fabro's interviewer beside its `InterviewReply` |
| run branch and base sha | `StartRecord`, `run diff`, the commits picker | platform record `run.branch {run_branch, base_sha}` written when Fabro creates the run branch |
| run branch and base sha | `StartRecord`, `run diff`, the commits picker | platform record `run.branch {run_branch, base_sha}` written when Fabro creates the run branch, at that checkpoint's stage position |
| Git identity | `git_identity` | platform record `git.identity {name, email, source}` |
| diff summary and patch per checkpoint | `Run.diff`, `Conclusion.diff`, `StageProjection.diff`, the changes sort | `diff_summary` and `patch_blob` on the `checkpoint` platform record |
| lifecycle before the engine, archive, title, parent, supersede, notices | the run list, header, `runs ps`, `run events --pretty` | platform records `run.created`, `run.lifecycle`, `run.archived`, `run.unarchived`, `run.title`, `run.parent`, `run.superseded`, `run.notice` |

View file

@ -19,7 +19,8 @@
//! routes, so no route is taken. The commit that creates the run branch also
//! records where it started: the `run.branch` platform record (the branch
//! name and the base commit) and the `git.identity` record (who authors the
//! commits, and where that identity came from).
//! commits, and where that identity came from), both at that checkpoint's
//! stage position, so the stream orders them with the firing's finish.
//! - `transition`: the platform checkpoint record, keyed on the Petri position
//! and the checkpoint's operation identity, with the stage's diff from its
//! parent commit (`diff_summary`, and the patch as a blob); then the stage's
@ -539,7 +540,7 @@ impl FabroHooks {
.base_sha
.clone()
.unwrap_or_else(|| snapshot.sha.clone());
if let Err(error) = self.record_branch(workspace, base_sha).await {
if let Err(error) = self.record_branch(key, workspace, base_sha).await {
warn!(run_id = %self.run_id, error = %error, "the run branch was not recorded");
}
}
@ -547,8 +548,21 @@ impl FabroHooks {
/// The `run.branch` and `git.identity` records, once per run: the first
/// workspace to create the run branch names where it started. A run
/// that already recorded its branch (a resume, or a nested workspace
/// after the root's) records nothing.
async fn record_branch(&self, workspace: &str, base_sha: String) -> Result<(), String> {
/// after the root's) records nothing. Both records take the position of
/// the checkpoint that created the branch, so the stream places them
/// with that firing (after its finish, before its routes) rather than by
/// the clock, which would put them on either side of the finish from
/// one run to the next.
async fn record_branch(
&self,
key: CheckpointKey,
workspace: &str,
base_sha: String,
) -> Result<(), String> {
let position = StagePosition {
execution: key.execution,
firing: key.firing,
};
let branch = self
.branch
.get_or_try_init(|| async {
@ -564,7 +578,7 @@ impl FabroHooks {
.append(
&self.run_id,
&PlatformRecord::RunBranch(record.clone()),
None,
Some(position),
)
.await
.map_err(|error| {
@ -577,7 +591,7 @@ impl FabroHooks {
identity: self.identity.clone(),
});
self.records
.append(&self.run_id, &identity, None)
.append(&self.run_id, &identity, Some(position))
.await
.map_err(|error| {
format!(