Files
pulse/pkg
rcourtman 871d94f53d Serialize Patrol action projection and finalize hung runs (#3151)
Three gaps remained in how Pulse projects a Patrol investigation's
action and closes its runs, after rcourtman/Pulse#3147.

- **Projections are serialized per investigation.** The lifecycle's
transition callback, an investigation read and a run's close each read
the action audit and apply it under the investigation's lock. A run's
close also finalizes before releasing it. A projection read from an
older state can no longer land after a newer one, and an approval can no
longer land between a run's reconciliation and its finalization.
- **The projection writes only what changed.** It records the changed
action and outcome through `SetAction` and `SetOutcome` when the store
offers `aicontracts.InvestigationActionStore` (a new optional interface;
a store without it keeps the old `Update`). It therefore cannot undo run
progress the orchestrator saved in between. The reference it writes
carries the broker's canonical result and verification status
(`dispositionFromRecord`) and keeps the model's causal attribution,
which the audit does not hold.
- **Runs that hang past their limit are finalized.** The stuck-run sweep
finalizes such a run with a copy of its session marked failed, leaving
the orchestrator's own session as it is. Once the orchestrator closes
the run itself, its stored session is finalized instead of the copy, and
a later refresh no longer reopens a record that finalization closed.
- **A recovered run keeps its own status** (second commit). The sweep
used to mark a run that a restart or its limit cut short after it
planned a fix as `completed`, because the finding already held the
action's outcome, while its session and record said `failed`. The
orchestrator records the same run as `failed` when it closes itself, and
every gate against a second plan keys on the `fix_queued` outcome, so
the sweep now keeps the outcome and the run's own status.

The matching pulse-enterprise change makes the orchestrator's saves keep
the action Pulse recorded, and gives its store `SetAction`. Each side
works without the other.

Testing
- New tests cover the per-investigation lock, field-only writes,
hydration returning the stored action after a refresh replaced the plan,
a close preferring the orchestrator's own close over the sweep's copy
and finalizing under the lock, a run past its limit finalized as given
up, a refresh not reopening a finalized record, the full action
reference, and a recovered run keeping the status it closed with.
Removing any one of the fixes fails its test.
- On pulse-dev: gofmt, vet, race runs of the targeted tests, and full
runs of `internal/ai/...`, `internal/api`, `internal/actionlifecycle`,
`internal/unifiedresources/...` and `pkg/aicontracts/...`. The registry,
contract, completion and browser guards pass on both commits.
- Live, on a private Pro stack built from this branch and
pulse-enterprise main, with a real model (Claude Sonnet through the
subscription CLI) investigating a Docker container whose web server had
died. In each run the model planned a container restart through
`patrol_propose_action`, and the next model turn was made to fail:
- **Provider error after the plan.** The run closed failed with the
error, `fix_queued` and the pending restart on the session, the finding
and its record. Re-investigation returned 409 `action_pending`, the
restart waited in Actions, and the finding's review showed the queued
fix with its plan. When the plan expired, the sweep published it, and
the session, finding and record moved to `needs_attention` with the
action expired.
- **Deadline after the plan.** With a 300-second limit and the next turn
hung, the run failed at the deadline with `fix_queued`, not `timed_out`,
and the next Patrol pass did not retry it.
- **Restart after the plan.** Pulse was killed mid-run. On boot the
stored session closed as interrupted with the plan kept,
re-investigation still returned 409, and once the grace passed a Patrol
pass recovered and finalized the run. With the second commit the finding
reads `failed`, matching its session and record; before it, it read
`completed`.
- Not observable on a local stack: the approval push, which needs a
relay client, and a run hanging past its limit without closing, since
the provider call is cancelled at the deadline. Unit tests cover both.
2026-10-24 01:39:10 +01:00
..