refactor(goal): persist state with domain events

This commit is contained in:
_Kerman
2026-07-31 22:52:18 +08:00
parent b6cf9298e3
commit 1a09174987
48 changed files with 354 additions and 650 deletions
@@ -18,11 +18,11 @@ The durable phases are `active`, `paused`, `blocked`, and `complete`. A blocked
### Durable record and replay
Every mutation uses `Agent.inject()` to insert one round-zero goal-sourced message containing a versioned full snapshot or, for clear, a revisioned tombstone. The mutation commits in the durable `agent/inbox/spliced` insertion even if its context remains queued and never reaches the model; later removal or discard does not roll it back. If the same message is admitted as `user/message`, replay verifies its id, source, metadata, and rendered `<goal_state>...</goal_state>` content against the insertion without applying the mutation again. This descriptive delimiter follows the repository's existing `<workspace_context>` convention and [Anthropic's published guidance to structure mixed prompt content with consistent descriptive XML tags](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/claude-prompting-best-practices#structure-prompts-with-xml-tags). That is public model-experience prior art, not evidence about any provider's proprietary training corpus. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field.
Every mutation appends a versioned `goal/change` session event containing a full snapshot or, for clear, a revisioned tombstone. The session log is the only durable source of truth, so persistence and fork inherit goal records without another database or header field. The [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md) owns the separation from inbox state and model context.
The replay fold derives mutations only from inbox insertions and validates JSON shape, source attribution, rendered content, message-id consistency on later admission, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds advance only from positive sequential admitted `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired.
The replay fold derives lifecycle mutations only from `goal/change` and validates JSON shape, fresh ids, revision continuity, lifecycle transitions, counters, and monotonic per-goal timestamps. Goal rounds advance only from positive sequential admitted `user/message` source numbers for the current active revision and cannot exceed `maxGoalRounds`; ordinary session turns do not affect the counter. A malformed current-format record fails replay rather than being ignored or repaired.
The service reconciles an injected mutation with its synchronous inbox insertion by message id, so reentrant insertion observers apply each accepted change exactly once and a later admission only checks consistency. Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart.
Incremental replay advances its cursor after each valid event and remains positioned at the first corrupt event, so later reads report the same durable fault. The durable log remains authoritative after restart.
### Lifecycle and live activation
@@ -32,16 +32,16 @@ A cache built from any seed starts disarmed, and every `agent/session-start` edg
### Service boundary
The service accepts only the exact live `Agent` object registered under its id. Successful mutation injection emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`.
The service accepts only the exact live `Agent` object registered under its id. A committed mutation emits the scoped `goal/changed` event with contained listener failures. Policy consumers use this service plus the public `Agent` interface and `agent/*` events; the goal domain does not import or modify `dsh-agent-loop`.
## Testing
Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, durable insertion folding, reentrant insertion observation, admission consistency, discarded-context persistence, rejected-injection rollback, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the goal record and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate.
Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set rejection, every lifecycle transition, blocker reason validation and retention, cap enforcement on resume, clear/replacement, seeded replay and `SessionStore.fork()` inheritance, session-start and lifecycle-owner disarming, active-goal rearming, durable event folding, inbox independence, stable corrupt-event replay, service/listener disposal, listener containment, backward-clock clamping, strict record decoding, lifecycle continuity, and sequential round attribution. A keyless Loader/stdio process test mounts the service and a lifecycle consumer through test-only `cordis.yml`, then reads the persisted JSONL externally to verify the goal record and absence of an unrequested goal round. The package source is held to the repository's per-file 100% coverage gate.
## Alternatives considered
- **Store goals in a separate database or session header** — rejected because the session log already supplies ordering, persistence, fork prefixes, and reconstructability; a second store introduces atomicity and lineage questions.
- **Use goal-only log events without queued context** — rejected because durable state that changes future model behavior must have a reconstructable path into model input. The inbox insertion supplies durable commit while retaining that model-facing path.
- **Couple each durable mutation to queued model context** — rejected by the later [goal-owned durable event decision](../architecture/2026-07-31-goal-owned-durable-events.md): goal tools and scheduled continuation prompts expose state when needed, while domain persistence remains independent from queue outcomes.
- **Persist activation and restart automatically** — rejected because opening or resuming a session must wait for human input; durable phase records status, not fresh authority to spend resources.
- **Count all session turns as goal rounds** — rejected because one session can contain human clarification, inspection, and unrelated work; only goal-attributed continuation turns consume this budget.
- **Add goal state or a generic loop abstraction to `dsh-agent-loop`** — rejected because state and continuation policy can compose through existing plugins, `Agent` verbs, and events without privileging the shipped loop implementation.
@@ -50,7 +50,7 @@ Unit coverage pins creation defaults, exact-live-agent checks, compare-and-set r
- Goal history survives persistence, resume, compaction of unrelated nodes, and session fork as ordinary session data.
- Resume and fork expose the same durable phase while remaining operationally inert until an explicit resume mutation arms activation.
- Full snapshots simplify inspection and strict replay; when admitted, they repeat the objective and state fields in model history until compaction shadows them.
- Full snapshots simplify inspection, strict replay, and last-wins projection without adding mutation-only messages to model history.
- Revision and lifecycle validation reject tampered, partially written, or producer-inconsistent goal records early.
- Round caps bound continuation count only; policy consumers map round, token, currency, time, and provider limits to blocked reasons when they stop work.