diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index 2ad6b0b22..b971bb139 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -16,9 +16,9 @@ Two errors are shared across domains, each with one `api` helper: `textvalue.Err Shared vocabulary has one owner each, and domains use it rather than copy it. `internal/environmentconfig` owns Environment setup, Skills, Plugins and initial files with their validation and public metadata; `Setup.Validate` checks requested configuration, where a Skill may be an unresolved reference, and `Setup.ValidateInstalled` checks frozen, installable configuration. `internal/skills` owns `ParseVersion`, the canonical positive decimal Skill version. `internal/metadata` owns the metadata rules: `Validate` for the pair, key and value limits and U+0000, `ValidateStorable` for U+0000 alone, and `Encode` with its 64 KiB bound. `internal/jsonobject` owns `Normalize`, the stable encoding of stored JSON objects that snapshots and retry identities compare. These packages import no persistence. -`internal/sessions` owns the Session vocabulary: Sessions, Turns, inputs, Environments and their provisioning failures, function calls, Item and Artifact reads, executor credentials, and the errors Session operations return, which `api` maps in `writeSessionsError`. It also owns the Session change vocabulary and its decisions: the public changes that report Turn and Session transitions, what a Turn that ends settles, measured Turn usage and the Session activity a change reports. Sequences that several Session operations share are `sessions` procedures, each over the transaction interface it declares: `CancelTurn` and `CancelWork` cancel work, `TrackInputActivity` reports the input activity a write changes, `FailEnvironment` and `TerminateEnvironment` settle an Environment that failed or whose managed compute ended, `CreateEnvironmentDevice` creates and binds a hosted Environment's dedicated Runtime device, `CheckComputeAdmission`, `CheckFileWriteGate` and `CheckInputStart` gate new work, `AdmitFunctionResult` saves a submitted function result for its Turn's call, and `LoadRequiredActions` lists the calls a Turn waits on. `sessions.ExecutionOperations` runs the Session writes only the execution owner makes; each function-call operation loads the Turn and its call in one leased Session transaction, decides in `sessions`, then applies in `sessionpg`. `internal/items` owns public Items: it projects observations, merges them into stored Items and builds the ordered events that report each Item change. Neither imports persistence. `internal/persistence/postgres/sessionpg` loads the facts those decisions read and applies them inside the caller's Session transaction, under the Session lock: it allocates event sequence positions, event IDs, Item positions and output indexes, writes the journal, Items, Turn usage and Artifact settlement, and prunes the journal. It decides nothing. `WithSession` runs a Session operation's transaction on the pool or the lease: it locks the Session, applies the operation, then prunes the journal. `BindSession` binds one Session to a transaction's queries as a `SessionTx`, which implements the procedures' interfaces. +`internal/sessions` owns the Session vocabulary: Sessions, Turns, inputs, Environments and their provisioning failures, function calls, Item and Artifact reads, executor credentials, and the errors Session operations return, which `api` maps in `writeSessionsError`. It also owns the Session change vocabulary and its decisions: the public changes that report Turn and Session transitions, what a Turn that ends settles, measured Turn usage and the Session activity a change reports. Sequences that several Session operations share are `sessions` procedures, each over the transaction interface it declares: `CancelTurn` and `CancelWork` cancel work, `TrackInputActivity` reports the input activity a write changes, `FailEnvironment` and `TerminateEnvironment` settle an Environment that failed or whose managed compute ended, `CreateEnvironmentDevice` creates and binds a hosted Environment's dedicated Runtime device, `CheckComputeAdmission`, `CheckFileWriteGate` and `CheckInputStart` gate new work, `AdmitFunctionResult` saves a submitted function result for its Turn's call, and `LoadRequiredActions` lists the calls a Turn waits on. `AppendTurnEvents` records a batch of a Turn's execution observations, which `NewJournalBatch` validates, and `AppendTurnEvent` records its terminal outcome; both project the new entries. `ProjectSource` projects one journal entry or admitted input into Items, the root Turn's usage and Subagents, and `ProjectInput` projects an admitted input. `ValidTransition` decides the Turn status transitions, and the Subagent projection decides each Subagent's identity, lifecycle, child Turn and child Item changes. `sessions.ExecutionOperations` runs the Session writes only the execution owner makes; each function-call operation loads the Turn and its call in one leased Session transaction, decides in `sessions`, then applies in `sessionpg`, and `AppendTurnEvents` records and projects a Turn's journal batch in one leased Session transaction. `internal/items` owns public Items: it projects observations, merges them into stored Items and builds the ordered events that report each Item change. Neither imports persistence. `internal/persistence/postgres/sessionpg` loads the facts those decisions read and applies them inside the caller's Session transaction, under the Session lock: it allocates event sequence positions, event IDs, Item positions, output indexes and Subagent IDs, writes the change journal, each Turn's execution journal and its counters, Items, Turn usage, Subagent bindings, child Turns and child Items, and Artifact settlement, and prunes the change journal. It decides nothing. `WithSession` runs a Session operation's transaction on the pool or the lease: it locks the Session, applies the operation, then prunes the change journal. `BindSession` binds one Session to a transaction's queries as a `SessionTx`, which implements the procedures' interfaces. -Other adapters call only two `sessionpg` participants, on their own transaction's queries: `LockSession`, which orders a write against the Session's Turns, and `BindSession`, which runs a Session procedure, such as `CreateEnvironmentDevice`, in that transaction. The other `sessionpg` exports serve `store` until the Session operations leave it. `deploymentpg` may import `sessionpg`, and `sessionpg` never imports `deploymentpg`; deployment's placement fact-loading and reservation code sits below both, so either may call it and it imports neither. Likewise `deployment` may compose the `sessions` procedures, and `sessions` never imports `deployment`. +Other adapters call only two `sessionpg` participants, on their own transaction's queries: `LockSession`, which orders a write against the Session's Turns, and `BindSession`, which runs a Session procedure, such as `CreateEnvironmentDevice`, in that transaction. The other `sessionpg` exports serve `store` until the Session operations leave it. `tests/fixtures` is test tooling that composes procedures over a pooled `WithSession`, which is why `WithSession` and `BindSession` stay exported after store goes; no production code may do this. `deploymentpg` may import `sessionpg`, and `sessionpg` never imports `deploymentpg`; deployment's placement fact-loading and reservation code sits below both, so either may call it and it imports neither. Likewise `deployment` may compose the `sessions` procedures, and `sessions` never imports `deployment`. `store` is transitional. `store.New` builds a pooled Store, and `store.NewExecution(s, lease)` builds the execution writer on a lease it borrows. An execution-only operation on a pooled Store fails with `store.ErrExecutionAuthority`. New adapters do not copy that check: their execution repositories require a `*pgunit.Lease` at construction, their public repositories expose no execution operation, and the check goes away with `store`. @@ -34,7 +34,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post - `modelconfiguration` (`modelconfigurationpg`): each Harness's deployment default model configuration and its last-use observations. - `skills` (`skillpg`): Skills and their immutable versions: archive checks, the default and latest pointers, version selection and deletion, and each version's sealed archive. Session creation freezes selected versions inside its `store` transaction with the `skills` rules. - `deployment` (`deploymentpg`): the sandbox deployment and its nodes: provider configuration and the sealed credential, the specification and retained generations, setup, update and switch, node enrollment, identity and authentication, generation configuration, capacity, presence, status, host history, reset, and the counts of nodes and sandboxes bound to the public address. It interprets Sandbox Provider declarations through the `providers.Registry` it is given, whose lookups return typed errors. `cmd/server` builds that registry and calls it only to build a direct Provider and to discover a Provider's configuration; it takes each setup's mode and declared operations from the `Setup` that `deployment` returns. The only other reader is `store`, whose own `providers.Builtin()` validates the stored specification at Session admission and reads the public-origin requirement at placement. Deployment changes run through `deployment.ExecutionOperations` on `deploymentpg.NewExecution(lease, …)`, which the Worker receives as `execution.Owner.Deployment`; the Worker reads the deployment and prepares a selection's setup through the pooled `deployment.Service` in `execution.Dispatcher.Deployment` and lists the Sessions a reset still has to archive through the pooled `deployment.Reader` in `execution.Dispatcher.DeploymentReader`, and node management and reads use the pooled `deploymentpg.Store`, which `cmd/server` also reads the owner epoch from and runs the host-history sampler on. Provider calls run outside transactions, and the final transaction rechecks the expected generation. Allocations, placement, reset admission and the reset's Session archive stay in `store`. -- `sessions` (`sessionpg`): Session use cases and reads as they leave `store`. The pooled `sessions.Service` runs the use cases on `sessionpg.Store`, which implements `sessions.Storage`, and plain reads use `sessions.Reader`, which `sessionpg.Store` also implements, directly. So far these cover root Items and Subagents (their reads) and Session Artifacts (their reads, deletion and the staging of a Turn's export). `cmd/server` builds one `sessionpg.Store` and wires the Service and the Store into `execution.Dispatcher.Sessions` and `SessionsReader` and into the api fields; the Worker stages Artifacts through the Service. Function calls and their application receipts run through `sessions.ExecutionOperations` on `sessionpg.NewExecution(lease)`, which the Worker receives as `execution.Owner.Sessions`. +- `sessions` (`sessionpg`): Session use cases and reads as they leave `store`. The pooled `sessions.Service` runs the use cases on `sessionpg.Store`, which implements `sessions.Storage`, and plain reads use `sessions.Reader`, which `sessionpg.Store` also implements, directly. So far these cover root Items and Subagents (their reads) and Session Artifacts (their reads, deletion and the staging of a Turn's export). `cmd/server` builds one `sessionpg.Store` and wires the Service and the Store into `execution.Dispatcher.Sessions` and `SessionsReader` and into the api fields; the Worker stages Artifacts through the Service. Function calls and their application receipts and the Turn execution journal run through `sessions.ExecutionOperations` on `sessionpg.NewExecution(lease)`, which the Worker receives as `execution.Owner.Sessions`. ## Request handling @@ -155,15 +155,15 @@ Accepted public MCP credential profiles are declared centrally by the service, s Core replaces a Turn's usage with each complete valid token breakdown, which `sessions.MeasuredUsage` decides, and keeps the last committed measurement when execution is interrupted. It never infers tokens from context occupancy or costs and never parses native raw payloads. When `done` also carries usage that was already reported, the counters are not added again. -Subagent observations use the common types in `internal/agentdaemon/proto/subagents.go`. Core assigns public IDs and projects them under the Session lock and the leased execution journal; native names, history parsing and outcome proof stay in the adapters. Child Turns have their own table, separate from Core's queue, and Session Turn reads and the Session event stream carry root work only. The migration-defined `public_execution_turns` view has no public reader; do not reintroduce mixed Session Turn pages. Repeated effects are idempotent, root output freezes first, and child Items are delivered before their terminal Turn snapshot. +Subagent observations use the common types in `internal/agentdaemon/proto/subagents.go`. `sessions` projects them and `sessionpg` assigns their public IDs, under the Session lock of the leased execution journal; native names, history parsing and outcome proof stay in the adapters. Child Turns have their own table, separate from Core's queue, and Session Turn reads and the Session event stream carry root work only. The migration-defined `public_execution_turns` view has no public reader; do not reintroduce mixed Session Turn pages. Repeated effects are idempotent, root output freezes first, and child Items are delivered before their terminal Turn snapshot. Function calls are stored per tenant, Session and Turn with immutable public and executor call identities and arguments. Result admission and application receipts serialize on the Session lock with cancellation and terminal transitions. `sessions.AdmitFunctionResult` keeps the complete caller-validated result, including omitted versus null error and output and ordered text and image parts; identical retries return the saved decision and changed results conflict. Recording a call through `sessions.ExecutionOperations` moves the Turn to `waiting`, and the last application receipt resumes it. A function result may join a message or cancel batch: its explicit Turn and call identity select an existing call, admission never creates a Turn for it, and the result and its input retry record commit in one transaction. Targets resolve only after the tenant Session lookup; an unknown call or a call of another Turn is 400 `invalid_request_error`. The input cursor skips function results, whose own receipts decide application. A Session with functions waits for a device that declares `function_tools`, and Core delivers each saved result once per live dispatch, appending a non-null error as a final text part because the native result has no error field. -Neutral tool and message observations go into the journal before Core projects public Items; the Item projector validates the shared observation contract and never decodes engine-native snapshots. `command_output` fragments update Items only for a command already indexed in the same Turn, and commit with their `agent.output.command_execution_output.delta` events. Completion output replaces accumulated drafts, terminal Items ignore late fragments, and cancellation keeps partial output without inventing a successful completion. +Neutral tool and message observations go into the journal before `sessions.ProjectSource` projects public Items; the Item projector in `items` validates the shared observation contract and never decodes engine-native snapshots. `command_output` fragments update Items only for a command already indexed in the same Turn, and commit with their `agent.output.command_execution_output.delta` events. Completion output replaces accumulated drafts, terminal Items ignore late fragments, and cancellation keeps partial output without inventing a successful completion. ## Journal and live events -Execution observations are written to tenant-scoped `turn_events` in ordered, idempotent batches before they back recovery or publication, with daemon payloads intact. Flush at least every 100 ms while consuming events and before terminal persistence; the terminal outcome, its journal entry and native continuity commit together. Journal limits are 512 KiB per payload, 1 MiB per batch, 65,536 observations and 32 MiB per Turn, with one extra entry reserved for the terminal outcome. Never infer a successful completion after a persistence error or stream overflow. +Execution observations are written to tenant-scoped `turn_events` in ordered, idempotent batches through `sessions.AppendTurnEvents` before they back recovery or publication, with daemon payloads intact. Flush at least every 100 ms while consuming events and before terminal persistence; the terminal outcome, its journal entry and native continuity commit together. `sessions` enforces the journal limits: 64 observations, 512 KiB per payload and 1 MiB per batch, and 65,536 observations and 32 MiB per Turn, with one extra entry reserved for the terminal outcome. Never infer a successful completion after a persistence error or stream overflow. Live Session SSE reads `session_events` committed with the corresponding input, Item or lifecycle change under the Session lock, as immutable transition snapshots. The notification buffer keeps at most 256 events and 64 MiB per Session after each transaction (the `sessions` retention bounds, which `sessionpg` applies), and read batches at most 32 events or 1 MiB, each keeping a single oversized event. GET polls committed events every 100 ms from the committed high-water mark; a missing sequence position ends the stream with a safe error. Socket writes have a five-second deadline and hold no database connection. Rebuilding historical indexes emits no live events. diff --git a/services/core/internal/execution/delivery.go b/services/core/internal/execution/delivery.go index e0b901b15..a9c2ea6d6 100644 --- a/services/core/internal/execution/delivery.go +++ b/services/core/internal/execution/delivery.go @@ -67,7 +67,7 @@ func (d *Dispatcher) deliver(ctx context.Context, tenantID, sessionID string, pe abort(peer, request.RunID) } }() - journal := &journal{store: d.Store, tenant: tenantID, session: sessionID, turn: request.RunID, next: 1, + journal := &journal{writer: d.sessionExecution, tenant: tenantID, session: sessionID, turn: request.RunID, next: 1, observeSubagents: request.ObserveSubagentIdentities} defer func() { finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/services/core/internal/execution/engine_failure_test.go b/services/core/internal/execution/engine_failure_test.go index fab868cc1..5328e7c79 100644 --- a/services/core/internal/execution/engine_failure_test.go +++ b/services/core/internal/execution/engine_failure_test.go @@ -13,7 +13,7 @@ func TestEngineClassificationSurvivesDrainWithoutChangingSettlement(t *testing.T for _, prior := range []string{"", "event_persistence_failed", "cancel_unconfirmed", "event_stream_incomplete"} { result := Result{ErrorCode: prior} writer := &recoveringWriter{} - j := journal{store: writer, next: 1} + j := journal{writer: writer, next: 1} events := make(chan proto.Envelope, 3) failure, _ := proto.NewEnvelope(proto.TypeError, "run", proto.ErrorPayload{Error: "raw native text", Code: "authentication_error"}) usage, _ := proto.NewEnvelope(proto.TypeUsage, "run", proto.Usage{InputTokens: 7}) @@ -50,7 +50,7 @@ func TestEngineClassificationSurvivesDrainWithoutChangingSettlement(t *testing.T func TestClassifiedErrorCannotOverrideCancellationReceipt(t *testing.T) { for _, drainFirst := range []bool{true, false} { result := Result{ErrorCode: "engine_failed"} - j := journal{store: &recoveringWriter{}, next: 1} + j := journal{writer: &recoveringWriter{}, next: 1} failure, _ := proto.NewEnvelope(proto.TypeError, "run", proto.ErrorPayload{Code: "rate_limit_exceeded"}) events := make(chan proto.Envelope, 1) events <- failure diff --git a/services/core/internal/execution/journal.go b/services/core/internal/execution/journal.go index aaa79fbe4..cf3a15438 100644 --- a/services/core/internal/execution/journal.go +++ b/services/core/internal/execution/journal.go @@ -10,7 +10,7 @@ import ( ) type journal struct { - store eventWriter + writer eventWriter tenant, session, turn string next int32 batch []sessions.ExecutionEvent @@ -93,7 +93,7 @@ func (j *journal) flush(ctx context.Context) error { } // An uncertain commit must retry the same batch even after more frames arrive. j.pendingCount = count - if err := j.store.AppendTurnEvents(ctx, j.tenant, j.session, j.turn, j.next, j.batch[:count]); err != nil { + if err := j.writer.AppendTurnEvents(ctx, j.tenant, j.session, j.turn, j.next, j.batch[:count]); err != nil { return err } j.pendingCount = 0 diff --git a/services/core/internal/execution/journal_test.go b/services/core/internal/execution/journal_test.go index d39b047c3..1955836aa 100644 --- a/services/core/internal/execution/journal_test.go +++ b/services/core/internal/execution/journal_test.go @@ -41,7 +41,7 @@ func (w *ambiguousWriter) AppendTurnEvents(_ context.Context, _, _, _ string, fi func TestJournalKeepsBatchIdentityAfterAnUncertainCommit(t *testing.T) { writer := &ambiguousWriter{} - j := journal{store: writer, next: 1} + j := journal{writer: writer, next: 1} for i := range 3 { env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "text", Sequence: uint64(i + 1)}) if err := j.observe(context.Background(), env); err != nil { @@ -79,7 +79,7 @@ func TestJournalRetainsTriggeringFrameAcrossFlushFailure(t *testing.T) { for _, size := range []int{10, 300 * 1024} { t.Run(fmt.Sprint(size), func(t *testing.T) { writer := &recoveringWriter{fail: true} - j := journal{store: writer, next: 1} + j := journal{writer: writer, next: 1} var expected []string for i := range 65 { env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: strings.Repeat("x", size), Sequence: uint64(i + 1)}) @@ -108,7 +108,7 @@ func TestJournalRetainsTriggeringFrameAcrossFlushFailure(t *testing.T) { func TestJournalDrainRetainsTerminalContinuityAndUsageOnFailure(t *testing.T) { writer := &recoveringWriter{fail: true} - j := journal{store: writer, next: 1} + j := journal{writer: writer, next: 1} upstream := make(chan proto.Envelope, 64) for i := range 63 { env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "partial", Sequence: uint64(i + 1)}) @@ -137,7 +137,7 @@ func TestJournalDrainRetainsTerminalContinuityAndUsageOnFailure(t *testing.T) { func TestCancellationReceiptContinuitySurvivesFlushFailure(t *testing.T) { writer := &recoveringWriter{fail: true} - j := &journal{store: writer, next: 1} + j := &journal{writer: writer, next: 1} for i := range 63 { env, _ := proto.NewEnvelope(proto.TypeDelta, "run", proto.DeltaPayload{Delta: "partial", Sequence: uint64(i + 1)}) if err := j.enqueue(env); err != nil { diff --git a/services/core/internal/execution/runtime_protocol_test.go b/services/core/internal/execution/runtime_protocol_test.go index c64157687..51e926496 100644 --- a/services/core/internal/execution/runtime_protocol_test.go +++ b/services/core/internal/execution/runtime_protocol_test.go @@ -16,7 +16,7 @@ func TestRuntimeProtocolUnknownEffectRetainsObservationFailure(t *testing.T) { t.Run(reason, func(t *testing.T) { result := Result{ErrorCode: reason, AppliedThrough: 1} writer := &recoveringWriter{} - j := journal{store: writer, next: 1} + j := journal{writer: writer, next: 1} events := make(chan proto.Envelope, 1) usage, err := proto.NewEnvelope(proto.TypeUsage, "run", proto.Usage{InputTokens: 7}) if err != nil { diff --git a/services/core/internal/persistence/postgres/sessionpg/binding.go b/services/core/internal/persistence/postgres/sessionpg/binding.go index 79c14f4c0..085e99613 100644 --- a/services/core/internal/persistence/postgres/sessionpg/binding.go +++ b/services/core/internal/persistence/postgres/sessionpg/binding.go @@ -33,6 +33,9 @@ var ( _ sessions.InputStartTx = (*SessionTx)(nil) _ sessions.ComputeAdmissionTx = (*SessionTx)(nil) _ sessions.EnvironmentDeviceTx = (*SessionTx)(nil) + _ sessions.TurnJournalTx = (*SessionTx)(nil) + _ sessions.TurnEventTx = (*SessionTx)(nil) + _ sessions.InputProjectionTx = (*SessionTx)(nil) ) // BindSession binds the tenant's Session to the caller's transaction-bound diff --git a/services/core/internal/persistence/postgres/sessionpg/doc.go b/services/core/internal/persistence/postgres/sessionpg/doc.go index 3ccc9cc8d..15d416ca6 100644 --- a/services/core/internal/persistence/postgres/sessionpg/doc.go +++ b/services/core/internal/persistence/postgres/sessionpg/doc.go @@ -4,11 +4,13 @@ // queries, the participants load the facts the sessions and items decisions // read and apply what they decide: they lock the Session, allocate event // sequence positions, event IDs, Item positions and output indexes, and write -// the public change journal, Items, Turn usage, Artifact settlement, -// Environment state, input reservations and devices. SessionTx binds a Session -// to its caller's transaction for the sessions procedures. Store is the pooled -// adapter: it stores the sessions.Service use cases and serves the Session -// reads. +// the public change journal, a Turn's execution journal and its counters, +// Items, Turn usage, Subagent bindings, child Turns and child Items, Artifact +// settlement, Environment state, input reservations and devices. SessionTx +// binds a Session to its caller's transaction for the sessions procedures. +// Store is the pooled adapter: it stores the sessions.Service use cases and +// serves the Session reads. Execution is the lease-bound adapter: it runs the +// sessions.ExecutionOperations transactions on the execution lease. package sessionpg import ( diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_journal.go b/services/core/internal/persistence/postgres/sessionpg/execution_journal.go new file mode 100644 index 000000000..ad0f9b1a1 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/execution_journal.go @@ -0,0 +1,24 @@ +package sessionpg + +import ( + "context" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// WithTurnJournal runs apply in a Session transaction on the lease, including +// for a publicly deleted Session. +func (e *Execution) WithTurnJournal(ctx context.Context, tenantID, sessionID string, apply func(context.Context, sessions.TurnJournalTx) error) error { + tenant, err := parseID(tenantID) + if err != nil { + return err + } + session, err := parseID(sessionID) + if err != nil { + return err + } + return WithSession(ctx, e.lease, tenant, session, func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession) error { + return apply(ctx, BindSession(q, tenant, session)) + }) +} diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go b/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go new file mode 100644 index 000000000..a7a1ffe7a --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/execution_journal_test.go @@ -0,0 +1,201 @@ +package sessionpg + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// leasedJournal holds the execution lease on a database of its own and returns +// the Session execution operations bound to it. +func leasedJournal(t *testing.T) (*pgxpool.Pool, *pgunit.Lease, *sessions.ExecutionOperations) { + t.Helper() + pool := pgtest.OpenIsolated(t, nil) + lease, err := pgunit.AcquireLease(t.Context(), pool) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = lease.Close(context.Background()) }) + operations, err := sessions.NewExecutionOperations(NewExecution(lease)) + if err != nil { + t.Fatal(err) + } + return pool, lease, operations +} + +func text(id pgtype.UUID) string { return uuid.UUID(id.Bytes).String() } + +// recorded reads the Turn's journal kinds and counters. +func recorded(t *testing.T, pool *pgxpool.Pool, turn pgtype.UUID) ([]string, int32, int64) { + t.Helper() + var kinds []string + var count int32 + var size int64 + if err := pool.QueryRow(t.Context(), `SELECT COALESCE((SELECT array_agg(kind ORDER BY ordinal) FROM turn_events WHERE turn_id = t.id), '{}'), t.event_count, t.event_bytes FROM turns t WHERE t.id = $1`, turn).Scan(&kinds, &count, &size); err != nil { + t.Fatal(err) + } + return kinds, count, size +} + +func observation(kind, payload string) sessions.ExecutionEvent { + return sessions.ExecutionEvent{Kind: kind, Payload: json.RawMessage(payload)} +} + +func TestTurnJournalRecordsReplaysAndRollsBack(t *testing.T) { + pool, lease, operations := leasedJournal(t) + tenant, session, turn := newTurn(t, pool, sessions.TurnInProgress) + record := func(tenant pgtype.UUID, first int32, events ...sessions.ExecutionEvent) error { + return operations.AppendTurnEvents(t.Context(), text(tenant), text(session), text(turn), first, events) + } + batch := []sessions.ExecutionEvent{observation("output_message", `{"id":"m1","status":"completed","text":"hi"}`), observation("done", `{}`)} + if err := record(tenant, 1, batch...); err != nil { + t.Fatal(err) + } + entries, count, size := recorded(t, pool, turn) + payloadBytes := int64(len(`{"id":"m1","status":"completed","text":"hi"}`) + len(`{}`)) + if strings.Join(entries, ",") != "output_message,done" || count != 2 || size != payloadBytes { + t.Fatalf("journal %q, %d entries, %d bytes", entries, count, size) + } + _, changes := journal(t, pool, session) + if len(changes) == 0 || changes[0].Event.Type != "agent.session.turn.item.added" { + t.Fatalf("projected changes %q", kinds(changes)) + } + + if err := record(tenant, 1, batch...); err != nil { + t.Fatal("replay", err) + } + if err := record(tenant, 1, observation("done", `{"other":true}`)); !errors.Is(err, sessions.ErrIdempotencyConflict) { + t.Fatal("different replay", err) + } + if err := record(tenant, 4, observation("done", `{}`)); !errors.Is(err, sessions.ErrTurnConflict) { + t.Fatal("gap", err) + } + if err := record(pgID(uuid.New()), 3, observation("done", `{}`)); !errors.Is(err, sessions.ErrNotFound) { + t.Fatal("other tenant", err) + } + invalid := observation("tool_call", `{"id":"call","stage":"after","observation":{"status":"completed","kind":"invalid"}}`) + if err := record(tenant, 3, observation("done", `{}`), invalid); err == nil { + t.Fatal("unprojectable batch recorded") + } + if entries, count, _ := recorded(t, pool, turn); len(entries) != 2 || count != 2 { + t.Fatalf("failed batch kept %q, %d entries", entries, count) + } + _, replayed := journal(t, pool, session) + if len(replayed) != len(changes) { + t.Fatalf("replays journaled %d changes, want %d", len(replayed), len(changes)) + } + + exec(t, pool, `UPDATE turns SET event_count = 65536 WHERE id = $1`, turn) + if err := record(tenant, 65537, observation("done", `{}`)); !errors.Is(err, sessions.ErrEventLimit) { + t.Fatal("journal limit", err) + } + exec(t, pool, `UPDATE turns SET event_count = 2 WHERE id = $1`, turn) + exec(t, pool, `UPDATE sessions SET deleted_at = clock_timestamp() WHERE id = $1`, session) + if err := record(tenant, 3, observation("done", `{}`)); err != nil { + t.Fatal("deleted Session", err) + } + if err := lease.Close(t.Context()); err != nil { + t.Fatal(err) + } + if err := record(tenant, 4, observation("done", `{}`)); !errors.Is(err, pgunit.ErrLeaseClosed) { + t.Fatal("closed lease", err) + } +} + +func TestSubagentProjectionStoresTheSessionRows(t *testing.T) { + pool, _, operations := leasedJournal(t) + tenant, session, turn, device := uuid.New(), uuid.New(), uuid.New(), uuid.New() + exec(t, pool, `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash, configuration) + VALUES ($1, $2, 'codex', 'key', 'hash', '{"agent":{"id":"agent_root"}}')`, session, tenant) + exec(t, pool, `INSERT INTO turns(id, session_id, status) VALUES ($1, $2, 'in_progress')`, turn, session) + exec(t, pool, `INSERT INTO devices(id, tenant_id, name, credential_hash) VALUES ($1, $2, 'device', repeat('a', 64))`, device, tenant) + exec(t, pool, `INSERT INTO session_devices(session_id, device_id, native_session_id) VALUES ($1, $2, 'root')`, session, device) + encode := func(kind string, value any) sessions.ExecutionEvent { + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return sessions.ExecutionEvent{Kind: kind, Payload: raw} + } + record := func(first int32, events ...sessions.ExecutionEvent) error { + return operations.AppendTurnEvents(t.Context(), tenant.String(), session.String(), turn.String(), first, events) + } + name, answer := "reviewer", "checked" + created, completed := int64(1700000000000), int64(1700000001000) + identity := proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 1700000000, ParentTurnID: "turn", SourceItemID: "item", Name: &name} + running := proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn", Status: sessions.TurnInProgress, CreatedAtMS: created} + message, _ := json.Marshal(proto.OutputMessagePayload{ID: "answer", Status: "completed", Text: &answer}) + if err := record(1, + encode(proto.TypeSubagentIdentity, identity), + encode(proto.TypeSubagentTurn, running), + encode(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "native-turn", ItemID: "answer", Position: 1, Kind: proto.TypeOutputMessage, Payload: message}), + ); err != nil { + t.Fatal(err) + } + + moved := running + moved.CreatedAtMS = created + 1 + if err := record(4, encode(proto.TypeSubagentTurn, moved)); !errors.Is(err, sessions.ErrIdempotencyConflict) { + t.Fatal("child Turn identity changed", err) + } + reparented := identity + reparented.ParentNativeID = "unknown" + if err := record(4, encode(proto.TypeSubagentIdentity, reparented)); !errors.Is(err, sessions.ErrIdempotencyConflict) { + t.Fatal("binding changed", err) + } + ended := running + ended.Status, ended.CompletedAtMS = sessions.TurnCompleted, &completed + if err := record(4, encode(proto.TypeSubagentTurn, ended)); err != nil { + t.Fatal(err) + } + + var subagent, childTurn string + inTx(t, pool, func(q *sqlc.Queries) error { + bound := BindSession(q, pgID(tenant), pgID(session)) + child, found, err := bound.LoadNativeSubagent(t.Context(), "child") + if err != nil || !found || !child.Visible || child.NativeCreatedAt != 1700000000 { + t.Fatalf("native Subagent %+v, %v, %v", child, found, err) + } + public, err := loadPublicSubagent(t.Context(), q, pgID(session), child.ID) + if err != nil || public.ParentAgentID != "agent_root" || public.Name == nil || *public.Name != name { + t.Fatalf("public Subagent %+v, %v", public, err) + } + subagent = child.ID + return nil + }) + var status string + var completedAt pgtype.Timestamptz + if err := pool.QueryRow(t.Context(), `SELECT id::text, status, completed_at FROM subagent_turns WHERE session_id = $1 AND subagent_id = $2`, session, subagent).Scan(&childTurn, &status, &completedAt); err != nil { + t.Fatal(err) + } + if status != sessions.TurnCompleted || completedAt.Time.UnixMilli() != completed { + t.Fatalf("child Turn %s at %v", status, completedAt.Time) + } + var position int32 + var index *int32 + if err := pool.QueryRow(t.Context(), `SELECT position, output_index FROM subagent_items WHERE turn_id = $1`, childTurn).Scan(&position, &index); err != nil { + t.Fatal(err) + } + if position != 2 || index == nil || *index != 0 { + t.Fatalf("child Item at %d, output index %v", position, index) + } + _, changes := journal(t, pool, pgID(session)) + if strings.Join(kinds(changes), ",") != "agent.session.subagent.created" { + t.Fatalf("Session changes %q", kinds(changes)) + } + if journaled, count, _ := recorded(t, pool, pgID(turn)); len(journaled) != 4 || count != 4 { + t.Fatalf("journal %q, %d entries", journaled, count) + } +} diff --git a/services/core/internal/persistence/postgres/sessionpg/items.go b/services/core/internal/persistence/postgres/sessionpg/items.go index 222ec3b7b..3e818822f 100644 --- a/services/core/internal/persistence/postgres/sessionpg/items.go +++ b/services/core/internal/persistence/postgres/sessionpg/items.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -16,18 +17,22 @@ import ( // LoadItem reads what the Session holds for update's Item: the stored Item and, // when update asks for them, whether the Turn has another assistant message and // the result the application saved for the call. -func LoadItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, update items.Update) (items.Stored, error) { +func (t *SessionTx) LoadItem(ctx context.Context, turnID string, update items.Update) (items.Stored, error) { var stored items.Stored + turn, err := parseID(turnID) + if err != nil { + return stored, err + } id, err := pgunit.ParseID(update.Item.ID) if err != nil { return stored, err } if update.NeedsNativeMessage() { - if stored.NativeMessage, err = q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}); err != nil { + if stored.NativeMessage, err = t.q.HasNativeMessageItem(ctx, sqlc.HasNativeMessageItemParams{TurnID: turn, ID: id}); err != nil { return stored, err } } - row, err := q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: session, ID: id}) + row, err := t.q.GetSessionItem(ctx, sqlc.GetSessionItemParams{SessionID: t.session, ID: id}) if err == nil { err = json.Unmarshal(row.Payload, &stored.Item) } else if errors.Is(err, pgx.ErrNoRows) { @@ -37,7 +42,7 @@ func LoadItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, u return stored, err } if call, ok := update.ResultCall(); ok { - stored.FunctionResult, err = q.FunctionItemResult(ctx, sqlc.FunctionItemResultParams{SessionID: session, TurnID: turn, CallID: call}) + stored.FunctionResult, err = t.q.FunctionItemResult(ctx, sqlc.FunctionItemResultParams{SessionID: t.session, TurnID: turn, CallID: call}) if errors.Is(err, pgx.ErrNoRows) { err = nil } @@ -50,7 +55,11 @@ func LoadItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, u // keeps both, and its first terminal status records when it settled. PutItem // returns the Item's output index, nil when it has none, and // textvalue.ErrUnstorable for text PostgreSQL cannot store. -func PutItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, created pgtype.Timestamptz, change items.Change) (*int32, error) { +func (t *SessionTx) PutItem(ctx context.Context, turnID string, created time.Time, change items.Change) (*int32, error) { + turn, err := parseID(turnID) + if err != nil { + return nil, err + } id, err := pgunit.ParseID(change.Item.ID) if err != nil { return nil, err @@ -59,7 +68,7 @@ func PutItem(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, cr if err != nil { return nil, err } - row, err := q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: session, TurnID: turn, CreatedAt: created, Payload: payload, IsOutput: change.Output}) + row, err := t.q.PutSessionItem(ctx, sqlc.PutSessionItemParams{ID: id, SessionID: t.session, TurnID: turn, CreatedAt: pgtype.Timestamptz{Time: created, Valid: !created.IsZero()}, Payload: payload, IsOutput: change.Output}) if err != nil { return nil, storable(err) } diff --git a/services/core/internal/persistence/postgres/sessionpg/journal.go b/services/core/internal/persistence/postgres/sessionpg/journal.go new file mode 100644 index 000000000..b588425c1 --- /dev/null +++ b/services/core/internal/persistence/postgres/sessionpg/journal.go @@ -0,0 +1,109 @@ +package sessionpg + +import ( + "context" + "encoding/json" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" +) + +// LoadJournalTurn reads the tenant's Turn in the Session. +func (t *SessionTx) LoadJournalTurn(ctx context.Context, turn string) (sessions.JournalTurn, bool, error) { + id, err := parseID(turn) + if err != nil { + return sessions.JournalTurn{}, false, err + } + row, err := t.q.GetTurn(ctx, sqlc.GetTurnParams{TenantID: t.tenant, SessionID: t.session, ID: id}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.JournalTurn{}, false, nil + } + if err != nil { + return sessions.JournalTurn{}, false, err + } + return sessions.JournalTurn{Status: row.Status, EventCount: row.EventCount, EventBytes: row.EventBytes}, true, nil +} + +// MatchEvents compares the Turn's journal entries from first with events. +func (t *SessionTx) MatchEvents(ctx context.Context, turn string, first int32, events []sessions.ExecutionEvent) (bool, error) { + id, err := parseID(turn) + if err != nil { + return false, err + } + batch, err := json.Marshal(events) + if err != nil { + return false, err + } + return t.q.MatchTurnEventBatch(ctx, sqlc.MatchTurnEventBatchParams{SessionID: t.session, TurnID: id, FirstOrdinal: first, EventCount: int32(len(events)), Batch: batch}) +} + +// InsertEvents records events at the Turn's journal positions from first. The +// database stamps each entry. +func (t *SessionTx) InsertEvents(ctx context.Context, turn string, first int32, events []sessions.ExecutionEvent) error { + id, err := parseID(turn) + if err != nil { + return err + } + batch, err := json.Marshal(events) + if err != nil { + return err + } + return t.q.InsertTurnEventBatch(ctx, sqlc.InsertTurnEventBatchParams{SessionID: t.session, TurnID: id, FirstOrdinal: first, Batch: batch}) +} + +// InsertEvent records event at the Turn's journal position ordinal. +func (t *SessionTx) InsertEvent(ctx context.Context, turn string, ordinal int32, event sessions.ExecutionEvent) error { + id, err := parseID(turn) + if err != nil { + return err + } + return t.q.InsertTurnEvent(ctx, sqlc.InsertTurnEventParams{SessionID: t.session, TurnID: id, Ordinal: ordinal, Kind: event.Kind, Payload: event.Payload}) +} + +// CountEvents adds entries to the Turn's journal counters. +func (t *SessionTx) CountEvents(ctx context.Context, turn string, count int32, size int64) error { + id, err := parseID(turn) + if err != nil { + return err + } + return t.q.CountTurnEvent(ctx, sqlc.CountTurnEventParams{SessionID: t.session, ID: id, EventCount: count, PayloadBytes: size}) +} + +// LoadEventSources reads the Turn's journal entries from first, in order. +func (t *SessionTx) LoadEventSources(ctx context.Context, turn string, first int32) ([]sessions.Source, error) { + id, err := parseID(turn) + if err != nil { + return nil, err + } + rows, err := t.q.ItemEventSources(ctx, sqlc.ItemEventSourcesParams{SessionID: t.session, TurnID: id, Ordinal: first}) + if err != nil { + return nil, err + } + sources := make([]sessions.Source, len(rows)) + for i, row := range rows { + sources[i] = sessions.Source{Turn: uuidString(row.TurnID), Kind: row.Kind, Sequence: int64(row.Ordinal), Payload: row.Payload, CreatedAt: row.CreatedAt.Time} + } + return sources, nil +} + +// LoadInputSource reads the Session's admitted input at sequence. +func (t *SessionTx) LoadInputSource(ctx context.Context, sequence int64) (sessions.Source, error) { + row, err := t.q.ItemInputSource(ctx, sqlc.ItemInputSourceParams{SessionID: t.session, Sequence: sequence}) + if err != nil { + return sessions.Source{}, err + } + return sessions.Source{Turn: uuidString(row.TurnID), Kind: row.Kind, Sequence: row.Sequence, Payload: row.Payload, CreatedAt: row.CreatedAt.Time}, nil +} + +// uuidString formats an identifier, and an absent one as empty. +func uuidString(id pgtype.UUID) string { + if !id.Valid { + return "" + } + return uuid.UUID(id.Bytes).String() +} diff --git a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go index 91dc37d93..b8aca3bd6 100644 --- a/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go +++ b/services/core/internal/persistence/postgres/sessionpg/sessionpg_test.go @@ -20,18 +20,19 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" ) -// newTurn stores a fresh Session with one Turn of status and returns their IDs. -func newTurn(t *testing.T, pool *pgxpool.Pool, status string) (pgtype.UUID, pgtype.UUID) { +// newTurn stores a fresh tenant's Session with one Turn of status and returns +// their IDs. +func newTurn(t *testing.T, pool *pgxpool.Pool, status string) (pgtype.UUID, pgtype.UUID, pgtype.UUID) { t.Helper() - session, turn := uuid.New(), uuid.New() - if _, err := pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash) VALUES ($1, $2, 'codex', 'key', 'hash')`, session, uuid.New()); err != nil { + tenant, session, turn := uuid.New(), uuid.New(), uuid.New() + if _, err := pool.Exec(t.Context(), `INSERT INTO sessions(id, tenant_id, engine, idempotency_key, request_hash) VALUES ($1, $2, 'codex', 'key', 'hash')`, session, tenant); err != nil { t.Fatal(err) } if _, err := pool.Exec(t.Context(), `INSERT INTO turns(id, session_id, status, completed_at) VALUES ($1, $2, $3::text, CASE WHEN $3::text IN ('completed', 'failed', 'cancelled') THEN clock_timestamp() END)`, turn, session, status); err != nil { t.Fatal(err) } - return pgtype.UUID{Bytes: session, Valid: true}, pgtype.UUID{Bytes: turn, Valid: true} + return pgtype.UUID{Bytes: tenant, Valid: true}, pgtype.UUID{Bytes: session, Valid: true}, pgtype.UUID{Bytes: turn, Valid: true} } // inTx runs apply in one committed transaction on transaction-bound queries. @@ -66,7 +67,7 @@ func journal(t *testing.T, pool *pgxpool.Pool, session pgtype.UUID) ([]int64, [] func TestAppendChangesSequencesAndPruneKeepsTheNewest(t *testing.T) { pool := pgtest.Open(t) - session, _ := newTurn(t, pool, sessions.TurnInProgress) + _, session, _ := newTurn(t, pool, sessions.TurnInProgress) total := sessions.RetainedChanges + 2 inTx(t, pool, func(q *sqlc.Queries) error { for range total { @@ -91,9 +92,9 @@ func TestAppendChangesSequencesAndPruneKeepsTheNewest(t *testing.T) { func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { pool := pgtest.Open(t) - session, turn := newTurn(t, pool, sessions.TurnInProgress) + tenant, session, turn := newTurn(t, pool, sessions.TurnInProgress) turnID := uuid.UUID(turn.Bytes).String() - created := pgtype.Timestamptz{Time: time.Now(), Valid: true} + created := time.Now() text := func(value string) *string { return &value } input := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "user", Status: "completed", Content: []v1.ItemContent{{Type: "input_text", Text: text("hi")}}} answer := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "assistant", Status: "in_progress", Content: []v1.ItemContent{{Type: "output_text", Text: text("draft")}}} @@ -101,7 +102,7 @@ func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { var indexes []*int32 inTx(t, pool, func(q *sqlc.Queries) error { for _, change := range []items.Change{{Item: input}, {Item: answer, Output: true}, {Item: search, Output: true}, {Previous: answer, Item: answer, Output: true}} { - index, err := PutItem(t.Context(), q, session, turn, created, change) + index, err := BindSession(q, tenant, session).PutItem(t.Context(), turnID, created, change) if err != nil { return err } @@ -121,11 +122,12 @@ func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { t.Fatalf("Session positions %q, %v", positions, err) } inTx(t, pool, func(q *sqlc.Queries) error { - stored, err := LoadItem(t.Context(), q, session, turn, items.Update{Item: v1.Item{ID: answer.ID}}) + bound := BindSession(q, tenant, session) + stored, err := bound.LoadItem(t.Context(), turnID, items.Update{Item: v1.Item{ID: answer.ID}}) if err != nil || !reflect.DeepEqual(stored, items.Stored{Item: answer}) { t.Fatalf("stored %+v, %v", stored, err) } - legacy, err := LoadItem(t.Context(), q, session, turn, items.Update{Item: v1.Item{ID: uuid.NewString()}, LegacyFinal: true}) + legacy, err := bound.LoadItem(t.Context(), turnID, items.Update{Item: v1.Item{ID: uuid.NewString()}, LegacyFinal: true}) if err != nil || !legacy.NativeMessage || legacy.Item.ID != "" { t.Fatalf("legacy aggregate %+v, %v", legacy, err) } @@ -135,12 +137,12 @@ func TestItemsTakePositionsAndOutputIndexes(t *testing.T) { func TestUnstorableTextIsTheSharedError(t *testing.T) { pool := pgtest.Open(t) - session, turn := newTurn(t, pool, sessions.TurnInProgress) + tenant, session, turn := newTurn(t, pool, sessions.TurnInProgress) nul := "a\x00b" item := v1.Item{ID: uuid.NewString(), TurnID: uuid.UUID(turn.Bytes).String(), Type: "message", Role: "user", Status: "completed", Content: []v1.ItemContent{{Type: "input_text", Text: &nul}}} for name, write := range map[string]func(*sqlc.Queries) error{ "item": func(q *sqlc.Queries) error { - _, err := PutItem(t.Context(), q, session, turn, pgtype.Timestamptz{Time: time.Now(), Valid: true}, items.Change{Item: item}) + _, err := BindSession(q, tenant, session).PutItem(t.Context(), item.TurnID, time.Now(), items.Change{Item: item}) return err }, "change": func(q *sqlc.Queries) error { @@ -159,16 +161,16 @@ func TestUnstorableTextIsTheSharedError(t *testing.T) { // settlement time. func TestTurnEndAppliesItemsTurnAndActivityInOrder(t *testing.T) { pool := pgtest.Open(t) - session, turn := newTurn(t, pool, sessions.TurnCompleted) + tenant, session, turn := newTurn(t, pool, sessions.TurnCompleted) turnID := uuid.UUID(turn.Bytes).String() - created := pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true} + created := time.Now().Add(-time.Minute) text := "partial" answer := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "message", Role: "assistant", Status: "in_progress", Content: []v1.ItemContent{{Type: "output_text", Text: &text}}} search := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "web_search_call", Status: "in_progress"} done := v1.Item{ID: uuid.NewString(), TurnID: turnID, Type: "web_search_call", Status: "completed"} inTx(t, pool, func(q *sqlc.Queries) error { for _, item := range []v1.Item{answer, search, done} { - if _, err := PutItem(t.Context(), q, session, turn, created, items.Change{Item: item, Output: true}); err != nil { + if _, err := BindSession(q, tenant, session).PutItem(t.Context(), turnID, created, items.Change{Item: item, Output: true}); err != nil { return err } } @@ -217,7 +219,7 @@ func TestTurnEndAppliesItemsTurnAndActivityInOrder(t *testing.T) { if statuses[answer.ID] != "incomplete" || statuses[search.ID] != "incomplete" || statuses[done.ID] != "completed" { t.Fatalf("statuses %v", statuses) } - if !settled[answer.ID].Equal(settled[search.ID]) || !settled[answer.ID].After(created.Time) || !settled[done.ID].Equal(created.Time.Truncate(time.Microsecond)) { + if !settled[answer.ID].Equal(settled[search.ID]) || !settled[answer.ID].After(created) || !settled[done.ID].Equal(created.Truncate(time.Microsecond)) { t.Fatalf("settlement times %v", settled) } } diff --git a/services/core/internal/persistence/postgres/sessionpg/subagent_reads.go b/services/core/internal/persistence/postgres/sessionpg/subagent_reads.go index 8b8fc91de..87e089788 100644 --- a/services/core/internal/persistence/postgres/sessionpg/subagent_reads.go +++ b/services/core/internal/persistence/postgres/sessionpg/subagent_reads.go @@ -21,7 +21,7 @@ func (s *Store) GetSubagent(ctx context.Context, tenantID, sessionID, subagentID var result v1.Subagent err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { var err error - result, err = LoadPublicSubagent(ctx, q, session, subagentID) + result, err = loadPublicSubagent(ctx, q, session, subagentID) return err }) return result, err @@ -37,7 +37,7 @@ func (s *Store) ListSubagents(ctx context.Context, tenantID, sessionID, cursor s if cursor != "" { // Any cursor that is not a Subagent of this Session, including a // malformed one, is an invalid cursor rather than a missing resource. - after, err := LoadPublicSubagent(ctx, q, session, cursor) + after, err := loadPublicSubagent(ctx, q, session, cursor) if err != nil { return unresolvedCursor(err, sessions.ErrResourceCursor) } @@ -53,7 +53,7 @@ func (s *Store) ListSubagents(ctx context.Context, tenantID, sessionID, cursor s rows = rows[:limit] } for _, id := range rows { - value, err := LoadPublicSubagent(ctx, q, session, uuid.UUID(id.Bytes).String()) + value, err := loadPublicSubagent(ctx, q, session, uuid.UUID(id.Bytes).String()) if err != nil { return err } @@ -67,10 +67,10 @@ func (s *Store) ListSubagents(ctx context.Context, tenantID, sessionID, cursor s func (s *Store) GetSubagentTurn(ctx context.Context, tenantID, sessionID, subagentID, turnID string) (v1.Turn, error) { var result v1.Turn err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - if _, err := LoadPublicSubagent(ctx, q, session, subagentID); err != nil { + if _, err := loadPublicSubagent(ctx, q, session, subagentID); err != nil { return err } - row, err := LoadChildTurn(ctx, q, session, subagentID, turnID) + row, err := loadChildTurn(ctx, q, session, subagentID, turnID) if err != nil { return err } @@ -89,14 +89,14 @@ func (s *Store) ListSubagentTurns(ctx context.Context, tenantID, sessionID, suba return result, sessions.ErrInvalidInput } err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - if _, err := LoadPublicSubagent(ctx, q, session, subagentID); err != nil { + if _, err := loadPublicSubagent(ctx, q, session, subagentID); err != nil { return err } child, _ := parseID(subagentID) p := sqlc.ListChildTurnsParams{SessionID: session, SubagentID: child, Ascending: ascending, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}} if cursor != "" { // Root Turns and other children's Turns are outside this list. - row, err := LoadChildTurn(ctx, q, session, subagentID, cursor) + row, err := loadChildTurn(ctx, q, session, subagentID, cursor) if err != nil { return unresolvedCursor(err, sessions.ErrResourceCursor) } @@ -142,13 +142,13 @@ func (s *Store) listChildItems(ctx context.Context, tenantID, sessionID, subagen return result, sessions.ErrInvalidInput } err := s.withPublicSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - if _, err := LoadPublicSubagent(ctx, q, session, subagentID); err != nil { + if _, err := loadPublicSubagent(ctx, q, session, subagentID); err != nil { return err } child, _ := parseID(subagentID) p := sqlc.ListChildItemsParams{SessionID: session, SubagentID: child, Ascending: ascending, PageLimit: int32(limit + 1), AfterID: pgtype.UUID{Valid: true}, AfterTurn: pgtype.UUID{Valid: true}} if turnID != "" { - row, err := LoadChildTurn(ctx, q, session, subagentID, turnID) + row, err := loadChildTurn(ctx, q, session, subagentID, turnID) if err != nil { return err } diff --git a/services/core/internal/persistence/postgres/sessionpg/subagents.go b/services/core/internal/persistence/postgres/sessionpg/subagents.go index 594132f66..c4a08f68d 100644 --- a/services/core/internal/persistence/postgres/sessionpg/subagents.go +++ b/services/core/internal/persistence/postgres/sessionpg/subagents.go @@ -2,7 +2,9 @@ package sessionpg import ( "context" + "encoding/json" "errors" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -14,9 +16,9 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -// LoadPublicSubagent loads a Subagent of the Session as the public API shows +// loadPublicSubagent loads a Subagent of the Session as the public API shows // it. A missing or malformed ID is sessions.ErrNotFound. -func LoadPublicSubagent(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, id string) (v1.Subagent, error) { +func loadPublicSubagent(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, id string) (v1.Subagent, error) { row, err := q.GetPublicSubagent(ctx, sqlc.GetPublicSubagentParams{SessionID: session, ID: pgunit.PathID(id)}) if errors.Is(err, pgx.ErrNoRows) { return v1.Subagent{}, sessions.ErrNotFound @@ -38,12 +40,179 @@ func LoadPublicSubagent(ctx context.Context, q *sqlc.Queries, session pgtype.UUI return result, nil } -// LoadChildTurn loads a Turn of the Session's Subagent child. A missing or +// loadChildTurn loads a Turn of the Session's Subagent child. A missing or // malformed ID, a root Turn and another child's Turn are sessions.ErrNotFound. -func LoadChildTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, child, id string) (sqlc.SubagentTurn, error) { +func loadChildTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, child, id string) (sqlc.SubagentTurn, error) { row, err := q.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: session, ID: pgunit.PathID(id)}) if errors.Is(err, pgx.ErrNoRows) || (err == nil && uuid.UUID(row.SubagentID.Bytes).String() != child) { return row, sessions.ErrNotFound } return row, err } + +func (t *SessionTx) LoadRootAgent(ctx context.Context) (string, error) { + return t.q.SubagentRootAgent(ctx, t.session) +} + +func (t *SessionTx) LoadNativeSubagent(ctx context.Context, native string) (sessions.NativeSubagent, bool, error) { + row, err := t.q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: t.session, NativeID: native}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.NativeSubagent{}, false, nil + } + if err != nil { + return sessions.NativeSubagent{}, false, err + } + return sessions.NativeSubagent{ + ID: uuid.UUID(row.ID.Bytes).String(), Visible: row.PublicVisible, NativeCreatedAt: row.NativeCreatedAt, + Status: row.Status, LifecycleAtMS: row.LifecycleAtMs, + }, true, nil +} + +// PutSubagentIdentity allocates the Subagent ID of a new binding. +func (t *SessionTx) PutSubagentIdentity(ctx context.Context, identity sessions.SubagentIdentity) (string, error) { + turn, err := parseID(identity.FirstTurn) + if err != nil { + return "", err + } + id, err := t.q.PutSubagentIdentity(ctx, sqlc.PutSubagentIdentityParams{ + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: t.session, + NativeID: identity.NativeID, ParentNativeID: identity.ParentNativeID, NativeCreatedAt: identity.NativeCreatedAt, + FirstTurnID: turn, FirstEventOrdinal: identity.FirstOrdinal, + }) + if errors.Is(err, pgx.ErrNoRows) { + return "", sessions.ErrIdempotencyConflict + } + if err != nil { + return "", err + } + return uuid.UUID(id.Bytes).String(), nil +} + +func (t *SessionTx) PublishSubagent(ctx context.Context, subagent string, name, instructions *string) error { + id, err := parseID(subagent) + if err != nil { + return err + } + p := sqlc.PublishSubagentParams{SessionID: t.session, ID: id} + if name != nil { + p.Name = pgtype.Text{String: *name, Valid: true} + } + if instructions != nil { + p.Instructions = pgtype.Text{String: *instructions, Valid: true} + } + return t.q.PublishSubagent(ctx, p) +} + +func (t *SessionTx) LoadPublicSubagent(ctx context.Context, id string) (v1.Subagent, error) { + return loadPublicSubagent(ctx, t.q, t.session, id) +} + +func (t *SessionTx) PutSubagentEffect(ctx context.Context, effect string, payload json.RawMessage) (bool, error) { + inserted, err := t.q.PutSubagentEffect(ctx, sqlc.PutSubagentEffectParams{SessionID: t.session, EffectID: effect, Payload: payload}) + if errors.Is(err, pgx.ErrNoRows) { + return false, sessions.ErrIdempotencyConflict + } + return inserted, err +} + +func (t *SessionTx) ApplySubagentLifecycle(ctx context.Context, subagent string, lifecycle sessions.SubagentLifecycle) error { + id, err := parseID(subagent) + if err != nil { + return err + } + closed := pgtype.Int8{} + if lifecycle.ClosedAtMS != nil { + closed = pgtype.Int8{Int64: *lifecycle.ClosedAtMS, Valid: true} + } + return t.q.ApplySubagentLifecycle(ctx, sqlc.ApplySubagentLifecycleParams{SessionID: t.session, ID: id, Status: lifecycle.Status, ClosedAtMs: closed, LifecycleAtMs: lifecycle.AtMS}) +} + +func (t *SessionTx) LoadChildTurn(ctx context.Context, subagent, id string) (sessions.ChildTurn, bool, error) { + row, err := loadChildTurn(ctx, t.q, t.session, subagent, id) + if errors.Is(err, sessions.ErrNotFound) { + return sessions.ChildTurn{}, false, nil + } + if err != nil { + return sessions.ChildTurn{}, false, err + } + return sessions.ChildTurn{ + ID: uuid.UUID(row.ID.Bytes).String(), Subagent: uuid.UUID(row.SubagentID.Bytes).String(), NativeID: row.NativeID, Status: row.Status, + CreatedAtMS: row.CreatedAt.Time.UnixMilli(), StartedAtMS: nativeMillis(row.StartedAt), CompletedAtMS: nativeMillis(row.CompletedAt), + Usage: json.RawMessage(row.TokenUsage), + }, true, nil +} + +func (t *SessionTx) PutChildTurn(ctx context.Context, turn sessions.ChildTurn) error { + id, err := parseID(turn.ID) + if err != nil { + return err + } + subagent, err := parseID(turn.Subagent) + if err != nil { + return err + } + _, err = t.q.PutChildTurn(ctx, sqlc.PutChildTurnParams{ + ID: id, SessionID: t.session, SubagentID: subagent, NativeID: turn.NativeID, Status: turn.Status, + CreatedAt: pgtype.Timestamptz{Time: time.UnixMilli(turn.CreatedAtMS), Valid: true}, StartedAt: nativeTime(turn.StartedAtMS), CompletedAt: nativeTime(turn.CompletedAtMS), + TokenUsage: turn.Usage, + }) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.ErrIdempotencyConflict + } + return err +} + +func (t *SessionTx) LoadChildItem(ctx context.Context, subagent, item string, candidate json.RawMessage) (sessions.StoredChildItem, bool, error) { + child, err := parseID(subagent) + if err != nil { + return sessions.StoredChildItem{}, false, err + } + id, err := parseID(item) + if err != nil { + return sessions.StoredChildItem{}, false, err + } + row, err := t.q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: t.session, SubagentID: child, ID: id, Candidate: candidate}) + if errors.Is(err, pgx.ErrNoRows) { + return sessions.StoredChildItem{}, false, nil + } + if err != nil { + return sessions.StoredChildItem{}, false, err + } + return sessions.StoredChildItem{Turn: uuid.UUID(row.TurnID.Bytes).String(), Position: row.Position, Payload: row.Payload, Same: row.PayloadEqual}, true, nil +} + +// PutChildItem stores a Subagent Item; a new output Item takes its Turn's next +// output index. +func (t *SessionTx) PutChildItem(ctx context.Context, item sessions.ChildItem) error { + id, err := parseID(item.ID) + if err != nil { + return err + } + subagent, err := parseID(item.Subagent) + if err != nil { + return err + } + turn, err := parseID(item.Turn) + if err != nil { + return err + } + _, err = t.q.PutChildItem(ctx, sqlc.PutChildItemParams{ID: id, SessionID: t.session, SubagentID: subagent, TurnID: turn, Position: item.Position, Payload: item.Payload, IsOutput: item.Output}) + return err +} + +// nativeTime stores native milliseconds; nil is absent. +func nativeTime(value *int64) pgtype.Timestamptz { + if value == nil { + return pgtype.Timestamptz{} + } + return pgtype.Timestamptz{Time: time.UnixMilli(*value), Valid: true} +} + +// nativeMillis reads stored native milliseconds; absent is nil. +func nativeMillis(value pgtype.Timestamptz) *int64 { + if !value.Valid { + return nil + } + ms := value.Time.UnixMilli() + return &ms +} diff --git a/services/core/internal/persistence/postgres/sessionpg/turn_end.go b/services/core/internal/persistence/postgres/sessionpg/turn_end.go index d354b990f..cc5e97257 100644 --- a/services/core/internal/persistence/postgres/sessionpg/turn_end.go +++ b/services/core/internal/persistence/postgres/sessionpg/turn_end.go @@ -36,7 +36,7 @@ func LoadEnding(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID) // the public changes. func ApplyTurnEnd(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, end sessions.TurnEnd) error { if end.TerminalActivity { - if err := RecordTerminalActivity(ctx, q, session); err != nil { + if err := recordTerminalActivity(ctx, q, session); err != nil { return err } } @@ -74,6 +74,10 @@ func ApplyTurnEnd(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUI // RecordTerminalActivity marks the Session's running managed compute active at // the database's commit clock. -func RecordTerminalActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { +func (t *SessionTx) RecordTerminalActivity(ctx context.Context) error { + return recordTerminalActivity(ctx, t.q, t.session) +} + +func recordTerminalActivity(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { return q.RecordRuntimeTerminalActivity(ctx, session) } diff --git a/services/core/internal/persistence/postgres/sessionpg/usage.go b/services/core/internal/persistence/postgres/sessionpg/usage.go index aac89f32f..6b164da89 100644 --- a/services/core/internal/persistence/postgres/sessionpg/usage.go +++ b/services/core/internal/persistence/postgres/sessionpg/usage.go @@ -17,10 +17,14 @@ func LoadUsage(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) (json. } // PutTurnUsage replaces a root Turn's recorded usage with a measurement. -func PutTurnUsage(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, usage v1.TokenUsage) error { +func (t *SessionTx) PutTurnUsage(ctx context.Context, turnID string, usage v1.TokenUsage) error { + turn, err := parseID(turnID) + if err != nil { + return err + } payload, err := json.Marshal(usage) if err != nil { return err } - return q.PutTurnUsage(ctx, sqlc.PutTurnUsageParams{SessionID: session, ID: turn, TokenUsage: payload}) + return t.q.PutTurnUsage(ctx, sqlc.PutTurnUsageParams{SessionID: t.session, ID: turn, TokenUsage: payload}) } diff --git a/services/core/internal/sessions/doc.go b/services/core/internal/sessions/doc.go index df8e82db8..adb5fa21d 100644 --- a/services/core/internal/sessions/doc.go +++ b/services/core/internal/sessions/doc.go @@ -7,10 +7,12 @@ // The Session writes that several operations share are procedures here, over // the transaction interfaces declared beside them: cancelling work, failing // and terminating an Environment, tracking input activity, the admission -// gates, admitting a function result and reading a Turn's required actions. -// Service runs the pooled Session use cases, such as staging a Turn's -// Artifacts, over Storage, and Reader declares the Session reads. -// ExecutionOperations runs the Session writes only the execution owner makes, -// such as recording function calls and their application receipts, over the +// gates, admitting a function result, reading a Turn's required actions, +// appending to a Turn's execution journal and projecting its observations and +// admitted inputs into Items, Turn usage and Subagents. Service runs the pooled +// Session use cases, such as staging a Turn's Artifacts, over Storage, and +// Reader declares the Session reads. ExecutionOperations runs the Session +// writes only the execution owner makes, such as recording function calls and +// their application receipts and journaling a Turn's observations, over the // lease-bound ExecutionStorage. package sessions diff --git a/services/core/internal/sessions/execution.go b/services/core/internal/sessions/execution.go index 8a3a8869c..6432a8e5d 100644 --- a/services/core/internal/sessions/execution.go +++ b/services/core/internal/sessions/execution.go @@ -19,4 +19,5 @@ func NewExecutionOperations(storage ExecutionStorage) (*ExecutionOperations, err // ExecutionStorage is the lease-bound Session storage, one family per line. type ExecutionStorage interface { FunctionExecution + JournalExecution } diff --git a/services/core/internal/sessions/execution_functions_test.go b/services/core/internal/sessions/execution_functions_test.go index 0b3ca82cd..a9b976046 100644 --- a/services/core/internal/sessions/execution_functions_test.go +++ b/services/core/internal/sessions/execution_functions_test.go @@ -15,6 +15,7 @@ import ( type fakeExecutionStorage struct { t *testing.T withFunctionTurn func(ctx context.Context, tenant, session, turn string, apply func(context.Context, FunctionTx, Turn) error) error + withTurnJournal func(ctx context.Context, tenant, session string, apply func(context.Context, TurnJournalTx) error) error } var _ ExecutionStorage = (*fakeExecutionStorage)(nil) @@ -27,6 +28,14 @@ func (f *fakeExecutionStorage) WithFunctionTurn(ctx context.Context, tenant, ses return f.withFunctionTurn(ctx, tenant, session, turn, apply) } +func (f *fakeExecutionStorage) WithTurnJournal(ctx context.Context, tenant, session string, apply func(context.Context, TurnJournalTx) error) error { + f.t.Helper() + if f.withTurnJournal == nil { + f.t.Fatal("unexpected call to WithTurnJournal") + } + return f.withTurnJournal(ctx, tenant, session, apply) +} + const testTenant = "4f0d0c35-8b8e-4d7c-9d1c-1f0a5b8a2e61" // functionOperations serves the tenant's Turn as current through tx. diff --git a/services/core/internal/sessions/execution_journal.go b/services/core/internal/sessions/execution_journal.go new file mode 100644 index 000000000..95480f05d --- /dev/null +++ b/services/core/internal/sessions/execution_journal.go @@ -0,0 +1,30 @@ +package sessions + +import "context" + +// JournalExecution records execution observations in Turn journals. +type JournalExecution interface { + // WithTurnJournal runs apply in a Session transaction on the lease-bound + // connection, with the tenant's Session locked, and passes apply the + // transaction's context. A deleted Session still records its Turns' + // observations. + WithTurnJournal(ctx context.Context, tenant, session string, apply func(context.Context, TurnJournalTx) error) error +} + +// AppendTurnEvents records an ordered batch of a Turn's execution observations +// in its journal from position first and projects them, as the +// AppendTurnEvents procedure decides. A malformed tenant, Session or Turn ID is +// ErrInvalidInput, before the batch is validated and before the Session is +// looked up. The journal is Core-internal; it is not the public event stream. +func (o *ExecutionOperations) AppendTurnEvents(ctx context.Context, tenant, session, turn string, first int32, events []ExecutionEvent) error { + if !validID(tenant) || !validID(session) { + return ErrInvalidInput + } + batch, err := NewJournalBatch(turn, first, events) + if err != nil { + return err + } + return o.storage.WithTurnJournal(ctx, tenant, session, func(ctx context.Context, tx TurnJournalTx) error { + return AppendTurnEvents(ctx, tx, batch) + }) +} diff --git a/services/core/internal/sessions/journal.go b/services/core/internal/sessions/journal.go new file mode 100644 index 000000000..a2af177fd --- /dev/null +++ b/services/core/internal/sessions/journal.go @@ -0,0 +1,183 @@ +package sessions + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" +) + +// A Turn's journal holds its execution observations in order. One batch holds +// at most 64 observations of at most 512 KiB each and 1 MiB together, and one +// Turn's journal at most 65,536 observations and 32 MiB. The terminal outcome +// is recorded beyond those limits, in the one entry they reserve for it. +const ( + journalBatchEvents = 64 + journalPayloadBytes = 512 * 1024 + journalBatchBytes = 1024 * 1024 + journalTurnEvents = 65536 + journalTurnBytes = 32 * 1024 * 1024 +) + +// JournalBatch is a validated batch of execution observations for a Turn's +// journal, each payload normalized. NewJournalBatch makes one. +type JournalBatch struct { + turn string + first int32 + events []ExecutionEvent + bytes int64 +} + +// NewJournalBatch validates an ordered batch of a Turn's execution +// observations that starts at journal position first. A malformed Turn ID is +// ErrInvalidInput before the batch is checked. Each observation needs a kind +// and a JSON object payload within the journal limits; a batch over the batch +// size limit is ErrEventLimit, and anything else invalid is ErrInvalidInput. +func NewJournalBatch(turn string, first int32, events []ExecutionEvent) (JournalBatch, error) { + if !validID(turn) || first < 1 || len(events) == 0 || len(events) > journalBatchEvents { + return JournalBatch{}, ErrInvalidInput + } + batch := JournalBatch{turn: turn, first: first, events: make([]ExecutionEvent, len(events))} + for i, event := range events { + if len(event.Payload) > journalPayloadBytes || !ValidEngine(event.Kind) { + return JournalBatch{}, ErrInvalidInput + } + payload, err := jsonobject.Normalize(event.Payload) + if err != nil { + return JournalBatch{}, fmt.Errorf("%w: %w", ErrInvalidInput, err) + } + batch.events[i] = ExecutionEvent{Kind: event.Kind, Payload: payload} + batch.bytes += int64(len(payload)) + } + if batch.bytes > journalBatchBytes { + return JournalBatch{}, ErrEventLimit + } + return batch, nil +} + +// validID reports whether value can name a stored resource: a nonzero UUID. +func validID(value string) bool { + id, err := uuid.Parse(value) + return err == nil && id != uuid.Nil +} + +// JournalTurn is what decides whether a Turn's journal records a new batch: +// the Turn's status and what its journal holds. +type JournalTurn struct { + Status string + EventCount int32 + EventBytes int64 +} + +// admit decides whether turn records b as its next journal entries: only an +// in-progress or waiting Turn records a batch, only at the position after its +// last entry (ErrTurnConflict), and only within the journal limits +// (ErrEventLimit). +func (b JournalBatch) admit(turn JournalTurn) error { + if (turn.Status != TurnInProgress && turn.Status != TurnWaiting) || b.first != turn.EventCount+1 { + return ErrTurnConflict + } + if turn.EventCount+int32(len(b.events)) > journalTurnEvents || turn.EventBytes+b.bytes > journalTurnBytes { + return ErrEventLimit + } + return nil +} + +// EventProjectionTx counts a Turn's new journal entries and projects them. +type EventProjectionTx interface { + ProjectionTx + // CountEvents adds count entries of size payload bytes to what the Turn's + // journal holds. + CountEvents(ctx context.Context, turn string, count int32, size int64) error + // LoadEventSources reads the Turn's journal entries from position first, in + // order. + LoadEventSources(ctx context.Context, turn string, first int32) ([]Source, error) +} + +// TurnJournalTx records a batch in a Turn's journal and projects it. +type TurnJournalTx interface { + EventProjectionTx + // LoadJournalTurn reads the tenant's Turn in the Session and reports whether + // it exists. + LoadJournalTurn(ctx context.Context, turn string) (JournalTurn, bool, error) + // MatchEvents reports whether the Turn's journal holds exactly events from + // position first. + MatchEvents(ctx context.Context, turn string, first int32, events []ExecutionEvent) (bool, error) + // InsertEvents records events at the Turn's journal positions from first. + InsertEvents(ctx context.Context, turn string, first int32, events []ExecutionEvent) error +} + +// TurnEventTx records one entry in a Turn's journal and projects it. +type TurnEventTx interface { + EventProjectionTx + // InsertEvent records event at the Turn's journal position ordinal. + InsertEvent(ctx context.Context, turn string, ordinal int32, event ExecutionEvent) error +} + +// AppendTurnEvents records batch in its Turn's journal and projects the new +// entries. A batch the journal already holds from the same position is an +// idempotent replay and changes nothing; a different batch there is +// ErrIdempotencyConflict. A new batch is recorded only as JournalBatch admits +// it, and a missing Turn is ErrNotFound. +func AppendTurnEvents(ctx context.Context, tx TurnJournalTx, batch JournalBatch) error { + if len(batch.events) == 0 { + return ErrInvalidInput + } + turn, found, err := tx.LoadJournalTurn(ctx, batch.turn) + if err != nil { + return err + } + if !found { + return ErrNotFound + } + if batch.first <= turn.EventCount { + matches, err := tx.MatchEvents(ctx, batch.turn, batch.first, batch.events) + if err != nil { + return err + } + if !matches { + return ErrIdempotencyConflict + } + return nil + } + if err := batch.admit(turn); err != nil { + return err + } + if err := tx.InsertEvents(ctx, batch.turn, batch.first, batch.events); err != nil { + return err + } + if err := projectEvents(ctx, tx, batch.turn, batch.first); err != nil { + return err + } + return tx.CountEvents(ctx, batch.turn, int32(len(batch.events)), batch.bytes) +} + +// AppendTurnEvent records event in the Turn's journal after its last entry, +// last, which the caller read under the Session lock, and projects it. It +// records the terminal outcome, which the journal limits reserve an entry for. +func AppendTurnEvent(ctx context.Context, tx TurnEventTx, turn string, last int32, event ExecutionEvent) error { + if err := tx.InsertEvent(ctx, turn, last+1, event); err != nil { + return err + } + if err := tx.CountEvents(ctx, turn, 1, int64(len(event.Payload))); err != nil { + return err + } + return projectEvents(ctx, tx, turn, last+1) +} + +// projectEvents projects the Turn's journal entries from position first, in +// order. +func projectEvents(ctx context.Context, tx EventProjectionTx, turn string, first int32) error { + sources, err := tx.LoadEventSources(ctx, turn, first) + if err != nil { + return err + } + for _, source := range sources { + if err := ProjectSource(ctx, tx, source); err != nil { + return err + } + } + return nil +} diff --git a/services/core/internal/sessions/journal_test.go b/services/core/internal/sessions/journal_test.go new file mode 100644 index 000000000..40c0e08fe --- /dev/null +++ b/services/core/internal/sessions/journal_test.go @@ -0,0 +1,165 @@ +package sessions + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +// event is an execution observation that projects nothing. +func event() ExecutionEvent { + return ExecutionEvent{Kind: "done", Payload: json.RawMessage(`{}`)} +} + +func TestNewJournalBatch(t *testing.T) { + big := json.RawMessage(`{"text":"` + strings.Repeat("x", 400*1024) + `"}`) + many := make([]ExecutionEvent, journalBatchEvents+1) + for i := range many { + many[i] = event() + } + for name, test := range map[string]struct { + first int32 + events []ExecutionEvent + want error + }{ + "valid": {1, []ExecutionEvent{event()}, nil}, + "position zero": {0, []ExecutionEvent{event()}, ErrInvalidInput}, + "empty": {1, nil, ErrInvalidInput}, + "too many": {1, many, ErrInvalidInput}, + "invalid kind": {1, []ExecutionEvent{{Kind: "Done", Payload: json.RawMessage(`{}`)}}, ErrInvalidInput}, + "not an object": {1, []ExecutionEvent{{Kind: "done", Payload: json.RawMessage(`[]`)}}, ErrInvalidInput}, + "payload too large": {1, []ExecutionEvent{{Kind: "done", Payload: json.RawMessage(`{"text":"` + strings.Repeat("x", journalPayloadBytes) + `"}`)}}, ErrInvalidInput}, + "batch too large": {1, []ExecutionEvent{{Kind: "delta", Payload: big}, {Kind: "delta", Payload: big}, {Kind: "delta", Payload: big}}, ErrEventLimit}, + } { + if _, err := NewJournalBatch(testTurn, test.first, test.events); !errors.Is(err, test.want) || (test.want == nil && err != nil) { + t.Errorf("%s: %v", name, err) + } + } + batch, err := NewJournalBatch(testTurn, 1, []ExecutionEvent{{Kind: "done", Payload: json.RawMessage(`{ "b": 1, "a": 2 }`)}}) + if err != nil || string(batch.events[0].Payload) != `{"a":2,"b":1}` || batch.bytes != int64(len(`{"a":2,"b":1}`)) { + t.Fatalf("normalized %s, %d bytes, %v", batch.events[0].Payload, batch.bytes, err) + } +} + +func TestJournalBatchAdmit(t *testing.T) { + batch, err := NewJournalBatch(testTurn, 3, []ExecutionEvent{event()}) + if err != nil { + t.Fatal(err) + } + for name, test := range map[string]struct { + turn JournalTurn + want error + }{ + "in progress": {JournalTurn{Status: TurnInProgress, EventCount: 2}, nil}, + "waiting": {JournalTurn{Status: TurnWaiting, EventCount: 2}, nil}, + "queued": {JournalTurn{Status: TurnQueued, EventCount: 2}, ErrTurnConflict}, + "ended": {JournalTurn{Status: TurnCompleted, EventCount: 2}, ErrTurnConflict}, + "gap": {JournalTurn{Status: TurnInProgress, EventCount: 1}, ErrTurnConflict}, + "byte limit": {JournalTurn{Status: TurnInProgress, EventCount: 2, EventBytes: journalTurnBytes}, ErrEventLimit}, + "within bytes": {JournalTurn{Status: TurnInProgress, EventCount: 2, EventBytes: journalTurnBytes - batch.bytes}, nil}, + } { + if err := batch.admit(test.turn); !errors.Is(err, test.want) || (test.want == nil && err != nil) { + t.Errorf("%s: %v", name, err) + } + } + last, err := NewJournalBatch(testTurn, journalTurnEvents, []ExecutionEvent{event(), event()}) + if err != nil { + t.Fatal(err) + } + if err := last.admit(JournalTurn{Status: TurnInProgress, EventCount: journalTurnEvents - 1}); !errors.Is(err, ErrEventLimit) { + t.Fatal("journal event limit", err) + } +} + +func TestAppendTurnEvents(t *testing.T) { + batch, err := NewJournalBatch(testTurn, 2, []ExecutionEvent{event()}) + if err != nil { + t.Fatal(err) + } + running := JournalTurn{Status: TurnInProgress, EventCount: 1} + found := func(turn JournalTurn) func() (JournalTurn, bool, error) { + return func() (JournalTurn, bool, error) { return turn, true, nil } + } + t.Run("records and projects a new batch", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: found(running), insertEvents: done, loadEventSources: returns([]Source{{Turn: testTurn, Kind: "done", Sequence: 2, Payload: json.RawMessage(`{}`)}}), countEvents: done} + if err := AppendTurnEvents(t.Context(), f, batch); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadJournalTurn "+testTurn, "InsertEvents "+testTurn+" 2 1", "LoadEventSources "+testTurn+" 2", "CountEvents "+testTurn+" 1 2") + }) + t.Run("replay changes nothing", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: found(JournalTurn{Status: TurnCompleted, EventCount: 2}), matchEvents: returns(true)} + if err := AppendTurnEvents(t.Context(), f, batch); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadJournalTurn "+testTurn, "MatchEvents "+testTurn+" 2 1") + }) + t.Run("different replay conflicts", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: found(JournalTurn{Status: TurnInProgress, EventCount: 2}), matchEvents: returns(false)} + if err := AppendTurnEvents(t.Context(), f, batch); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + }) + t.Run("unadmitted batch writes nothing", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: found(JournalTurn{Status: TurnCompleted, EventCount: 1})} + if err := AppendTurnEvents(t.Context(), f, batch); !errors.Is(err, ErrTurnConflict) { + t.Fatal(err) + } + assertCalls(t, f, "LoadJournalTurn "+testTurn) + }) + t.Run("missing Turn", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: func() (JournalTurn, bool, error) { return JournalTurn{}, false, nil }} + if err := AppendTurnEvents(t.Context(), f, batch); !errors.Is(err, ErrNotFound) { + t.Fatal(err) + } + }) + t.Run("projection failure stops before counting", func(t *testing.T) { + f := &fakeTx{t: t, loadJournalTurn: found(running), insertEvents: done, loadEventSources: func() ([]Source, error) { return nil, errStorage }} + if err := AppendTurnEvents(t.Context(), f, batch); !errors.Is(err, errStorage) { + t.Fatal(err) + } + assertCalls(t, f, "LoadJournalTurn "+testTurn, "InsertEvents "+testTurn+" 2 1", "LoadEventSources "+testTurn+" 2") + }) +} + +// The terminal outcome goes after the last entry the caller read, whatever the +// journal limits. +func TestAppendTurnEvent(t *testing.T) { + f := &fakeTx{t: t, insertEvent: done, countEvents: done, loadEventSources: returns([]Source(nil))} + if err := AppendTurnEvent(t.Context(), f, testTurn, journalTurnEvents, event()); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "InsertEvent "+testTurn+" 65537 done", "CountEvents "+testTurn+" 1 2", "LoadEventSources "+testTurn+" 65537") +} + +// ExecutionOperations checks the identifiers, then the batch, then the +// Session: here every Session is missing. +func TestExecutionOperationsValidationOrder(t *testing.T) { + big := json.RawMessage(`{"text":"` + strings.Repeat("x", 400*1024) + `"}`) + oversized := []ExecutionEvent{{Kind: "delta", Payload: big}, {Kind: "delta", Payload: big}, {Kind: "delta", Payload: big}} + operations, err := NewExecutionOperations(&fakeExecutionStorage{t: t, withTurnJournal: func(context.Context, string, string, func(context.Context, TurnJournalTx) error) error { + return ErrNotFound + }}) + if err != nil { + t.Fatal(err) + } + for name, test := range map[string]struct { + tenant, turn string + first int32 + events []ExecutionEvent + want error + }{ + "missing Session": {testTenant, testTurn, 1, []ExecutionEvent{event()}, ErrNotFound}, + "invalid batch": {testTenant, testTurn, 0, []ExecutionEvent{event()}, ErrInvalidInput}, + "oversized batch": {testTenant, testTurn, 1, oversized, ErrEventLimit}, + "malformed Turn with an oversized batch": {testTenant, "turn", 1, oversized, ErrInvalidInput}, + "malformed Turn in a missing Session": {testTenant, "turn", 1, []ExecutionEvent{event()}, ErrInvalidInput}, + "malformed tenant with an oversized batch": {"tenant", testTurn, 1, oversized, ErrInvalidInput}, + } { + if err := operations.AppendTurnEvents(t.Context(), test.tenant, testSession, test.turn, test.first, test.events); !errors.Is(err, test.want) { + t.Errorf("%s: %v", name, err) + } + } +} diff --git a/services/core/internal/sessions/projection.go b/services/core/internal/sessions/projection.go new file mode 100644 index 000000000..0d1c155c3 --- /dev/null +++ b/services/core/internal/sessions/projection.go @@ -0,0 +1,118 @@ +package sessions + +import ( + "context" + "encoding/json" + "fmt" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" +) + +// Source is an observation the Session projects: an entry of a Turn's journal, +// an admitted input message, or a Turn's terminal outcome. +type Source struct { + Turn string + Kind string + // Sequence is the entry's journal position or the input's sequence; a + // terminal outcome projected without an entry has none. + Sequence int64 + Payload json.RawMessage + CreatedAt time.Time +} + +// ItemProjectionTx loads and stores the Session Items an observation projects, +// and journals their public changes. +type ItemProjectionTx interface { + // LoadItem reads what the Session holds for update's Item in the Turn. + LoadItem(ctx context.Context, turn string, update items.Update) (items.Stored, error) + // PutItem stores a decided Item change of the Turn, first observed at + // created, and returns the Item's output index, nil when it has none. + PutItem(ctx context.Context, turn string, created time.Time, change items.Change) (*int32, error) + // AppendChanges journals public changes in order. + AppendChanges(ctx context.Context, changes ...SessionChange) error +} + +// ProjectionTx loads and applies what projecting an observation reads and +// writes: Session Items, root Turn usage and Subagents. +type ProjectionTx interface { + ItemProjectionTx + SubagentProjectionTx + // PutTurnUsage replaces a root Turn's recorded usage with a measurement. + PutTurnUsage(ctx context.Context, turn string, usage v1.TokenUsage) error +} + +// InputProjectionTx projects an admitted input. +type InputProjectionTx interface { + ProjectionTx + // LoadInputSource reads the Session's admitted input at sequence. + LoadInputSource(ctx context.Context, sequence int64) (Source, error) +} + +// ProjectSource projects one observation under the Session lock. Subagent +// observations update the Session's Subagents, their Turns and Items and the +// root coordination Items. Any other observation replaces its root Turn's +// usage with the measurement it carries, then updates the Session Items it +// projects and journals their changes. +func ProjectSource(ctx context.Context, tx ProjectionTx, source Source) error { + switch source.Kind { + case proto.TypeSubagentIdentity: + return projectSubagentIdentity(ctx, tx, source) + case proto.TypeSubagentLifecycle: + return projectSubagentLifecycle(ctx, tx, source.Payload) + case proto.TypeSubagentTurn: + return projectSubagentTurn(ctx, tx, source.Payload) + case proto.TypeSubagentItem: + return projectSubagentItem(ctx, tx, source.Payload) + case proto.TypeSubagentCoordination: + return projectRootCoordination(ctx, tx, source) + } + if usage := MeasuredUsage(source.Kind, source.Payload); usage != nil { + if err := tx.PutTurnUsage(ctx, source.Turn, *usage); err != nil { + return err + } + } + updates, err := items.Project(source.Turn, source.Kind, source.Sequence, source.Payload) + if err != nil { + return fmt.Errorf("project execution item: %w", err) + } + for _, update := range updates { + if err := projectItem(ctx, tx, source, update); err != nil { + return err + } + } + return nil +} + +// ProjectInput projects the Session's admitted input at sequence: a message +// becomes its Session Items, and other inputs project nothing. +func ProjectInput(ctx context.Context, tx InputProjectionTx, sequence int64) error { + source, err := tx.LoadInputSource(ctx, sequence) + if err != nil { + return err + } + if source.Kind != "message" { + return nil + } + return ProjectSource(ctx, tx, source) +} + +// projectItem applies one Item update source projects: it stores the change +// items.Observe decides and journals the change's public events. +func projectItem(ctx context.Context, tx ItemProjectionTx, source Source, update items.Update) error { + stored, err := tx.LoadItem(ctx, source.Turn, update) + if err != nil { + return err + } + change, ok, err := items.Observe(source.Kind, update, stored) + if err != nil || !ok { + return err + } + index, err := tx.PutItem(ctx, source.Turn, source.CreatedAt, change) + if err != nil { + return err + } + return tx.AppendChanges(ctx, ItemChanges(change, index)...) +} diff --git a/services/core/internal/sessions/projection_test.go b/services/core/internal/sessions/projection_test.go new file mode 100644 index 000000000..cb0cb36b5 --- /dev/null +++ b/services/core/internal/sessions/projection_test.go @@ -0,0 +1,47 @@ +package sessions + +import ( + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" +) + +func TestProjectSource(t *testing.T) { + t.Run("usage replaces the Turn's usage", func(t *testing.T) { + var recorded []v1.TokenUsage + f := &fakeTx{t: t, putTurnUsage: func(usage v1.TokenUsage) error { recorded = append(recorded, usage); return nil }} + payload := json.RawMessage(`{"usage":{"tokens":{"input_tokens":5,"output_tokens":3,"cached_input_tokens":1,"reasoning_output_tokens":2,"total_tokens":8}}}`) + if err := ProjectSource(t.Context(), f, Source{Turn: testTurn, Kind: "done", Sequence: 4, Payload: payload}); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "PutTurnUsage "+testTurn) + want := v1.TokenUsage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8, InputTokensDetails: v1.InputTokenDetails{CachedTokens: 1}, OutputTokensDetails: v1.OutputTokenDetails{ReasoningTokens: 2}} + if !reflect.DeepEqual(recorded, []v1.TokenUsage{want}) { + t.Fatalf("usage %+v", recorded) + } + }) + t.Run("a new Item is stored and journaled", func(t *testing.T) { + id := items.Identity(testTurn, "message:m1") + index := int32(3) + var changes []SessionChange + f := &fakeTx{t: t, loadItem: returns(items.Stored{}), putItem: func(items.Change) (*int32, error) { return &index, nil }, appendChanges: collect(&changes)} + if err := ProjectSource(t.Context(), f, Source{Turn: testTurn, Kind: "output_message", Sequence: 2, Payload: json.RawMessage(`{"id":"m1","status":"completed","text":"hi"}`)}); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadItem "+testTurn+" "+id, "PutItem "+testTurn+" "+id, "AppendChanges agent.session.turn.item.added,agent.session.turn.content_part.added,agent.session.turn.output_text.delta,agent.session.turn.output_text.done,agent.session.turn.content_part.done,agent.session.turn.item.done") + if changes[0].Event.Item.ID != id || *changes[0].Event.OutputIndex != 3 { + t.Fatalf("added %+v", changes[0].Event) + } + }) +} + +func TestProjectInput(t *testing.T) { + f := &fakeTx{t: t, loadInputSource: returns(Source{Turn: testTurn, Kind: "function_call_output", Sequence: 7, Payload: json.RawMessage(`{}`)})} + if err := ProjectInput(t.Context(), f, 7); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadInputSource 7") +} diff --git a/services/core/internal/sessions/subagents.go b/services/core/internal/sessions/subagents.go index ef19b6537..ba982ab0b 100644 --- a/services/core/internal/sessions/subagents.go +++ b/services/core/internal/sessions/subagents.go @@ -2,8 +2,13 @@ package sessions import ( "context" + "encoding/json" + "reflect" + "strings" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) // SubagentReader reads the Subagents a visible Session observed, with their @@ -30,3 +35,486 @@ type SubagentReader interface { // Subagent's Turns. An empty Turn ID is ErrInvalidInput. ListSubagentTurnItems(ctx context.Context, tenantID, sessionID, subagentID, turnID, cursor string, limit int, ascending bool) (v1.ItemList, error) } + +// SubagentIdentity binds a native Subagent to the Session at the journal entry +// that first observed it. It is an execution binding, not a public Subagent. +type SubagentIdentity struct { + NativeID, ParentNativeID string + NativeCreatedAt int64 + FirstTurn string + FirstOrdinal int32 +} + +// NativeSubagent is what the Session holds for a native Subagent: its ID, +// whether it is public, when it was natively created (in seconds) and its +// lifecycle status as of LifecycleAtMS. +type NativeSubagent struct { + ID string + Visible bool + NativeCreatedAt int64 + Status string + LifecycleAtMS int64 +} + +// SubagentLifecycle is a Subagent's lifecycle status as of AtMS, and when it +// closed. +type SubagentLifecycle struct { + Status string + ClosedAtMS *int64 + AtMS int64 +} + +// ChildTurn is a Subagent's Turn. Its timestamps are native milliseconds and +// Usage is its measured usage, nil when unknown. +type ChildTurn struct { + ID, Subagent, NativeID, Status string + CreatedAtMS int64 + StartedAtMS, CompletedAtMS *int64 + Usage json.RawMessage +} + +// StoredChildItem is what the Session holds for a Subagent Item: its Turn, +// position and stored payload, and whether that payload equals the candidate +// it was loaded with. +type StoredChildItem struct { + Turn string + Position int32 + Payload json.RawMessage + Same bool +} + +// ChildItem is a Subagent Item to store at its Turn position. Output Items take +// the Turn's next output index when they are first stored. +type ChildItem struct { + ID, Subagent, Turn string + Position int32 + Payload json.RawMessage + Output bool +} + +// SubagentProjectionTx loads and applies what projecting Subagent observations +// reads and writes. +type SubagentProjectionTx interface { + // LoadRootAgent reads the Session's Agent ID. + LoadRootAgent(ctx context.Context) (string, error) + // LoadNativeSubagent reads the Session's native Subagent and reports whether + // the Session has it. + LoadNativeSubagent(ctx context.Context, native string) (NativeSubagent, bool, error) + // PutSubagentIdentity binds a native Subagent to the Session, or finds the + // same binding, and returns its ID. A different binding of the native ID, + // or a parent the Session does not know, is ErrIdempotencyConflict. + PutSubagentIdentity(ctx context.Context, identity SubagentIdentity) (string, error) + // PublishSubagent makes a Subagent public with its identity metadata. + PublishSubagent(ctx context.Context, id string, name, instructions *string) error + // LoadPublicSubagent reads a public Subagent of the Session. + LoadPublicSubagent(ctx context.Context, id string) (v1.Subagent, error) + // PutSubagentEffect records a native lifecycle effect once and reports + // whether this call recorded it. Another payload under the same effect ID + // is ErrIdempotencyConflict. + PutSubagentEffect(ctx context.Context, effect string, payload json.RawMessage) (bool, error) + // ApplySubagentLifecycle records a Subagent's lifecycle status. + ApplySubagentLifecycle(ctx context.Context, id string, lifecycle SubagentLifecycle) error + // LoadChildTurn reads a Turn of the Subagent and reports whether the + // Subagent has it. + LoadChildTurn(ctx context.Context, subagent, id string) (ChildTurn, bool, error) + // PutChildTurn stores a Subagent Turn. A Turn whose identity differs from + // the stored one is ErrIdempotencyConflict. + PutChildTurn(ctx context.Context, turn ChildTurn) error + // RecordTerminalActivity marks the Session's running managed compute + // active. + RecordTerminalActivity(ctx context.Context) error + // LoadChildItem reads an Item of the Subagent, compares its payload with + // candidate, and reports whether the Subagent has it. + LoadChildItem(ctx context.Context, subagent, id string, candidate json.RawMessage) (StoredChildItem, bool, error) + // PutChildItem stores a Subagent Item. + PutChildItem(ctx context.Context, item ChildItem) error +} + +// validNativeIdentity reports whether a native identifier is a non-empty, +// trimmed single line of at most 512 bytes. +func validNativeIdentity(value string) bool { + return value != "" && len(value) <= 512 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n") +} + +// visibleSubagent reads the public native Subagent an observation names; one +// the Session does not have or has not published is ErrInvalidInput. +func visibleSubagent(ctx context.Context, tx SubagentProjectionTx, native string) (NativeSubagent, error) { + child, found, err := tx.LoadNativeSubagent(ctx, native) + if err != nil { + return NativeSubagent{}, err + } + if !found || !child.Visible { + return NativeSubagent{}, ErrInvalidInput + } + return child, nil +} + +// recordSubagentChange journals the Subagent's public state as a +// agent.session.subagent. change. +func recordSubagentChange(ctx context.Context, tx ProjectionTx, id, kind string) error { + value, err := tx.LoadPublicSubagent(ctx, id) + if err != nil { + return err + } + return tx.AppendChanges(ctx, SessionChange{Event: v1.SessionEvent{Type: "agent.session.subagent." + kind, Subagent: &value}}) +} + +// projectSubagentIdentity binds a native Subagent to the Session at the entry +// that observed it and publishes it on its first observation. Identity +// metadata is immutable after its first public observation. +func projectSubagentIdentity(ctx context.Context, tx ProjectionTx, source Source) error { + var identity proto.SubagentIdentityPayload + if json.Unmarshal(source.Payload, &identity) != nil || identity.NativeCreatedAt <= 0 || identity.NativeID == identity.ParentNativeID { + return ErrInvalidInput + } + for _, value := range []string{identity.NativeID, identity.ParentNativeID, identity.ParentTurnID, identity.SourceItemID} { + if !validNativeIdentity(value) { + return ErrInvalidInput + } + } + id, err := tx.PutSubagentIdentity(ctx, SubagentIdentity{ + NativeID: identity.NativeID, ParentNativeID: identity.ParentNativeID, NativeCreatedAt: identity.NativeCreatedAt, + FirstTurn: source.Turn, FirstOrdinal: int32(source.Sequence), + }) + if err != nil { + return err + } + previous, found, err := tx.LoadNativeSubagent(ctx, identity.NativeID) + if err != nil { + return err + } + if !found { + return ErrNotFound + } + if previous.Visible { + return nil + } + if err := tx.PublishSubagent(ctx, id, identity.Name, identity.Instructions); err != nil { + return err + } + return recordSubagentChange(ctx, tx, id, "created") +} + +// projectSubagentLifecycle records a public Subagent's lifecycle effect once. +// An effect older than the recorded lifecycle is ErrIdempotencyConflict, and +// only a status change is applied and journaled. +func projectSubagentLifecycle(ctx context.Context, tx ProjectionTx, raw json.RawMessage) error { + var p proto.SubagentLifecyclePayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.EffectID) || p.OccurredAtMS <= 0 || (p.Status != "active" && p.Status != "closed") { + return ErrInvalidInput + } + child, err := visibleSubagent(ctx, tx, p.NativeID) + if err != nil { + return err + } + if p.OccurredAtMS < child.NativeCreatedAt*1000 { + return ErrInvalidInput + } + inserted, err := tx.PutSubagentEffect(ctx, p.EffectID, raw) + if err != nil || !inserted { + return err + } + if p.OccurredAtMS < child.LifecycleAtMS { + return ErrIdempotencyConflict + } + if p.Status == child.Status { + return nil + } + lifecycle := SubagentLifecycle{Status: p.Status, AtMS: p.OccurredAtMS} + if p.Status == "closed" { + closed := p.OccurredAtMS + lifecycle.ClosedAtMS = &closed + } + if err := tx.ApplySubagentLifecycle(ctx, child.ID, lifecycle); err != nil { + return err + } + return recordSubagentChange(ctx, tx, child.ID, p.Status) +} + +// projectSubagentTurn stores a public Subagent's Turn snapshot. Child Turns +// publish no Session changes: the Session stream carries root work, and child +// state is read through the Subagent routes. +func projectSubagentTurn(ctx context.Context, tx ProjectionTx, raw json.RawMessage) error { + var p proto.SubagentTurnPayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || p.CreatedAtMS <= 0 { + return ErrInvalidInput + } + if p.Status != TurnQueued && p.Status != TurnInProgress && p.Status != TurnWaiting && !TerminalStatus(p.Status) { + return ErrInvalidInput + } + if TerminalStatus(p.Status) != (p.CompletedAtMS != nil) { + return ErrInvalidInput + } + if (p.StartedAtMS != nil && *p.StartedAtMS < p.CreatedAtMS) || (p.CompletedAtMS != nil && (*p.CompletedAtMS < p.CreatedAtMS || (p.StartedAtMS != nil && *p.CompletedAtMS < *p.StartedAtMS))) { + return ErrInvalidInput + } + child, err := visibleSubagent(ctx, tx, p.NativeID) + if err != nil { + return err + } + next := ChildTurn{ + ID: items.Identity(child.ID, "turn:"+p.TurnID), Subagent: child.ID, NativeID: p.TurnID, Status: p.Status, + CreatedAtMS: p.CreatedAtMS, StartedAtMS: p.StartedAtMS, CompletedAtMS: p.CompletedAtMS, + } + old, found, err := tx.LoadChildTurn(ctx, child.ID, next.ID) + if err != nil { + return err + } + if p.Usage != nil { + value, _ := json.Marshal(p.Usage) + measured := MeasuredUsage("usage", value) + if measured == nil { + return ErrInvalidInput + } + next.Usage, _ = json.Marshal(measured) + } + put, err := decideChildTurn(old, found, next) + if err != nil || !put { + return err + } + if err := tx.PutChildTurn(ctx, next); err != nil { + return err + } + // Terminal replays are not stored again, so they never restart the managed + // idle timer. + if TerminalStatus(next.Status) { + return tx.RecordTerminalActivity(ctx) + } + return nil +} + +// decideChildTurn decides whether a Subagent Turn snapshot is stored over what +// the Session holds. Re-reading native history cannot reopen or change an +// ended child Turn: a snapshot that is not terminal is ignored, the same +// terminal snapshot is a replay, and a different one is +// ErrIdempotencyConflict. A status change ValidTransition rejects is +// ErrTurnConflict. +func decideChildTurn(old ChildTurn, found bool, next ChildTurn) (bool, error) { + if found && TerminalStatus(old.Status) { + if !TerminalStatus(next.Status) { + return false, nil + } + var previousUsage, nextUsage *v1.TokenUsage + _ = json.Unmarshal(old.Usage, &previousUsage) + _ = json.Unmarshal(next.Usage, &nextUsage) + if old.Status != next.Status || old.CompletedAtMS == nil || *old.CompletedAtMS != *next.CompletedAtMS || !reflect.DeepEqual(previousUsage, nextUsage) { + return false, ErrIdempotencyConflict + } + return false, nil + } + if found && old.Status != next.Status && !ValidTransition(old.Status, next.Status) { + return false, ErrTurnConflict + } + return true, nil +} + +// projectSubagentItem stores the Items a public Subagent's Item snapshot +// projects, at twice its native position plus their offset, in a Turn the +// Subagent has. +func projectSubagentItem(ctx context.Context, tx ProjectionTx, raw json.RawMessage) error { + var p proto.SubagentItemPayload + if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || !validNativeIdentity(p.ItemID) || p.Position < 0 || p.Position > 1073741823 { + return ErrInvalidInput + } + child, err := visibleSubagent(ctx, tx, p.NativeID) + if err != nil { + return err + } + turnID := items.Identity(child.ID, "turn:"+p.TurnID) + turn, found, err := tx.LoadChildTurn(ctx, child.ID, turnID) + if err != nil { + return err + } + if !found { + return ErrNotFound + } + projected, err := childItems(ctx, tx, turnID, p) + if err != nil { + return err + } + for offset, item := range projected { + if err := putChildItem(ctx, tx, turn, p.Position*2+int32(offset), item); err != nil { + return err + } + } + return nil +} + +// putChildItem stores a Subagent Item at its Turn position. A stored Item +// keeps its Turn and position, and only an in-progress one changes; an Item of +// an ended Turn is ErrTurnConflict. Child Items publish no Session changes: +// the Session stream carries root work, and child history is read through the +// Subagent routes. +func putChildItem(ctx context.Context, tx SubagentProjectionTx, turn ChildTurn, position int32, item v1.Item) error { + payload, err := item.MarshalStored() + if err != nil { + return err + } + old, found, err := tx.LoadChildItem(ctx, turn.Subagent, item.ID, payload) + if err != nil { + return err + } + if found { + if old.Turn != turn.ID || old.Position != position { + return ErrIdempotencyConflict + } + var previous v1.Item + if err := json.Unmarshal(old.Payload, &previous); err != nil { + return err + } + if old.Same { + return nil + } + if previous.Status != "in_progress" { + return ErrIdempotencyConflict + } + } + if TerminalStatus(turn.Status) { + return ErrTurnConflict + } + return tx.PutChildItem(ctx, ChildItem{ID: item.ID, Subagent: turn.Subagent, Turn: turn.ID, Position: position, Payload: payload, Output: item.Role != "user" && item.Type != "function_call_output"}) +} + +// childItems projects a Subagent Item snapshot to the complete Items it +// carries: a coordination operation, a message, a tool call or a reasoning +// summary. +func childItems(ctx context.Context, tx SubagentProjectionTx, turn string, p proto.SubagentItemPayload) ([]v1.Item, error) { + var result []v1.Item + switch p.Kind { + case proto.TypeSubagentCoordination: + var value proto.SubagentCoordinationPayload + if json.Unmarshal(p.Payload, &value) != nil || value.ID != p.ItemID || value.ActorID != p.NativeID { + return result, ErrInvalidInput + } + item, err := coordinationItem(ctx, tx, turn, value) + return []v1.Item{item}, err + case proto.TypeOutputMessage: + var message proto.OutputMessagePayload + if json.Unmarshal(p.Payload, &message) != nil || message.ID != p.ItemID || message.Text == nil { + return result, ErrInvalidInput + } + case proto.TypeToolCall: + var call proto.ToolCallPayload + if json.Unmarshal(p.Payload, &call) != nil || call.ID != p.ItemID || call.Observation == nil { + return result, ErrInvalidInput + } + case "message": + // Input projection validates and normalizes the existing public message shape. + case "reasoning": + var value struct { + Status string `json:"status"` + Summary []v1.SummaryText `json:"summary"` + } + if json.Unmarshal(p.Payload, &value) != nil { + return result, ErrInvalidInput + } + if value.Status != "" && value.Status != "in_progress" && value.Status != "completed" && value.Status != "incomplete" { + return result, ErrInvalidInput + } + for _, part := range value.Summary { + if part.Type != "summary_text" { + return result, ErrInvalidInput + } + } + return []v1.Item{{ID: items.Identity(turn, "reasoning:"+p.ItemID), TurnID: turn, Type: "reasoning", Status: value.Status, Summary: value.Summary}}, nil + default: + return result, ErrInvalidInput + } + updates, err := items.Project(turn, p.Kind, int64(p.Position), p.Payload) + if err != nil { + return result, err + } + if len(updates) < 1 || len(updates) > 2 { + return nil, ErrInvalidInput + } + for _, update := range updates { + if update.AppendText { + return nil, ErrInvalidInput + } + result = append(result, update.Item) + } + return result, nil +} + +// coordinationItem projects a native coordination operation to its Item in the +// Turn, resolving native actors and recipients to public Agent and Subagent +// IDs. An empty native ID is the Session's Agent. A failed operation may name a +// recipient the Session never observed, which keeps its native ID; any other +// unknown or unpublished Subagent is ErrNotFound. +func coordinationItem(ctx context.Context, tx SubagentProjectionTx, turn string, p proto.SubagentCoordinationPayload) (v1.Item, error) { + value := v1.Item{ID: items.Identity(turn, "coordination:"+p.ID), TurnID: turn, Type: p.Kind, Status: p.Status, Model: p.Model, ReasoningEffort: p.ReasoningEffort} + if !validNativeIdentity(p.ID) { + return value, ErrInvalidInput + } + if p.Status != "in_progress" && p.Status != "completed" && p.Status != "failed" && p.Status != "incomplete" && p.Kind != "agent_message" { + return value, ErrInvalidInput + } + resolve := func(native string, failedReference bool) (string, error) { + if native == "" { + return tx.LoadRootAgent(ctx) + } + child, found, err := tx.LoadNativeSubagent(ctx, native) + if err != nil { + return "", err + } + if !found { + if failedReference && validNativeIdentity(native) { + return native, nil + } + return "", ErrNotFound + } + if !child.Visible { + return "", ErrNotFound + } + return child.ID, nil + } + actor, err := resolve(p.ActorID, false) + if err != nil { + return value, err + } + if actor == "" { + return value, ErrInvalidInput + } + value.SenderAgentID = actor + if p.Text != nil { + value.Content = []v1.ItemContent{{Type: "output_text", Text: p.Text}} + } + switch p.Kind { + case "create_subagent_call": + value.AgentID = actor + case "wait_for_subagents_call": + value.RecipientAgentIDs = []string{} + for _, recipient := range p.Recipients { + id, err := resolve(recipient, p.Status == "failed") + if err != nil { + return value, err + } + value.RecipientAgentIDs = append(value.RecipientAgentIDs, id) + } + case "send_subagent_input_call", "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call", "agent_message": + if len(p.Recipients) != 1 { + return value, ErrInvalidInput + } + value.RecipientAgentID, err = resolve(p.Recipients[0], p.Status == "failed") + if err != nil { + return value, err + } + default: + return value, ErrInvalidInput + } + return value, nil +} + +// projectRootCoordination projects the root Agent's coordination operation to +// its Session Item and journals the Item's changes. +func projectRootCoordination(ctx context.Context, tx ProjectionTx, source Source) error { + var p proto.SubagentCoordinationPayload + if json.Unmarshal(source.Payload, &p) != nil || p.ActorID != "" { + return ErrInvalidInput + } + value, err := coordinationItem(ctx, tx, source.Turn, p) + if err != nil { + return err + } + return projectItem(ctx, tx, source, items.Update{Item: value}) +} diff --git a/services/core/internal/sessions/subagents_test.go b/services/core/internal/sessions/subagents_test.go new file mode 100644 index 000000000..bf9788ba5 --- /dev/null +++ b/services/core/internal/sessions/subagents_test.go @@ -0,0 +1,169 @@ +package sessions + +import ( + "encoding/json" + "errors" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" +) + +const testSubagent = "5b2d7c61-0d6e-4f0a-9d7e-2f1b3c4d5e6f" + +func millis(value int64) *int64 { return &value } + +func TestDecideChildTurn(t *testing.T) { + usage := json.RawMessage(`{"input_tokens":1,"output_tokens":1,"total_tokens":2,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}`) + ended := ChildTurn{Status: TurnCompleted, CompletedAtMS: millis(2000), Usage: usage} + for name, test := range map[string]struct { + old ChildTurn + found bool + next ChildTurn + put bool + want error + }{ + "new": {ChildTurn{}, false, ChildTurn{Status: TurnInProgress}, true, nil}, + "started": {ChildTurn{Status: TurnQueued}, true, ChildTurn{Status: TurnInProgress}, true, nil}, + "same status": {ChildTurn{Status: TurnInProgress}, true, ChildTurn{Status: TurnInProgress}, true, nil}, + "ended": {ChildTurn{Status: TurnInProgress}, true, ended, true, nil}, + "reopened": {ChildTurn{Status: TurnInProgress}, true, ChildTurn{Status: TurnQueued}, false, ErrTurnConflict}, + "ended then active": {ended, true, ChildTurn{Status: TurnInProgress}, false, nil}, + "terminal replay": {ended, true, ended, false, nil}, + "other status": {ended, true, ChildTurn{Status: TurnFailed, CompletedAtMS: millis(2000), Usage: usage}, false, ErrIdempotencyConflict}, + "other completion": {ended, true, ChildTurn{Status: TurnCompleted, CompletedAtMS: millis(2001), Usage: usage}, false, ErrIdempotencyConflict}, + "other usage": {ended, true, ChildTurn{Status: TurnCompleted, CompletedAtMS: millis(2000)}, false, ErrIdempotencyConflict}, + "reformatted replay": {ended, true, ChildTurn{Status: TurnCompleted, CompletedAtMS: millis(2000), Usage: json.RawMessage(` ` + string(usage))}, false, nil}, + } { + put, err := decideChildTurn(test.old, test.found, test.next) + if put != test.put || !errors.Is(err, test.want) || (test.want == nil && err != nil) { + t.Errorf("%s: put %v, %v", name, put, err) + } + } +} + +func payload(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +// native is a fake LoadNativeSubagent that finds child. +func native(child NativeSubagent) func(string) (NativeSubagent, bool, error) { + return func(string) (NativeSubagent, bool, error) { return child, true, nil } +} + +func TestProjectSubagentIdentity(t *testing.T) { + name := "reviewer" + identity := Source{Turn: testTurn, Kind: proto.TypeSubagentIdentity, Sequence: 3, Payload: payload(t, proto.SubagentIdentityPayload{ + NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 1700000000, ParentTurnID: "turn", SourceItemID: "item", Name: &name, + })} + t.Run("first observation publishes", func(t *testing.T) { + var bound []SubagentIdentity + var changes []SessionChange + f := &fakeTx{t: t, + putSubagentIdentity: func(identity SubagentIdentity) (string, error) { + bound = append(bound, identity) + return testSubagent, nil + }, + loadNativeSubagent: native(NativeSubagent{ID: testSubagent}), + publishSubagent: done, + loadPublicSubagent: returns(v1.Subagent{ID: testSubagent}), + appendChanges: collect(&changes), + } + if err := ProjectSource(t.Context(), f, identity); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "PutSubagentIdentity child", "LoadNativeSubagent child", "PublishSubagent "+testSubagent, "LoadPublicSubagent "+testSubagent, "AppendChanges agent.session.subagent.created") + want := SubagentIdentity{NativeID: "child", ParentNativeID: "root", NativeCreatedAt: 1700000000, FirstTurn: testTurn, FirstOrdinal: 3} + if len(bound) != 1 || bound[0] != want || changes[0].Event.Subagent.ID != testSubagent { + t.Fatalf("bound %+v, changes %+v", bound, changes) + } + }) + t.Run("published Subagent keeps its metadata", func(t *testing.T) { + f := &fakeTx{t: t, putSubagentIdentity: func(SubagentIdentity) (string, error) { return testSubagent, nil }, loadNativeSubagent: native(NativeSubagent{ID: testSubagent, Visible: true})} + if err := ProjectSource(t.Context(), f, identity); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "PutSubagentIdentity child", "LoadNativeSubagent child") + }) + t.Run("self parent", func(t *testing.T) { + f := &fakeTx{t: t} + self := Source{Turn: testTurn, Kind: proto.TypeSubagentIdentity, Payload: payload(t, proto.SubagentIdentityPayload{NativeID: "child", ParentNativeID: "child", NativeCreatedAt: 1, ParentTurnID: "turn", SourceItemID: "item"})} + if err := ProjectSource(t.Context(), f, self); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + }) +} + +func TestProjectSubagentLifecycle(t *testing.T) { + active := NativeSubagent{ID: testSubagent, Visible: true, NativeCreatedAt: 1, Status: "active", LifecycleAtMS: 1500} + closing := Source{Kind: proto.TypeSubagentLifecycle, Payload: payload(t, proto.SubagentLifecyclePayload{NativeID: "child", EffectID: "effect", Status: "closed", OccurredAtMS: 2000})} + t.Run("status change applies and journals", func(t *testing.T) { + var applied []SubagentLifecycle + var changes []SessionChange + f := &fakeTx{t: t, loadNativeSubagent: native(active), putSubagentEffect: returns(true), + applySubagentLifecycle: func(lifecycle SubagentLifecycle) error { applied = append(applied, lifecycle); return nil }, + loadPublicSubagent: returns(v1.Subagent{ID: testSubagent}), appendChanges: collect(&changes)} + if err := ProjectSource(t.Context(), f, closing); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadNativeSubagent child", "PutSubagentEffect effect", "ApplySubagentLifecycle "+testSubagent+" closed", "LoadPublicSubagent "+testSubagent, "AppendChanges agent.session.subagent.closed") + if len(applied) != 1 || applied[0].AtMS != 2000 || applied[0].ClosedAtMS == nil || *applied[0].ClosedAtMS != 2000 { + t.Fatalf("applied %+v", applied) + } + }) + t.Run("recorded effect is a replay", func(t *testing.T) { + f := &fakeTx{t: t, loadNativeSubagent: native(active), putSubagentEffect: returns(false)} + if err := ProjectSource(t.Context(), f, closing); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadNativeSubagent child", "PutSubagentEffect effect") + }) + t.Run("older effect conflicts", func(t *testing.T) { + later := active + later.LifecycleAtMS = 2500 + f := &fakeTx{t: t, loadNativeSubagent: native(later), putSubagentEffect: returns(true)} + if err := ProjectSource(t.Context(), f, closing); !errors.Is(err, ErrIdempotencyConflict) { + t.Fatal(err) + } + }) + t.Run("unpublished Subagent", func(t *testing.T) { + f := &fakeTx{t: t, loadNativeSubagent: native(NativeSubagent{ID: testSubagent})} + if err := ProjectSource(t.Context(), f, closing); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + }) +} + +func TestProjectSubagentTurn(t *testing.T) { + child := NativeSubagent{ID: testSubagent, Visible: true} + id := items.Identity(testSubagent, "turn:native-turn") + completed := Source{Kind: proto.TypeSubagentTurn, Payload: payload(t, proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn", Status: TurnCompleted, CreatedAtMS: 1000, CompletedAtMS: millis(2000)})} + t.Run("an ending records activity", func(t *testing.T) { + f := &fakeTx{t: t, loadNativeSubagent: native(child), loadChildTurn: func() (ChildTurn, bool, error) { return ChildTurn{}, false, nil }, putChildTurn: func(ChildTurn) error { return nil }, recordTerminalActivity: done} + if err := ProjectSource(t.Context(), f, completed); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadNativeSubagent child", "LoadChildTurn "+testSubagent+" "+id, "PutChildTurn "+id+" completed", "RecordTerminalActivity") + }) + t.Run("a terminal replay records nothing", func(t *testing.T) { + stored := ChildTurn{ID: id, Subagent: testSubagent, NativeID: "native-turn", Status: TurnCompleted, CreatedAtMS: 1000, CompletedAtMS: millis(2000)} + f := &fakeTx{t: t, loadNativeSubagent: native(child), loadChildTurn: func() (ChildTurn, bool, error) { return stored, true, nil }} + if err := ProjectSource(t.Context(), f, completed); err != nil { + t.Fatal(err) + } + assertCalls(t, f, "LoadNativeSubagent child", "LoadChildTurn "+testSubagent+" "+id) + }) + t.Run("a terminal snapshot needs its completion", func(t *testing.T) { + f := &fakeTx{t: t} + incomplete := Source{Kind: proto.TypeSubagentTurn, Payload: payload(t, proto.SubagentTurnPayload{NativeID: "child", TurnID: "native-turn", Status: TurnCompleted, CreatedAtMS: 1000})} + if err := ProjectSource(t.Context(), f, incomplete); !errors.Is(err, ErrInvalidInput) { + t.Fatal(err) + } + }) +} diff --git a/services/core/internal/sessions/transaction_test.go b/services/core/internal/sessions/transaction_test.go index b1c29e1f8..0a365afce 100644 --- a/services/core/internal/sessions/transaction_test.go +++ b/services/core/internal/sessions/transaction_test.go @@ -4,9 +4,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "testing" "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" ) // fakeTx is a strict Session transaction. Each method records its call, with @@ -34,6 +38,29 @@ type fakeTx struct { loadPendingFileWrite func() (bool, error) loadBoundDevice func() (bool, error) insertEnvironmentDevice func() error + + loadJournalTurn func() (JournalTurn, bool, error) + matchEvents func() (bool, error) + insertEvents func() error + insertEvent func() error + countEvents func() error + loadEventSources func() ([]Source, error) + loadInputSource func() (Source, error) + putTurnUsage func(v1.TokenUsage) error + loadItem func() (items.Stored, error) + putItem func(items.Change) (*int32, error) + loadRootAgent func() (string, error) + loadNativeSubagent func(native string) (NativeSubagent, bool, error) + putSubagentIdentity func(SubagentIdentity) (string, error) + publishSubagent func() error + loadPublicSubagent func() (v1.Subagent, error) + putSubagentEffect func() (bool, error) + applySubagentLifecycle func(SubagentLifecycle) error + loadChildTurn func() (ChildTurn, bool, error) + putChildTurn func(ChildTurn) error + recordTerminalActivity func() error + loadChildItem func() (StoredChildItem, bool, error) + putChildItem func(ChildItem) error } var ( @@ -41,6 +68,9 @@ var ( _ InputStartTx = (*fakeTx)(nil) _ ComputeAdmissionTx = (*fakeTx)(nil) _ EnvironmentDeviceTx = (*fakeTx)(nil) + _ TurnJournalTx = (*fakeTx)(nil) + _ TurnEventTx = (*fakeTx)(nil) + _ InputProjectionTx = (*fakeTx)(nil) ) func (f *fakeTx) record(name string, set bool, detail ...string) { @@ -136,6 +166,116 @@ func (f *fakeTx) InsertEnvironmentDevice(_ context.Context, device ExecutionDevi return f.insertEnvironmentDevice() } +func (f *fakeTx) LoadJournalTurn(_ context.Context, turn string) (JournalTurn, bool, error) { + f.record("LoadJournalTurn", f.loadJournalTurn != nil, turn) + return f.loadJournalTurn() +} + +func (f *fakeTx) MatchEvents(_ context.Context, turn string, first int32, events []ExecutionEvent) (bool, error) { + f.record("MatchEvents", f.matchEvents != nil, turn, fmt.Sprint(first), fmt.Sprint(len(events))) + return f.matchEvents() +} + +func (f *fakeTx) InsertEvents(_ context.Context, turn string, first int32, events []ExecutionEvent) error { + f.record("InsertEvents", f.insertEvents != nil, turn, fmt.Sprint(first), fmt.Sprint(len(events))) + return f.insertEvents() +} + +func (f *fakeTx) InsertEvent(_ context.Context, turn string, ordinal int32, event ExecutionEvent) error { + f.record("InsertEvent", f.insertEvent != nil, turn, fmt.Sprint(ordinal), event.Kind) + return f.insertEvent() +} + +func (f *fakeTx) CountEvents(_ context.Context, turn string, count int32, size int64) error { + f.record("CountEvents", f.countEvents != nil, turn, fmt.Sprint(count), fmt.Sprint(size)) + return f.countEvents() +} + +func (f *fakeTx) LoadEventSources(_ context.Context, turn string, first int32) ([]Source, error) { + f.record("LoadEventSources", f.loadEventSources != nil, turn, fmt.Sprint(first)) + return f.loadEventSources() +} + +func (f *fakeTx) LoadInputSource(_ context.Context, sequence int64) (Source, error) { + f.record("LoadInputSource", f.loadInputSource != nil, fmt.Sprint(sequence)) + return f.loadInputSource() +} + +func (f *fakeTx) PutTurnUsage(_ context.Context, turn string, usage v1.TokenUsage) error { + f.record("PutTurnUsage", f.putTurnUsage != nil, turn) + return f.putTurnUsage(usage) +} + +func (f *fakeTx) LoadItem(_ context.Context, turn string, update items.Update) (items.Stored, error) { + f.record("LoadItem", f.loadItem != nil, turn, update.Item.ID) + return f.loadItem() +} + +func (f *fakeTx) PutItem(_ context.Context, turn string, _ time.Time, change items.Change) (*int32, error) { + f.record("PutItem", f.putItem != nil, turn, change.Item.ID) + return f.putItem(change) +} + +func (f *fakeTx) LoadRootAgent(context.Context) (string, error) { + f.record("LoadRootAgent", f.loadRootAgent != nil) + return f.loadRootAgent() +} + +func (f *fakeTx) LoadNativeSubagent(_ context.Context, native string) (NativeSubagent, bool, error) { + f.record("LoadNativeSubagent", f.loadNativeSubagent != nil, native) + return f.loadNativeSubagent(native) +} + +func (f *fakeTx) PutSubagentIdentity(_ context.Context, identity SubagentIdentity) (string, error) { + f.record("PutSubagentIdentity", f.putSubagentIdentity != nil, identity.NativeID) + return f.putSubagentIdentity(identity) +} + +func (f *fakeTx) PublishSubagent(_ context.Context, id string, _, _ *string) error { + f.record("PublishSubagent", f.publishSubagent != nil, id) + return f.publishSubagent() +} + +func (f *fakeTx) LoadPublicSubagent(_ context.Context, id string) (v1.Subagent, error) { + f.record("LoadPublicSubagent", f.loadPublicSubagent != nil, id) + return f.loadPublicSubagent() +} + +func (f *fakeTx) PutSubagentEffect(_ context.Context, effect string, _ json.RawMessage) (bool, error) { + f.record("PutSubagentEffect", f.putSubagentEffect != nil, effect) + return f.putSubagentEffect() +} + +func (f *fakeTx) ApplySubagentLifecycle(_ context.Context, id string, lifecycle SubagentLifecycle) error { + f.record("ApplySubagentLifecycle", f.applySubagentLifecycle != nil, id, lifecycle.Status) + return f.applySubagentLifecycle(lifecycle) +} + +func (f *fakeTx) LoadChildTurn(_ context.Context, subagent, id string) (ChildTurn, bool, error) { + f.record("LoadChildTurn", f.loadChildTurn != nil, subagent, id) + return f.loadChildTurn() +} + +func (f *fakeTx) PutChildTurn(_ context.Context, turn ChildTurn) error { + f.record("PutChildTurn", f.putChildTurn != nil, turn.ID, turn.Status) + return f.putChildTurn(turn) +} + +func (f *fakeTx) RecordTerminalActivity(context.Context) error { + f.record("RecordTerminalActivity", f.recordTerminalActivity != nil) + return f.recordTerminalActivity() +} + +func (f *fakeTx) LoadChildItem(_ context.Context, subagent, id string, _ json.RawMessage) (StoredChildItem, bool, error) { + f.record("LoadChildItem", f.loadChildItem != nil, subagent, id) + return f.loadChildItem() +} + +func (f *fakeTx) PutChildItem(_ context.Context, item ChildItem) error { + f.record("PutChildItem", f.putChildItem != nil, item.ID, fmt.Sprint(item.Position)) + return f.putChildItem(item) +} + // returns is a fake method that reads value. func returns[T any](value T) func() (T, error) { return func() (T, error) { return value, nil } diff --git a/services/core/internal/sessions/turn.go b/services/core/internal/sessions/turn.go index 0455e4d7b..c7ad7e11c 100644 --- a/services/core/internal/sessions/turn.go +++ b/services/core/internal/sessions/turn.go @@ -37,6 +37,22 @@ func TerminalStatus(status string) bool { return status == TurnCompleted || status == TurnFailed || status == TurnCancelled } +// ValidTransition reports whether a Turn may move from one status to another: +// a queued Turn starts, fails or is cancelled, and an in-progress or waiting +// Turn moves between the two or ends. An ended Turn never moves. +func ValidTransition(from, to string) bool { + switch from { + case TurnQueued: + return to == TurnInProgress || to == TurnFailed || to == TurnCancelled + case TurnInProgress: + return to == TurnWaiting || TerminalStatus(to) + case TurnWaiting: + return to == TurnInProgress || TerminalStatus(to) + default: + return false + } +} + type TurnTransition struct { ExpectedStatus string Status string diff --git a/services/core/internal/sessions/turn_changes_test.go b/services/core/internal/sessions/turn_changes_test.go index 8b25b172c..3536e39e2 100644 --- a/services/core/internal/sessions/turn_changes_test.go +++ b/services/core/internal/sessions/turn_changes_test.go @@ -37,6 +37,32 @@ func TestTerminalStatus(t *testing.T) { } } +func TestValidTransition(t *testing.T) { + for _, test := range []struct { + from, to string + want bool + }{ + {TurnQueued, TurnInProgress, true}, + {TurnQueued, TurnFailed, true}, + {TurnQueued, TurnCancelled, true}, + {TurnQueued, TurnWaiting, false}, + {TurnQueued, TurnCompleted, false}, + {TurnInProgress, TurnWaiting, true}, + {TurnInProgress, TurnCompleted, true}, + {TurnInProgress, TurnQueued, false}, + {TurnWaiting, TurnInProgress, true}, + {TurnWaiting, TurnCancelled, true}, + {TurnWaiting, TurnQueued, false}, + {TurnCompleted, TurnInProgress, false}, + {TurnFailed, TurnCompleted, false}, + {TurnCancelled, TurnCancelled, false}, + } { + if got := ValidTransition(test.from, test.to); got != test.want { + t.Errorf("%s -> %s: %v", test.from, test.to, got) + } + } +} + func TestTurnChangesReportTheNewStatus(t *testing.T) { for _, test := range []struct { status string diff --git a/services/core/internal/store/admin_session_archive_test.go b/services/core/internal/store/admin_session_archive_test.go index 10727b4cd..b8da724b5 100644 --- a/services/core/internal/store/admin_session_archive_test.go +++ b/services/core/internal/store/admin_session_archive_test.go @@ -136,7 +136,7 @@ func TestManagedSessionArchiveRetainsHistoryAndSettledResources(t *testing.T) { } input := submitMessage(t, s, tenant, session.ID, "completed") transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - if err := w.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained"}`)}}); err != nil { + if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained"}`)}}); err != nil { t.Fatal(err) } body := []byte("retained artifact") diff --git a/services/core/internal/store/command_output_test.go b/services/core/internal/store/command_output_test.go index ead53987f..8ae0b4ba5 100644 --- a/services/core/internal/store/command_output_test.go +++ b/services/core/internal/store/command_output_test.go @@ -16,7 +16,7 @@ import ( func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) - defer pool.Close() + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "command-output"}) if err != nil { @@ -38,13 +38,13 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { event("command_output", `{"id":"cmd","delta":"same\n"}`), } for range 2 { - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } } before, _ := s.SessionEventCursor(ctx, tenant, session.ID) // A bad command reference rolls back preceding valid fragments and their events. - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, []sessions.ExecutionEvent{ + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, []sessions.ExecutionEvent{ event("command_output", `{"id":"cmd","delta":"rollback"}`), event("command_output", `{"id":"unknown","delta":"orphan"}`), }); err == nil { @@ -67,7 +67,7 @@ func TestCommandOutputCommitsFragmentsSnapshotsAndRecovery(t *testing.T) { event("tool_call", `{"id":"partial","stage":"before","observation":{"kind":"command","command":"wait","status":"in_progress"}}`), event("command_output", `{"id":"partial","delta":"已观察\n"}`), } - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, final); err != nil { t.Fatal(err) } if _, err := s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{}`), "", input.Sequence); err != nil { diff --git a/services/core/internal/store/execution_test.go b/services/core/internal/store/execution_test.go index b036e3bf2..2c8e5a92e 100644 --- a/services/core/internal/store/execution_test.go +++ b/services/core/internal/store/execution_test.go @@ -72,7 +72,7 @@ func executionOwnerPID(t *testing.T, pool *pgxpool.Pool) int32 { func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { s, pool := testStore(t) writer := executionWriter(t, s) - functions := sessionExecution(t, writer.lease) + operations := sessionExecution(t, writer.lease) tenant, active := newTurnSession(t, s) input := submitMessage(t, s, tenant, active.ID, "active") transition(t, writer, tenant, active.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) @@ -95,7 +95,7 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { waitInput := submitMessage(t, s, tenant, waiting.ID, "waiting") transition(t, writer, tenant, waiting.ID, waitInput.TurnID, sessions.TurnQueued, sessions.TurnInProgress) call := functionCallFixture("saved") - if err = functions.RecordFunctionCall(t.Context(), tenant, waiting.ID, waitInput.TurnID, call); err != nil { + if err = operations.RecordFunctionCall(t.Context(), tenant, waiting.ID, waitInput.TurnID, call); err != nil { t.Fatal(err) } if err = SubmitFixtureFunctionResult(t.Context(), s, tenant, waiting.ID, waitInput.TurnID, call.CallID, json.RawMessage(`{"success":true,"output":"saved"}`)); err != nil { @@ -124,9 +124,9 @@ func TestExecutionLeaseLossFencesAllLifecycleWrites(t *testing.T) { mustReject("binding", writer.BindSessionDevice(t.Context(), tenant, queued.ID, host.ID)) _, err = writer.TransitionTurn(t.Context(), tenant, queued.ID, pending.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) mustReject("claim", err) - mustReject("journal", writer.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"stale"}`)}})) - mustReject("callback", functions.RecordFunctionCall(t.Context(), tenant, active.ID, input.TurnID, functionCallFixture("late"))) - mustReject("receipt", functions.ConfirmFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID)) + mustReject("journal", operations.AppendTurnEvents(t.Context(), tenant, active.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"stale"}`)}})) + mustReject("callback", operations.RecordFunctionCall(t.Context(), tenant, active.ID, input.TurnID, functionCallFixture("late"))) + mustReject("receipt", operations.ConfirmFunctionResult(t.Context(), tenant, waiting.ID, waitInput.TurnID, call.CallID)) _, err = writer.CompleteExecution(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{"done":{"content":"stale"}}`), "stale-native", input.Sequence) mustReject("completion", err) _, err = writer.TransitionTurn(t.Context(), tenant, active.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnFailed}) @@ -196,6 +196,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { if backend(writer) != owner || backend(s) == owner { t.Fatal("execution transactions do not run on the leased connection") } + journal := sessionExecution(t, writer.lease) type work struct{ tenant, session, turn string } tasks := make([]work, 4) for i := range tasks { @@ -208,7 +209,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { group.Go(func() { _, err := writer.TransitionTurn(t.Context(), task.tenant, task.session, task.turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}) if err == nil { - err = writer.AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"accepted"}`)}}) + err = journal.AppendTurnEvents(t.Context(), task.tenant, task.session, task.turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"accepted"}`)}}) } results <- err }) @@ -249,7 +250,7 @@ func TestExecutionWriterSerializesWritesOnItsLease(t *testing.T) { func TestPooledStoreHasNoExecutionAuthority(t *testing.T) { s, _ := testStore(t) tenant, session := newTurnSession(t, s) - input := submitMessage(t, s, tenant, session.ID, "start") + submitMessage(t, s, tenant, session.ID, "start") before, err := s.GetSession(t.Context(), tenant, session.ID) if err != nil { t.Fatal(err) @@ -260,14 +261,11 @@ func TestPooledStoreHasNoExecutionAuthority(t *testing.T) { } _, archiveErr := s.ArchiveManagedSession(t.Context(), tenant, session.ID, 0) _, expiryErr := s.ExpireEnvironmentInputs(t.Context()) - subagent := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"visible"}`)}, subagentIdentityEvent("child", "root", 100)} for name, err := range map[string]error{ "ownership check": s.checkExecutionOwnership(t.Context()), "archive": archiveErr, "input expiry": expiryErr, "reconciliation": s.ReconcileEnvironmentConnections(t.Context()), - "mixed batch": s.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, subagent), - "subagent only": s.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, subagent[1:]), "connection change": s.ReplaceEnvironmentConnection(t.Context(), tenant, uuid.NewString(), uuid.NewString()), } { if !errors.Is(err, ErrExecutionAuthority) { @@ -281,7 +279,4 @@ func TestPooledStoreHasNoExecutionAuthority(t *testing.T) { if next, err := s.SessionEventCursor(t.Context(), tenant, session.ID); err != nil || next != cursor { t.Fatal("rejected execution operation published events", next, err) } - if events, err := s.ListTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 0, 100); err != nil || len(events) != 0 { - t.Fatal("rejected batch reached the journal", events, err) - } } diff --git a/services/core/internal/store/function_item_events_test.go b/services/core/internal/store/function_item_events_test.go index 7cb36c70d..d6a0b3e4e 100644 --- a/services/core/internal/store/function_item_events_test.go +++ b/services/core/internal/store/function_item_events_test.go @@ -2,10 +2,11 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "reflect" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) func TestFunctionResultEventsAreInputs(t *testing.T) { @@ -17,7 +18,7 @@ func TestFunctionResultEventsAreInputs(t *testing.T) { {Kind: "tool_call", Payload: json.RawMessage(`{"id":"call","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","arguments":{},"content":[{"type":"input_text","text":"result"}]}}`)}, {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"answer"}`)}, } - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, events); err != nil { + if err := sessionExecution(t, executionWriter(t, s).lease).AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, events); err != nil { t.Fatal(err) } changes, err := s.ListSessionEvents(t.Context(), tenant, session.ID, 0) @@ -76,7 +77,7 @@ func TestFunctionResultItemsRetainSubmittedFields(t *testing.T) { t.Fatal(err) } event := sessions.ExecutionEvent{Kind: "tool_call", Payload: json.RawMessage(`{"id":"call","stage":"after","observation":{"status":"completed","kind":"function","name":"lookup","arguments":{},"content":[{"type":"input_text","text":"normalized"}]}}`)} - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, []sessions.ExecutionEvent{event}); err != nil { + if err := functions.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, []sessions.ExecutionEvent{event}); err != nil { t.Fatal(err) } assertFields := func(value any) { diff --git a/services/core/internal/store/item_order_migration_test.go b/services/core/internal/store/item_order_migration_test.go index 16d25cac7..2d5691eef 100644 --- a/services/core/internal/store/item_order_migration_test.go +++ b/services/core/internal/store/item_order_migration_test.go @@ -153,7 +153,7 @@ func TestItemOrderMigrationPreservesIndexedHistory(t *testing.T) { if _, err = s.TransitionTurn(ctx, tenant, session, turn, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if err = s.AppendTurnEvents(ctx, tenant, session, turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { + if err = executionOwner(t, fixtureDB{pool: migratedPool}, s).Sessions.AppendTurnEvents(ctx, tenant, session, turn, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-upgrade","delta":"continued"}`)}}); err != nil { t.Fatal(err) } addedID := items.Identity(turn, "message:after-upgrade") diff --git a/services/core/internal/store/item_order_test.go b/services/core/internal/store/item_order_test.go index 7f18caa6f..773d36aae 100644 --- a/services/core/internal/store/item_order_test.go +++ b/services/core/internal/store/item_order_test.go @@ -17,6 +17,7 @@ import ( func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "ordered"}) if err != nil { @@ -47,7 +48,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) want = append(want, items.Identity(input.TurnID, "message:"+key)) } for range 2 { - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } } @@ -62,7 +63,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) completion, _ := json.Marshal(map[string]string{"id": keys[0], "status": "completed", "text": "final"}) batch = []sessions.ExecutionEvent{{Kind: "output_message", Payload: completion}, {Kind: "delta", Payload: json.RawMessage(`{"item_id":"last","delta":"partial"}`)}} for range 2 { - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, batch); err != nil { t.Fatal(err) } } @@ -71,10 +72,10 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) {Kind: "delta", Payload: json.RawMessage(`{"item_id":"rollback","delta":"discard"}`)}, {Kind: "output_message", Payload: json.RawMessage(`{"id":"invalid","status":"invalid"}`)}, } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, bad); err == nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, bad); err == nil { t.Fatal("invalid batch accepted") } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-rollback","delta":"retained"}`)}}); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 6, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"after-rollback","delta":"retained"}`)}}); err != nil { t.Fatal(err) } want = append(want, items.Identity(input.TurnID, "message:after-rollback")) @@ -138,7 +139,7 @@ func TestItemObservationOrderSurvivesTiesUpdatesRetriesAndRecovery(t *testing.T) if _, err = s.TransitionTurn(ctx, tenant, session.ID, next.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, next.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"new","delta":"new turn"}`)}}); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, next.TurnID, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"new","delta":"new turn"}`)}}); err != nil { t.Fatal(err) } var index int diff --git a/services/core/internal/store/item_projection.go b/services/core/internal/store/item_projection.go deleted file mode 100644 index d4fa2f857..000000000 --- a/services/core/internal/store/item_projection.go +++ /dev/null @@ -1,66 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" -) - -func projectItemSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, kind string, sequence int64, raw json.RawMessage, created pgtype.Timestamptz) error { - updates, err := items.Project(uuid.UUID(turn.Bytes).String(), kind, sequence, raw) - if err != nil { - return fmt.Errorf("project execution item: %w", err) - } - for _, update := range updates { - stored, err := sessionpg.LoadItem(ctx, q, session, turn, update) - if err != nil { - return err - } - change, ok, err := items.Observe(kind, update, stored) - if err != nil { - return err - } - if !ok { - continue - } - index, err := sessionpg.PutItem(ctx, q, session, turn, created, change) - if err != nil { - return err - } - if err := sessionpg.AppendChanges(ctx, q, session, sessions.ItemChanges(change, index)...); err != nil { - return err - } - } - return nil -} - -func indexInput(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, sequence int64) error { - row, err := q.ItemInputSource(ctx, sqlc.ItemInputSourceParams{SessionID: session, Sequence: sequence}) - if err != nil { - return err - } - if row.Kind != "message" { - return nil - } - return projectSource(ctx, q, session, row.TurnID, row.Kind, row.Sequence, row.Payload, row.CreatedAt) -} - -func indexEvents(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, first int32) error { - rows, err := q.ItemEventSources(ctx, sqlc.ItemEventSourcesParams{SessionID: session, TurnID: turn, Ordinal: first}) - if err != nil { - return err - } - for _, row := range rows { - if err = projectSource(ctx, q, session, turn, row.Kind, int64(row.Ordinal), row.Payload, row.CreatedAt); err != nil { - return err - } - } - return nil -} diff --git a/services/core/internal/store/item_reads_test.go b/services/core/internal/store/item_reads_test.go index ac5fc8767..f0f877c6b 100644 --- a/services/core/internal/store/item_reads_test.go +++ b/services/core/internal/store/item_reads_test.go @@ -17,6 +17,7 @@ import ( func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "items"}) if err != nil { @@ -42,7 +43,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) {Kind: "done", Payload: json.RawMessage(`{"content":"corrected answer","metadata":{"agent_session_id":"PRIVATE"}}`)}, } for range 2 { - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } } @@ -119,6 +120,7 @@ func TestItemsRecoverSnapshotsPartialResultsPaginationAndIsolation(t *testing.T) func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "legacy"}) input, err := s.SubmitMessage(ctx, tenant, session.ID, "input", json.RawMessage(`{"text":"test"}`)) @@ -130,7 +132,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) t.Fatal(err) } bad := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"must roll back"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"mismatch","stage":"after","observation":{"status":"completed","kind":"invalid"}}`)}} - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, bad); err == nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, bad); err == nil { t.Fatal("invalid snapshot accepted") } events, err := s.ListTurnEvents(ctx, tenant, session.ID, input.TurnID, 0, 100) @@ -154,6 +156,7 @@ func TestItemProjectionFailureRollsBackJournalAndAggregateRecovers(t *testing.T) func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() for _, receiptOnly := range []bool{true, false} { session, _ := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString()}) @@ -166,7 +169,7 @@ func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { t.Fatal(err) } if receiptOnly { - err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "cancel_receipt", Payload: json.RawMessage(`{"applied":true,"outcome":{"content":"retained cancellation text"}}`)}}) + err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "cancel_receipt", Payload: json.RawMessage(`{"applied":true,"outcome":{"content":"retained cancellation text"}}`)}}) if err != nil { t.Fatal(err) } @@ -185,6 +188,7 @@ func TestReceiptOnlyTextRecoversWithoutInventingCompletion(t *testing.T) { func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "failed-items"}) if err != nil { @@ -205,7 +209,7 @@ func TestLegacyFailureRetainsPartialAnswerAcrossRecovery(t *testing.T) { {Kind: "error", Payload: json.RawMessage(`{"error":"provider failure"}`)}, {Kind: "done", Payload: json.RawMessage(`{"content":"provider failure"}`)}, } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"done":{"content":"provider failure"},"error_code":"engine_failed"}`), "", input.Sequence) diff --git a/services/core/internal/store/item_serialization_test.go b/services/core/internal/store/item_serialization_test.go index 1378b9eeb..a74bd030c 100644 --- a/services/core/internal/store/item_serialization_test.go +++ b/services/core/internal/store/item_serialization_test.go @@ -2,10 +2,11 @@ package store import ( "encoding/json" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "reflect" "strings" "testing" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) type wireEvent struct { @@ -75,7 +76,7 @@ func TestAssistantMessageEventsFollowOfficialSequence(t *testing.T) { if err != nil { t.Fatal(err) } - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, test.events); err != nil { + if err := sessionExecution(t, executionWriter(t, s).lease).AppendTurnEvents(t.Context(), tenant, session.ID, turn, 1, test.events); err != nil { t.Fatal(err) } var kinds, deltas []string diff --git a/services/core/internal/store/list_cursor_public_test.go b/services/core/internal/store/list_cursor_public_test.go index c14a5d669..2cafa6538 100644 --- a/services/core/internal/store/list_cursor_public_test.go +++ b/services/core/internal/store/list_cursor_public_test.go @@ -34,7 +34,7 @@ type cursorFixture struct { file string } -func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, skillService *skills.Service, artifacts *sessions.Service, client pathIDClient, token, tenant, label string) cursorFixture { +func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, journal *sessions.ExecutionOperations, skillService *skills.Service, artifacts *sessions.Service, client pathIDClient, token, tenant, label string) cursorFixture { t.Helper() ctx := t.Context() var f cursorFixture @@ -121,7 +121,7 @@ func seedCursorFixture(t *testing.T, s *store.Store, writer *store.Store, skillS turn("child", "child-turn", opened), message("child", "child-turn", "child-item", 0), message("child", "child-turn", "child-item-2", 1), turn("child", "later-child-turn", opened+1000), message("child", "later-child-turn", "later-child-item", 0), turn("sibling", "sibling-turn", opened), message("sibling", "sibling-turn", "sibling-item", 0)} - if err = writer.AppendTurnEvents(ctx, tenant, created.ID, receipt.TurnID, 1, facts); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, created.ID, receipt.TurnID, 1, facts); err != nil { t.Fatal(err) } return created.ID, receipt.TurnID @@ -239,14 +239,15 @@ func TestListCursorErrorsPostgres(t *testing.T) { server := httptest.NewServer(h) defer server.Close() client := pathIDClient{t: t, server: server} - writer := executionOwner(t, db, s).Store + leased := executionOwner(t, db, s) + writer := leased.Store skillService := store.SkillService(t, db.pool, db.cipher) _, sessionService, err := fixtureSessions(db) if err != nil { t.Fatal(err) } - a := seedCursorFixture(t, s, writer, skillService, sessionService, client, owner, ownerTenant, "a") - b := seedCursorFixture(t, s, writer, skillService, sessionService, client, foreign, foreignTenant, "b") + a := seedCursorFixture(t, s, writer, leased.Sessions, skillService, sessionService, client, owner, ownerTenant, "a") + b := seedCursorFixture(t, s, writer, leased.Sessions, skillService, sessionService, client, foreign, foreignTenant, "b") text := func(value string) *string { return &value } var ( diff --git a/services/core/internal/store/public_execution_test.go b/services/core/internal/store/public_execution_test.go index 4e10aa83e..855d52845 100644 --- a/services/core/internal/store/public_execution_test.go +++ b/services/core/internal/store/public_execution_test.go @@ -118,7 +118,7 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { // A native measurement committed before process loss must survive startup // reconciliation even when no Done frame can be recovered. usage := json.RawMessage(`{"tokens":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":13}}`) - if err := h.s.AppendTurnEvents(ctx, h.tenant, h.session.ID, first.TurnID, 1, []sessions.ExecutionEvent{{Kind: proto.TypeUsage, Payload: usage}}); err != nil { + if err := h.owner().Sessions.AppendTurnEvents(ctx, h.tenant, h.session.ID, first.TurnID, 1, []sessions.ExecutionEvent{{Kind: proto.TypeUsage, Payload: usage}}); err != nil { t.Fatal(err) } checkMeasurement := func(ended bool) { @@ -148,7 +148,7 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) { if _, err := h.s.SubmitMessage(ctx, h.tenant, queued.ID, "first", json.RawMessage(`{"text":"Not sent"}`)); err != nil { t.Fatal(err) } - worker := startWorker(t, ctx, h.db, h.d) + worker := startOwnedWorker(t, ctx, h.db, h.d, h.owner()) stopped, cancel := context.WithCancel(ctx) cancel() if err := worker.Run(stopped); err != context.Canceled { diff --git a/services/core/internal/store/runtime_idle_clock_test.go b/services/core/internal/store/runtime_idle_clock_test.go index 22eeb216b..efb1d5cbf 100644 --- a/services/core/internal/store/runtime_idle_clock_test.go +++ b/services/core/internal/store/runtime_idle_clock_test.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" @@ -120,9 +121,10 @@ func TestManagedIdleClockIgnoresChildHostSkewAndReplay(t *testing.T) { source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() created := source - 1000 payload, _ := json.Marshal(proto.SubagentTurnPayload{NativeID: "child", TurnID: "remote-turn", Status: sessions.TurnCompleted, CreatedAtMS: created, StartedAtMS: &created, CompletedAtMS: &source}) + tenant, _ := parseID(owner.TenantID) project := func() error { return w.withSession(t.Context(), owner.TenantID, owner.SessionID, func(ctx context.Context, q *sqlc.Queries, session pgtype.UUID) error { - return projectSubagentTurn(ctx, q, session, payload) + return sessions.ProjectSource(ctx, sessionpg.BindSession(q, tenant, session), sessions.Source{Turn: root, Kind: proto.TypeSubagentTurn, Payload: payload}) }) } before := runtimeDatabaseTime(t, s) diff --git a/services/core/internal/store/sandbox_deployment_switch_test.go b/services/core/internal/store/sandbox_deployment_switch_test.go index a80bdb747..3fa85be08 100644 --- a/services/core/internal/store/sandbox_deployment_switch_test.go +++ b/services/core/internal/store/sandbox_deployment_switch_test.go @@ -317,7 +317,7 @@ func TestSandboxSwitchPreservesReleasedAllocationAndItemHistory(t *testing.T) { if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { t.Fatal(err) } - if err := w.AppendTurnEvents(t.Context(), tenant, history.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained answer"}`)}}); err != nil { + if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), tenant, history.ID, input.TurnID, 1, []sessions.ExecutionEvent{{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"retained answer"}`)}}); err != nil { t.Fatal(err) } if _, err := w.TransitionTurn(t.Context(), tenant, history.ID, input.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted}); err != nil { diff --git a/services/core/internal/store/self_hosted_cancel_public_test.go b/services/core/internal/store/self_hosted_cancel_public_test.go index 3f33f202e..8a9469d86 100644 --- a/services/core/internal/store/self_hosted_cancel_public_test.go +++ b/services/core/internal/store/self_hosted_cancel_public_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" @@ -28,9 +30,11 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { {OrganizationID: "test-org", ProjectID: tenant, SubjectKind: "service_account", SubjectID: "cancel-caller", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}, {OrganizationID: "test-org", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "cancel-caller", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: foreignTenant}, }) + var owner execution.Owner serve := func() (*httptest.Server, func(bool)) { t.Helper() - worker, stop := publicInitialWorker(t, s, db) + owner = executionOwner(t, db, s) + worker, stop := publicOwnedWorker(t, s, db, owner) handler, err := publicHandler(t, s, db, auth, "codex", workerExecution(worker), executorURL("https://offline-executor.example")) if err != nil { t.Fatal(err) @@ -136,7 +140,7 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { return input.TurnID } first := start() - if err := s.AppendTurnEvents(t.Context(), tenant, created.ID, first, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"controlled-partial","delta":"Retained partial output."}`)}}); err != nil { + if err := owner.Sessions.AppendTurnEvents(t.Context(), tenant, created.ID, first, 1, []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"item_id":"controlled-partial","delta":"Retained partial output."}`)}}); err != nil { t.Fatal(err) } before := snapshot(created.ID) @@ -169,7 +173,9 @@ func TestSelfHostedCancellationOfficialClient(t *testing.T) { transition(first, sessions.TurnInProgress, sessions.TurnCancelled) for _, reopen := range []bool{false, true} { if reopen { + awaitRelease := pgtest.ObserveExecutionLeaseRelease(t, db.pool) stop(false) + awaitRelease() server.Close() db.pool.Close() s, db = newModelTestStoreDB(t) diff --git a/services/core/internal/store/self_hosted_initial_public_test.go b/services/core/internal/store/self_hosted_initial_public_test.go index 4d2537a1f..53fc88bb7 100644 --- a/services/core/internal/store/self_hosted_initial_public_test.go +++ b/services/core/internal/store/self_hosted_initial_public_test.go @@ -17,6 +17,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" @@ -150,7 +151,9 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { t.Fatal("public initial creation did not retain its database deadline") } } + awaitRelease := pgtest.ObserveExecutionLeaseRelease(t, db.pool) stop(false) + awaitRelease() server.Close() db.pool.Close() reopened, reopenedDB := newModelTestStoreDB(t) @@ -211,7 +214,14 @@ func TestSelfHostedInitialCreationOfficialClient(t *testing.T) { func publicInitialWorker(t *testing.T, s *store.Store, db fixtureDB) (*execution.Worker, func(bool)) { t.Helper() - worker := startWorker(t, t.Context(), db, &execution.Dispatcher{Store: s, Registry: runtimegateway.NewRegistry()}) + return publicOwnedWorker(t, s, db, executionOwner(t, db, s)) +} + +// publicOwnedWorker is publicInitialWorker on owner, for tests that also write +// as the Worker's execution owner. +func publicOwnedWorker(t *testing.T, s *store.Store, db fixtureDB, owner execution.Owner) (*execution.Worker, func(bool)) { + t.Helper() + worker := startOwnedWorker(t, t.Context(), db, &execution.Dispatcher{Store: s, Registry: runtimegateway.NewRegistry()}, owner) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error, 1) go func() { done <- worker.Run(ctx) }() diff --git a/services/core/internal/store/session_diagnostics_test.go b/services/core/internal/store/session_diagnostics_test.go index 20949ddf1..9a028592d 100644 --- a/services/core/internal/store/session_diagnostics_test.go +++ b/services/core/internal/store/session_diagnostics_test.go @@ -25,10 +25,11 @@ func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { tenant, session := newTurnSession(t, s) receipt := submitMessage(t, s, tenant, session.ID, "start") transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + journal := sessionExecution(t, executionWriter(t, s).lease) before := diagnosticToolEvent("cmd", "before", "in_progress") after := diagnosticToolEvent("cmd", "after", "failed") for i, event := range []sessions.ExecutionEvent{before, after} { - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, int32(i+1), []sessions.ExecutionEvent{event}); err != nil { + if err := journal.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, int32(i+1), []sessions.ExecutionEvent{event}); err != nil { t.Fatal(err) } } @@ -47,12 +48,12 @@ func TestDiagnosticItemReceiptSettlementAndReplay(t *testing.T) { if item.CompletedAt == nil || !item.CompletedAt.Equal(received) || item.CompletedAt.Before(item.StartedAt) { t.Fatal("Item did not use terminal receipt", item, received) } - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 2, []sessions.ExecutionEvent{after}); err != nil { + if err := journal.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 2, []sessions.ExecutionEvent{after}); err != nil { t.Fatal(err) } // A terminal legacy Item with unknown settlement must remain unknown even on a repeated upsert. runtimeSuspensionSQL(t, s.pool, "UPDATE session_items SET settled_at=NULL WHERE id=$1", item.ItemID) - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, []sessions.ExecutionEvent{after}); err != nil { + if err := journal.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, []sessions.ExecutionEvent{after}); err != nil { t.Fatal(err) } transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnFailed) @@ -69,7 +70,7 @@ func TestDiagnosticForceSettlementIgnoresNativeClock(t *testing.T) { turn := uuid.NewString() runtimeSuspensionSQL(t, s.pool, "INSERT INTO turns(id,session_id,status,started_at) VALUES($1,$2,'in_progress',clock_timestamp())", turn, owner.SessionID) events := []sessions.ExecutionEvent{diagnosticToolEvent("first", "before", "in_progress"), diagnosticToolEvent("second", "before", "in_progress")} - if err := w.AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { + if err := sessionExecution(t, w.lease).AppendTurnEvents(t.Context(), owner.TenantID, owner.SessionID, turn, 1, events); err != nil { t.Fatal(err) } source := runtimeDatabaseTime(t, s).Add(skew).UnixMilli() @@ -281,7 +282,8 @@ func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { receipt := submitMessage(t, s, tenant, session.ID, "start") transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) events := []sessions.ExecutionEvent{diagnosticToolEvent("cmd", "before", "in_progress"), diagnosticToolEvent("cmd", "after", "completed")} - if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 1, events); err != nil { + journal := sessionExecution(t, executionWriter(t, s).lease) + if err := journal.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 1, events); err != nil { t.Fatal(err) } first, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) @@ -298,7 +300,7 @@ func TestDiagnosticFirstSettlementSurvivesStoredStatusRegression(t *testing.T) { runtimeSuspensionSQL(t, pool, "UPDATE session_items SET payload=jsonb_set(payload,'{status}','\"in_progress\"') WHERE id=$1", item.ItemID) if force { transition(t, s, tenant, session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnFailed) - } else if err := s.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, events[1:]); err != nil { + } else if err := journal.AppendTurnEvents(t.Context(), tenant, session.ID, receipt.TurnID, 3, events[1:]); err != nil { t.Fatal(err) } got, err := s.GetTurnDiagnosticsSnapshot(t.Context(), tenant, session.ID, receipt.TurnID) diff --git a/services/core/internal/store/session_events_test.go b/services/core/internal/store/session_events_test.go index ff0984160..e65fbf79b 100644 --- a/services/core/internal/store/session_events_test.go +++ b/services/core/internal/store/session_events_test.go @@ -51,6 +51,7 @@ func TestSessionEventsAreVisibleOnlyAfterCommit(t *testing.T) { func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "stream"}) if err != nil { @@ -81,12 +82,12 @@ func TestSessionEventsCommitSnapshotsRetriesAndIsolation(t *testing.T) { {Kind: "tool_call", Payload: json.RawMessage(`{"id":"cmd","stage":"before","observation":{"status":"in_progress","kind":"command","command":"sleep 10"}}`)}, } for range 2 { - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } } before, _ = s.SessionEventCursor(ctx, tenant, session.ID) - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 5, []sessions.ExecutionEvent{ + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 5, []sessions.ExecutionEvent{ {Kind: "delta", Payload: json.RawMessage(`{"item_id":"discarded","delta":"rollback"}`)}, {Kind: "output_message", Payload: json.RawMessage(`{"status":"invalid"}`)}, }); err == nil { diff --git a/services/core/internal/store/subagent_coordination.go b/services/core/internal/store/subagent_coordination.go deleted file mode 100644 index e153f4cc1..000000000 --- a/services/core/internal/store/subagent_coordination.go +++ /dev/null @@ -1,102 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func coordinationItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentCoordinationPayload) (v1.Item, error) { - value := v1.Item{ID: items.Identity(turn, "coordination:"+p.ID), TurnID: turn, Type: p.Kind, Status: p.Status, Model: p.Model, ReasoningEffort: p.ReasoningEffort} - if !validNativeIdentity(p.ID) { - return value, sessions.ErrInvalidInput - } - if p.Status != "in_progress" && p.Status != "completed" && p.Status != "failed" && p.Status != "incomplete" && p.Kind != "agent_message" { - return value, sessions.ErrInvalidInput - } - resolve := func(native string, failedReference bool) (string, error) { - if native == "" { - return q.SubagentRootAgent(ctx, session) - } - row, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: native}) - if errors.Is(err, pgx.ErrNoRows) && failedReference && validNativeIdentity(native) { - return native, nil - } - if err != nil { - return "", err - } - if !row.PublicVisible { - return "", sessions.ErrNotFound - } - return uuid.UUID(row.ID.Bytes).String(), nil - } - actor, err := resolve(p.ActorID, false) - if err != nil { - return value, err - } - if actor == "" { - return value, sessions.ErrInvalidInput - } - value.SenderAgentID = actor - if p.Text != nil { - value.Content = []v1.ItemContent{{Type: "output_text", Text: p.Text}} - } - switch p.Kind { - case "create_subagent_call": - value.AgentID = actor - case "wait_for_subagents_call": - value.RecipientAgentIDs = []string{} - for _, recipient := range p.Recipients { - id, err := resolve(recipient, p.Status == "failed") - if err != nil { - return value, err - } - value.RecipientAgentIDs = append(value.RecipientAgentIDs, id) - } - case "send_subagent_input_call", "resume_subagent_call", "interrupt_subagent_call", "close_subagent_call", "agent_message": - if len(p.Recipients) != 1 { - return value, sessions.ErrInvalidInput - } - value.RecipientAgentID, err = resolve(p.Recipients[0], p.Status == "failed") - if err != nil { - return value, err - } - default: - return value, sessions.ErrInvalidInput - } - return value, nil -} -func projectRootCoordination(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, raw json.RawMessage, created pgtype.Timestamptz) error { - var p proto.SubagentCoordinationPayload - if json.Unmarshal(raw, &p) != nil || p.ActorID != "" { - return sessions.ErrInvalidInput - } - value, err := coordinationItem(ctx, q, session, uuid.UUID(turn.Bytes).String(), p) - if err != nil { - return err - } - update := items.Update{Item: value} - stored, err := sessionpg.LoadItem(ctx, q, session, turn, update) - if err != nil { - return err - } - change, ok, err := items.Observe(proto.TypeSubagentCoordination, update, stored) - if err != nil || !ok { - return err - } - index, err := sessionpg.PutItem(ctx, q, session, turn, created, change) - if err != nil { - return err - } - return sessionpg.AppendChanges(ctx, q, session, sessions.ItemChanges(change, index)...) -} diff --git a/services/core/internal/store/subagent_identities_test.go b/services/core/internal/store/subagent_identities_test.go index 988f36961..6b8a84bce 100644 --- a/services/core/internal/store/subagent_identities_test.go +++ b/services/core/internal/store/subagent_identities_test.go @@ -23,6 +23,7 @@ func subagentIdentityEvent(child, parent string, created int64) sessions.Executi func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { s, pool := testStore(t) w := executionWriter(t, s) + journal := sessionExecution(t, w.lease) ctx := t.Context() tenant, session := newSubagentSession(t, s) host, err := s.CreateDevice(ctx, tenant, "identity test", runtimedevice.HashCredential(uuid.NewString())) @@ -36,17 +37,11 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { transition(t, w, tenant, session.ID, input.TurnID, sessions.TurnQueued, sessions.TurnInProgress) a, b := subagentIdentityEvent("child-a", "root", 102), subagentIdentityEvent("child-b", "root", 101) batch := []sessions.ExecutionEvent{a, b, a} - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); !errors.Is(err, ErrExecutionAuthority) { - t.Fatal("unleased discovery accepted", err) - } for range 2 { - if err = w.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal(err) } } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); !errors.Is(err, ErrExecutionAuthority) { - t.Fatal("unleased replay of a committed discovery accepted", err) - } saved, err := s.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") if err != nil || saved.ID == "" || saved.ID == saved.NativeID || saved.SessionID != session.ID || saved.FirstTurnID != input.TurnID || saved.FirstEventOrdinal != 1 || saved.NativeCreatedAt != 102 || saved.FirstObservedAt.IsZero() { t.Fatal(saved, err) @@ -59,7 +54,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { if _, err = s.GetSubagentIdentity(ctx, owner.tenant, owner.session, "child-a"); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("foreign read", err) } - if err = w.AppendTurnEvents(ctx, owner.tenant, owner.session, input.TurnID, 4, []sessions.ExecutionEvent{a}); !errors.Is(err, sessions.ErrNotFound) { + if err = journal.AppendTurnEvents(ctx, owner.tenant, owner.session, input.TurnID, 4, []sessions.ExecutionEvent{a}); !errors.Is(err, sessions.ErrNotFound) { t.Fatal("foreign write", err) } } @@ -75,7 +70,7 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { // A preceding new identity and public output must roll back with the conflict. bad := []sessions.ExecutionEvent{subagentIdentityEvent("rollback-child", "root", 105), {Kind: proto.TypeDelta, Payload: json.RawMessage(`{"delta":"must roll back","sequence":1}`)}, conflict} - if err = w.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, bad); !errors.Is(err, sessions.ErrIdempotencyConflict) { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, bad); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("conflicting facts accepted", err) } if _, err = s.GetSubagentIdentity(ctx, tenant, session.ID, "rollback-child"); !errors.Is(err, sessions.ErrNotFound) { @@ -99,13 +94,13 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { } foreignInput := submitMessage(t, s, tenant, foreign.ID, "first") transition(t, w, tenant, foreign.ID, foreignInput.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []sessions.ExecutionEvent{a}); !errors.Is(err, sessions.ErrIdempotencyConflict) { + if err = journal.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []sessions.ExecutionEvent{a}); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("same device/native child reassigned to another Session", err) } if _, err = pool.Exec(ctx, "UPDATE session_devices SET native_session_id='known-root' WHERE session_id=$1", foreign.ID); err != nil { t.Fatal(err) } - if err = w.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []sessions.ExecutionEvent{subagentIdentityEvent("other-child", "root", 101)}); !errors.Is(err, sessions.ErrIdempotencyConflict) { + if err = journal.AppendTurnEvents(ctx, tenant, foreign.ID, foreignInput.TurnID, 1, []sessions.ExecutionEvent{subagentIdentityEvent("other-child", "root", 101)}); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal("known root binding ignored", err) } if _, err = w.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCompleted, json.RawMessage(`{}`), "root", input.Sequence); err != nil { @@ -118,18 +113,19 @@ func TestSubagentIdentityIsAtomicScopedAndImmutable(t *testing.T) { awaitRelease() reopened, _ := testStore(t) nextOwner := executionWriter(t, reopened) + nextJournal := sessionExecution(t, nextOwner.lease) again, err := reopened.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") if err != nil || !reflect.DeepEqual(again, saved) { t.Fatal("restart changed identity", again, err) } second := submitMessage(t, reopened, tenant, session.ID, "second") transition(t, nextOwner, tenant, session.ID, second.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - if err = w.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []sessions.ExecutionEvent{a}); err == nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []sessions.ExecutionEvent{a}); err == nil { t.Fatal("closed owner wrote identity") } continued := proto.SubagentIdentityPayload{NativeID: "child-a", ParentNativeID: "root", NativeCreatedAt: 102, ParentTurnID: "later-native-turn", SourceItemID: "resume-item"} raw, _ := json.Marshal(continued) - if err = nextOwner.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []sessions.ExecutionEvent{{Kind: proto.TypeSubagentIdentity, Payload: raw}}); err != nil { + if err = nextJournal.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, []sessions.ExecutionEvent{{Kind: proto.TypeSubagentIdentity, Payload: raw}}); err != nil { t.Fatal(err) } again, err = reopened.GetSubagentIdentity(ctx, tenant, session.ID, "child-a") @@ -158,7 +154,7 @@ func TestSubagentIdentityRejectsLostLease(t *testing.T) { t.Fatal(killed, err) } successor := executionWriter(t, s) - if err := old.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{subagentIdentityEvent("child", "root", 100)}); err == nil { + if err := sessionExecution(t, old.lease).AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, []sessions.ExecutionEvent{subagentIdentityEvent("child", "root", 100)}); err == nil { t.Fatal("lost owner committed identity") } if err := successor.lease.CheckOwnership(context.Background()); err != nil { diff --git a/services/core/internal/store/subagent_item_projection.go b/services/core/internal/store/subagent_item_projection.go deleted file mode 100644 index 324ae43bb..000000000 --- a/services/core/internal/store/subagent_item_projection.go +++ /dev/null @@ -1,147 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func isSubagentObservation(kind string) bool { - switch kind { - case proto.TypeSubagentIdentity, proto.TypeSubagentLifecycle, proto.TypeSubagentTurn, proto.TypeSubagentItem, proto.TypeSubagentCoordination: - return true - } - return false -} -func projectSubagentItem(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { - var p proto.SubagentItemPayload - if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || !validNativeIdentity(p.ItemID) || p.Position < 0 || p.Position > 1073741823 { - return sessions.ErrInvalidInput - } - child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) - if err != nil { - return err - } - if !child.PublicVisible { - return sessions.ErrInvalidInput - } - turnID := items.Identity(uuid.UUID(child.ID.Bytes).String(), "turn:"+p.TurnID) - turn, err := sessionpg.LoadChildTurn(ctx, q, session, uuid.UUID(child.ID.Bytes).String(), turnID) - if err != nil { - return err - } - projected, err := childItems(ctx, q, session, turnID, p) - if err != nil { - return err - } - for offset, item := range projected { - if err := putChildItem(ctx, q, session, child.ID, turn, p.Position*2+int32(offset), item); err != nil { - return err - } - } - return nil -} - -func putChildItem(ctx context.Context, q *sqlc.Queries, session, childID pgtype.UUID, turn sqlc.SubagentTurn, position int32, item v1.Item) error { - payload, err := item.MarshalStored() - if err != nil { - return err - } - id, _ := parseID(item.ID) - old, err := q.GetChildItem(ctx, sqlc.GetChildItemParams{SessionID: session, SubagentID: childID, ID: id, Candidate: payload}) - fresh := errors.Is(err, pgx.ErrNoRows) - if err != nil && !fresh { - return err - } - var previous v1.Item - if !fresh { - if old.TurnID != turn.ID || old.Position != position { - return sessions.ErrIdempotencyConflict - } - if err = json.Unmarshal(old.Payload, &previous); err != nil { - return err - } - if old.PayloadEqual { - return nil - } - if previous.Status != "in_progress" { - return sessions.ErrIdempotencyConflict - } - } - if sessions.TerminalStatus(turn.Status) { - return sessions.ErrTurnConflict - } - // Child Items publish no Session events: the Session stream carries root work, - // and child history is read through the Subagent routes. The stored output - // index keeps its existing meaning. - _, err = q.PutChildItem(ctx, sqlc.PutChildItemParams{ID: id, SessionID: session, SubagentID: childID, TurnID: turn.ID, Position: position, Payload: payload, IsOutput: item.Role != "user" && item.Type != "function_call_output"}) - return err -} - -func childItems(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, turn string, p proto.SubagentItemPayload) ([]v1.Item, error) { - var result []v1.Item - switch p.Kind { - case proto.TypeSubagentCoordination: - var value proto.SubagentCoordinationPayload - if json.Unmarshal(p.Payload, &value) != nil || value.ID != p.ItemID || value.ActorID != p.NativeID { - return result, sessions.ErrInvalidInput - } - item, err := coordinationItem(ctx, q, session, turn, value) - return []v1.Item{item}, err - case proto.TypeOutputMessage: - var message proto.OutputMessagePayload - if json.Unmarshal(p.Payload, &message) != nil || message.ID != p.ItemID || message.Text == nil { - return result, sessions.ErrInvalidInput - } - case proto.TypeToolCall: - var call proto.ToolCallPayload - if json.Unmarshal(p.Payload, &call) != nil || call.ID != p.ItemID || call.Observation == nil { - return result, sessions.ErrInvalidInput - } - case "message": - // Input projection validates and normalizes the existing public message shape. - case "reasoning": - var value struct { - Status string `json:"status"` - Summary []v1.SummaryText `json:"summary"` - } - if json.Unmarshal(p.Payload, &value) != nil { - return result, sessions.ErrInvalidInput - } - if value.Status != "" && value.Status != "in_progress" && value.Status != "completed" && value.Status != "incomplete" { - return result, sessions.ErrInvalidInput - } - for _, part := range value.Summary { - if part.Type != "summary_text" { - return result, sessions.ErrInvalidInput - } - } - return []v1.Item{{ID: items.Identity(turn, "reasoning:"+p.ItemID), TurnID: turn, Type: "reasoning", Status: value.Status, Summary: value.Summary}}, nil - default: - return result, sessions.ErrInvalidInput - } - updates, err := items.Project(turn, p.Kind, int64(p.Position), p.Payload) - if err != nil { - return result, err - } - if len(updates) < 1 || len(updates) > 2 { - return nil, sessions.ErrInvalidInput - } - for _, update := range updates { - if update.AppendText { - return nil, sessions.ErrInvalidInput - } - result = append(result, update.Item) - } - return result, nil -} diff --git a/services/core/internal/store/subagent_lifecycle.go b/services/core/internal/store/subagent_lifecycle.go deleted file mode 100644 index 3a49fb0f6..000000000 --- a/services/core/internal/store/subagent_lifecycle.go +++ /dev/null @@ -1,83 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "strings" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func validNativeIdentity(value string) bool { - return value != "" && len(value) <= 512 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n") -} -func recordSubagentChange(ctx context.Context, q *sqlc.Queries, session, id pgtype.UUID, kind string) error { - value, err := sessionpg.LoadPublicSubagent(ctx, q, session, uuid.UUID(id.Bytes).String()) - if err != nil { - return err - } - return sessionpg.AppendChanges(ctx, q, session, sessions.SessionChange{Event: v1.SessionEvent{Type: "agent.session.subagent." + kind, Subagent: &value}}) -} -func publishSubagent(ctx context.Context, q *sqlc.Queries, session, id pgtype.UUID, identity proto.SubagentIdentityPayload) error { - previous, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: identity.NativeID}) - if err != nil { - return err - } - // Identity metadata is immutable after its first public observation. - if previous.PublicVisible { - return nil - } - p := sqlc.PublishSubagentParams{SessionID: session, ID: id} - if identity.Name != nil { - p.Name = pgtype.Text{String: *identity.Name, Valid: true} - } - if identity.Instructions != nil { - p.Instructions = pgtype.Text{String: *identity.Instructions, Valid: true} - } - if err := q.PublishSubagent(ctx, p); err != nil { - return err - } - return recordSubagentChange(ctx, q, session, id, "created") -} -func projectSubagentLifecycle(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { - var p proto.SubagentLifecyclePayload - if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.EffectID) || p.OccurredAtMS <= 0 || (p.Status != "active" && p.Status != "closed") { - return sessions.ErrInvalidInput - } - child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) - if err != nil { - return err - } - if !child.PublicVisible || p.OccurredAtMS < child.NativeCreatedAt*1000 { - return sessions.ErrInvalidInput - } - inserted, err := q.PutSubagentEffect(ctx, sqlc.PutSubagentEffectParams{SessionID: session, EffectID: p.EffectID, Payload: raw}) - if errors.Is(err, pgx.ErrNoRows) { - return sessions.ErrIdempotencyConflict - } - if err != nil || !inserted { - return err - } - if p.OccurredAtMS < child.LifecycleAtMs { - return sessions.ErrIdempotencyConflict - } - if p.Status == child.Status { - return nil - } - closed := pgtype.Int8{} - if p.Status == "closed" { - closed = pgtype.Int8{Int64: p.OccurredAtMS, Valid: true} - } - if err = q.ApplySubagentLifecycle(ctx, sqlc.ApplySubagentLifecycleParams{SessionID: session, ID: child.ID, Status: p.Status, ClosedAtMs: closed, LifecycleAtMs: p.OccurredAtMS}); err != nil { - return err - } - return recordSubagentChange(ctx, q, session, child.ID, p.Status) -} diff --git a/services/core/internal/store/subagent_native_outputs_test.go b/services/core/internal/store/subagent_native_outputs_test.go index 6db0b6e68..51c3c92bd 100644 --- a/services/core/internal/store/subagent_native_outputs_test.go +++ b/services/core/internal/store/subagent_native_outputs_test.go @@ -34,12 +34,12 @@ func TestSubagentNativeFunctionResultDoesNotConsumeOutputIndex(t *testing.T) { subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "native-file-change", Position: 0, Kind: proto.TypeToolCall, Payload: call}), subagentFact(proto.TypeSubagentItem, proto.SubagentItemPayload{NativeID: "child", TurnID: "turn", ItemID: "answer", Position: 1, Kind: proto.TypeOutputMessage, Payload: message}), } - if err = owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { + if err = sessionExecution(t, owner.lease).AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { t.Fatal(err) } finished := int64(101000) terminal := subagentFact(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "child", TurnID: "turn", Status: sessions.TurnCompleted, CreatedAtMS: 100000, CompletedAtMS: &finished}) - if err = owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 5, []sessions.ExecutionEvent{terminal, facts[2], facts[3]}); err != nil { + if err = sessionExecution(t, owner.lease).AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 5, []sessions.ExecutionEvent{terminal, facts[2], facts[3]}); err != nil { t.Fatal("identical native tool history must survive replay after completion", err) } child, err := s.GetSubagentIdentity(t.Context(), tenant, session.ID, "child") @@ -112,7 +112,7 @@ func TestSubagentCancelledPartialMessageSurvivesHistoryReplay(t *testing.T) { // A cold history read must preserve the partial answer without re-execution. message, terminal, } - if err := owner.AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { + if err := sessionExecution(t, owner.lease).AppendTurnEvents(t.Context(), tenant, session.ID, input.TurnID, 1, facts); err != nil { t.Fatal(err) } child, err := s.GetSubagentIdentity(t.Context(), tenant, session.ID, "child") diff --git a/services/core/internal/store/subagent_resources_test.go b/services/core/internal/store/subagent_resources_test.go index 520f42e92..d6e7a1655 100644 --- a/services/core/internal/store/subagent_resources_test.go +++ b/services/core/internal/store/subagent_resources_test.go @@ -30,6 +30,7 @@ func subagentFact(kind string, value any) sessions.ExecutionEvent { func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { s, pool := testStore(t) owner := executionWriter(t, s) + journal := sessionExecution(t, owner.lease) ctx := t.Context() tenant, session := newSubagentSession(t, s) host, err := s.CreateDevice(ctx, tenant, "child resources", runtimedevice.HashCredential(uuid.NewString())) @@ -44,7 +45,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { ordinal := int32(1) appendFacts := func(facts ...sessions.ExecutionEvent) { t.Helper() - if err := owner.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); err != nil { + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); err != nil { t.Fatal(err) } ordinal += int32(len(facts)) @@ -178,7 +179,7 @@ func TestSubagentResourcesNativeOwnershipLifecycleAndRecovery(t *testing.T) { // A conflicting replay rolls back the whole batch, including an earlier new child. closed.OccurredAtMS++ facts := []sessions.ExecutionEvent{subagentIdentityEvent("rollback", "root", 104), subagentFact(proto.TypeSubagentLifecycle, closed)} - if err = owner.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); !errors.Is(err, sessions.ErrIdempotencyConflict) { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, root.TurnID, ordinal, facts); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal(err) } if _, err = s.GetSubagentIdentity(ctx, tenant, session.ID, "rollback"); !errors.Is(err, sessions.ErrNotFound) { diff --git a/services/core/internal/store/subagent_turn_projection.go b/services/core/internal/store/subagent_turn_projection.go deleted file mode 100644 index 720f3177e..000000000 --- a/services/core/internal/store/subagent_turn_projection.go +++ /dev/null @@ -1,93 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "reflect" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/items" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -func nativeMillis(value *int64) pgtype.Timestamptz { - if value == nil { - return pgtype.Timestamptz{} - } - return pgtype.Timestamptz{Time: time.UnixMilli(*value), Valid: true} -} -func projectSubagentTurn(ctx context.Context, q *sqlc.Queries, session pgtype.UUID, raw json.RawMessage) error { - var p proto.SubagentTurnPayload - if json.Unmarshal(raw, &p) != nil || !validNativeIdentity(p.NativeID) || !validNativeIdentity(p.TurnID) || p.CreatedAtMS <= 0 { - return sessions.ErrInvalidInput - } - if p.Status != sessions.TurnQueued && p.Status != sessions.TurnInProgress && p.Status != sessions.TurnWaiting && !sessions.TerminalStatus(p.Status) { - return sessions.ErrInvalidInput - } - if sessions.TerminalStatus(p.Status) != (p.CompletedAtMS != nil) { - return sessions.ErrInvalidInput - } - if (p.StartedAtMS != nil && *p.StartedAtMS < p.CreatedAtMS) || (p.CompletedAtMS != nil && (*p.CompletedAtMS < p.CreatedAtMS || (p.StartedAtMS != nil && *p.CompletedAtMS < *p.StartedAtMS))) { - return sessions.ErrInvalidInput - } - child, err := q.GetNativeSubagent(ctx, sqlc.GetNativeSubagentParams{SessionID: session, NativeID: p.NativeID}) - if err != nil { - return err - } - if !child.PublicVisible { - return sessions.ErrInvalidInput - } - id, _ := parseID(items.Identity(uuid.UUID(child.ID.Bytes).String(), "turn:"+p.TurnID)) - old, err := q.GetChildTurn(ctx, sqlc.GetChildTurnParams{SessionID: session, ID: id}) - fresh := errors.Is(err, pgx.ErrNoRows) - if err != nil && !fresh { - return err - } - usage := []byte(nil) - if p.Usage != nil { - value, _ := json.Marshal(p.Usage) - if measured := sessions.MeasuredUsage("usage", value); measured != nil { - usage, _ = json.Marshal(measured) - } else { - return sessions.ErrInvalidInput - } - } - // Re-reading native history cannot reopen or mutate a completed child Turn. - if !fresh && sessions.TerminalStatus(old.Status) { - if !sessions.TerminalStatus(p.Status) { - return nil - } - var previousUsage, nextUsage *v1.TokenUsage - _ = json.Unmarshal(old.TokenUsage, &previousUsage) - _ = json.Unmarshal(usage, &nextUsage) - if old.Status != p.Status || old.CompletedAt.Time.UnixMilli() != *p.CompletedAtMS || !reflect.DeepEqual(previousUsage, nextUsage) { - return sessions.ErrIdempotencyConflict - } - return nil - } - if !fresh && old.Status != p.Status && !validTransition(old.Status, p.Status) { - return sessions.ErrTurnConflict - } - row, err := q.PutChildTurn(ctx, sqlc.PutChildTurnParams{ID: id, SessionID: session, SubagentID: child.ID, NativeID: p.TurnID, Status: p.Status, CreatedAt: pgtype.Timestamptz{Time: time.UnixMilli(p.CreatedAtMS), Valid: true}, StartedAt: nativeMillis(p.StartedAtMS), CompletedAt: nativeMillis(p.CompletedAtMS), TokenUsage: usage}) - if errors.Is(err, pgx.ErrNoRows) { - return sessions.ErrIdempotencyConflict - } - if err != nil { - return err - } - // Terminal replays returned above; they must not restart the managed idle timer. - // Child Turns publish no Session events: the Session stream carries root work, - // and child state is read through the Subagent routes. - if sessions.TerminalStatus(row.Status) { - return sessionpg.RecordTerminalActivity(ctx, q, session) - } - return nil -} diff --git a/services/core/internal/store/subagent_visibility_public_test.go b/services/core/internal/store/subagent_visibility_public_test.go index bd66a6401..e7db1c169 100644 --- a/services/core/internal/store/subagent_visibility_public_test.go +++ b/services/core/internal/store/subagent_visibility_public_test.go @@ -82,7 +82,8 @@ func TestSubagentVisibilityPublic(t *testing.T) { server := httptest.NewServer(handler) defer server.Close() client := pathIDClient{t: t, server: server} - writer := executionOwner(t, db, s).Store + leased := executionOwner(t, db, s) + writer := leased.Store ctx := t.Context() created := openStream(t, server, token, http.MethodPost, "/v1/agents/sessions", @@ -147,7 +148,7 @@ func TestSubagentVisibilityPublic(t *testing.T) { subagentFixture(proto.TypeSubagentTurn, proto.SubagentTurnPayload{NativeID: "nested", TurnID: "nested-turn", Status: sessions.TurnCompleted, CreatedAtMS: opened, StartedAtMS: &opened, CompletedAtMS: &finished}), subagentFixture(proto.TypeSubagentCoordination, proto.SubagentCoordinationPayload{ID: "wait", Kind: "wait_for_subagents_call", Status: "completed", Recipients: []string{"child"}}), } - if err = writer.AppendTurnEvents(ctx, tenant, session, root, 1, facts); err != nil { + if err = leased.Sessions.AppendTurnEvents(ctx, tenant, session, root, 1, facts); err != nil { t.Fatal(err) } if _, err = writer.TransitionTurn(ctx, tenant, session, root, sessions.TurnTransition{ExpectedStatus: sessions.TurnInProgress, Status: sessions.TurnCompleted}); err != nil { diff --git a/services/core/internal/store/token_usage.go b/services/core/internal/store/token_usage.go index 340bdd05a..e5a57312f 100644 --- a/services/core/internal/store/token_usage.go +++ b/services/core/internal/store/token_usage.go @@ -5,11 +5,7 @@ import ( "encoding/json" "fmt" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/jackc/pgx/v5/pgtype" ) // MeasuredSessionUsage returns Core-internal measured usage for Runtime @@ -32,25 +28,3 @@ func (s *Store) MeasuredSessionUsage(ctx context.Context, tenantID, sessionID st } return usage, nil } - -func projectSource(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, kind string, sequence int64, raw json.RawMessage, created pgtype.Timestamptz) error { - if kind == proto.TypeSubagentIdentity { - return projectSubagentIdentity(ctx, q, session, turn, int32(sequence), raw) - } - switch kind { - case proto.TypeSubagentLifecycle: - return projectSubagentLifecycle(ctx, q, session, raw) - case proto.TypeSubagentTurn: - return projectSubagentTurn(ctx, q, session, raw) - case proto.TypeSubagentItem: - return projectSubagentItem(ctx, q, session, raw) - case proto.TypeSubagentCoordination: - return projectRootCoordination(ctx, q, session, turn, raw, created) - } - if usage := sessions.MeasuredUsage(kind, raw); usage != nil { - if err := sessionpg.PutTurnUsage(ctx, q, session, turn, *usage); err != nil { - return err - } - } - return projectItemSource(ctx, q, session, turn, kind, sequence, raw, created) -} diff --git a/services/core/internal/store/token_usage_integration_test.go b/services/core/internal/store/token_usage_integration_test.go index 1dcadab59..404c8a8a9 100644 --- a/services/core/internal/store/token_usage_integration_test.go +++ b/services/core/internal/store/token_usage_integration_test.go @@ -5,17 +5,19 @@ import ( "encoding/json" "errors" "fmt" + "testing" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" - "testing" ) func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { ctx := context.Background() s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "usage"}) if err != nil { @@ -42,12 +44,12 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { } batch := []sessions.ExecutionEvent{{Kind: "usage", Payload: usage(10)}} for range 2 { - if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, batch); err != nil { t.Fatal(err) } } // A later snapshot replaces the earlier measurement; it is not a delta. - if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 2, []sessions.ExecutionEvent{{Kind: "usage", Payload: usage(20)}}); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 2, []sessions.ExecutionEvent{{Kind: "usage", Payload: usage(20)}}); err != nil { t.Fatal(err) } measured, err := s.GetTurn(ctx, tenant, session.ID, admission.TurnID) @@ -105,7 +107,8 @@ func TestTokenUsageDurableSnapshotsAndSessionTotals(t *testing.T) { func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { ctx := context.Background() - s, _ := store.NewTestStore(t) + s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "cancel-recovery"}) if err != nil { @@ -120,7 +123,7 @@ func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { t.Fatal(err) } receipt := json.RawMessage(`{"applied":true,"outcome":{"usage":{"tokens":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":13}}}}`) - if err = s.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, []sessions.ExecutionEvent{{Kind: "cancel_receipt", Payload: receipt}}); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, admission.TurnID, 1, []sessions.ExecutionEvent{{Kind: "cancel_receipt", Payload: receipt}}); err != nil { t.Fatal(err) } // Startup recovery has no in-memory cancellation outcome. @@ -139,7 +142,8 @@ func TestCancellationReceiptUsageSurvivesRecovery(t *testing.T) { // and after a Turn ends with unknown usage (EVT-13). func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { ctx := context.Background() - s, _ := store.NewTestStore(t) + s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "unknown-usage"}) if err != nil { @@ -215,7 +219,7 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { first := submit("first") total(-1) move(first.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - if err = s.AppendTurnEvents(ctx, tenant, session.ID, first.TurnID, 1, usage(10)); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, first.TurnID, 1, usage(10)); err != nil { t.Fatal(err) } // An active Turn's recorded snapshot does not count yet. @@ -231,7 +235,7 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { total(-1) move(second.TurnID, sessions.TurnQueued, sessions.TurnInProgress) total(-1) - if err = s.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, usage(20)); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, second.TurnID, 1, usage(20)); err != nil { t.Fatal(err) } total(-1) @@ -252,7 +256,7 @@ func TestSessionUsageRequiresEveryRootTurnEndedAndMeasured(t *testing.T) { } fourth := submit("fourth") move(fourth.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - if err = s.AppendTurnEvents(ctx, tenant, session.ID, fourth.TurnID, 1, usage(30)); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, fourth.TurnID, 1, usage(30)); err != nil { t.Fatal(err) } finish(fourth, sessions.TurnCompleted) diff --git a/services/core/internal/store/turn_completion.go b/services/core/internal/store/turn_completion.go index 64f76d487..bd7f812ca 100644 --- a/services/core/internal/store/turn_completion.go +++ b/services/core/internal/store/turn_completion.go @@ -11,6 +11,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -75,7 +76,7 @@ func (s *Store) CompleteExecution(ctx context.Context, tenantID, sessionID, turn if err != nil { return err } - if err = insertTurnEvent(ctx, q, row, "execution_"+status, outcome); err != nil { + if err = sessions.AppendTurnEvent(ctx, sessionpg.BindSession(q, p.TenantID, session), uuid.UUID(row.ID.Bytes).String(), row.EventCount, sessions.ExecutionEvent{Kind: "execution_" + status, Payload: outcome}); err != nil { return err } if nativeID != "" { diff --git a/services/core/internal/store/turn_events.go b/services/core/internal/store/turn_events.go deleted file mode 100644 index 30d0ab773..000000000 --- a/services/core/internal/store/turn_events.go +++ /dev/null @@ -1,119 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "slices" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/jsonobject" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// AppendTurnEvents records an ordered batch atomically, not public SSE replay events. -func (s *Store) AppendTurnEvents(ctx context.Context, tenantID, sessionID, turnID string, first int32, events []sessions.ExecutionEvent) error { - p, err := sessionpg.TurnLookup(tenantID, sessionID, turnID) - if err != nil { - return err - } - if first < 1 || len(events) == 0 || len(events) > 64 { - return sessions.ErrInvalidInput - } - // Subagent observations are projected under the execution journal, so only - // the execution writer records a batch that contains one, replays included. - if slices.ContainsFunc(events, func(event sessions.ExecutionEvent) bool { return isSubagentObservation(event.Kind) }) { - if err := s.checkExecutionAuthority(); err != nil { - return err - } - } - normalized := make([]sessions.ExecutionEvent, len(events)) - payloadBytes := 0 - for i, event := range events { - if len(event.Payload) > 512*1024 || !sessions.ValidEngine(event.Kind) { - return sessions.ErrInvalidInput - } - payload, err := jsonobject.Normalize(event.Payload) - if err != nil { - return fmt.Errorf("%w: %w", sessions.ErrInvalidInput, err) - } - normalized[i] = sessions.ExecutionEvent{Kind: event.Kind, Payload: payload} - payloadBytes += len(payload) - } - if payloadBytes > 1024*1024 { - return sessions.ErrEventLimit - } - batch, err := json.Marshal(normalized) - if err != nil { - return err - } - return s.withSession(ctx, tenantID, sessionID, func(ctx context.Context, q *sqlc.Queries, _ pgtype.UUID) error { - turn, err := q.GetTurn(ctx, p) - if errors.Is(err, pgx.ErrNoRows) { - return sessions.ErrNotFound - } - if err != nil { - return err - } - if first <= turn.EventCount { - matches, err := q.MatchTurnEventBatch(ctx, sqlc.MatchTurnEventBatchParams{SessionID: p.SessionID, TurnID: p.ID, FirstOrdinal: first, EventCount: int32(len(events)), Batch: batch}) - if err != nil { - return err - } - if !matches { - return sessions.ErrIdempotencyConflict - } - return nil - } - if (turn.Status != sessions.TurnInProgress && turn.Status != sessions.TurnWaiting) || first != turn.EventCount+1 { - return sessions.ErrTurnConflict - } - if turn.EventCount+int32(len(events)) > 65536 || turn.EventBytes+int64(payloadBytes) > 32*1024*1024 { - return sessions.ErrEventLimit - } - if err = q.InsertTurnEventBatch(ctx, sqlc.InsertTurnEventBatchParams{SessionID: p.SessionID, TurnID: p.ID, FirstOrdinal: first, Batch: batch}); err != nil { - return err - } - if err := indexEvents(ctx, q, p.SessionID, p.ID, first); err != nil { - return err - } - return q.CountTurnEvent(ctx, sqlc.CountTurnEventParams{SessionID: p.SessionID, ID: p.ID, EventCount: int32(len(events)), PayloadBytes: int64(payloadBytes)}) - }) -} - -func insertTurnEvent(ctx context.Context, q *sqlc.Queries, turn sqlc.Turn, kind string, payload json.RawMessage) error { - err := q.InsertTurnEvent(ctx, sqlc.InsertTurnEventParams{SessionID: turn.SessionID, TurnID: turn.ID, Ordinal: turn.EventCount + 1, Kind: kind, Payload: payload}) - if err != nil { - return err - } - if err = q.CountTurnEvent(ctx, sqlc.CountTurnEventParams{SessionID: turn.SessionID, ID: turn.ID, EventCount: 1, PayloadBytes: int64(len(payload))}); err != nil { - return err - } - return indexEvents(ctx, q, turn.SessionID, turn.ID, turn.EventCount+1) -} - -func (s *Store) ListTurnEvents(ctx context.Context, tenantID, sessionID, turnID string, after int32, limit int) ([]sessions.TurnEvent, error) { - p, err := sessionpg.TurnLookup(tenantID, sessionID, turnID) - if err != nil { - return nil, err - } - if after < 0 || limit < 1 || limit > 100 { - return nil, sessions.ErrInvalidInput - } - if _, err = s.GetTurn(ctx, tenantID, sessionID, turnID); err != nil { - return nil, err - } - rows, err := s.queries.ListTurnEvents(ctx, sqlc.ListTurnEventsParams{TenantID: p.TenantID, SessionID: p.SessionID, TurnID: p.ID, Ordinal: after, Limit: int32(limit)}) - if err != nil { - return nil, err - } - events := make([]sessions.TurnEvent, 0, len(rows)) - for _, row := range rows { - events = append(events, sessions.TurnEvent{Ordinal: row.Ordinal, Kind: row.Kind, Payload: row.Payload, CreatedAt: row.CreatedAt.Time}) - } - return events, nil -} diff --git a/services/core/internal/store/turn_events_test.go b/services/core/internal/store/turn_events_test.go index d95047362..cbdd71512 100644 --- a/services/core/internal/store/turn_events_test.go +++ b/services/core/internal/store/turn_events_test.go @@ -14,7 +14,8 @@ import ( func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { ctx := context.Background() - s, _ := store.NewTestStore(t) + s, pool := store.NewTestStore(t) + journal := executionOwner(t, fixtureDB{pool: pool}, s).Sessions tenant := uuid.NewString() session, err := s.CreateSession(ctx, tenant, sessions.CreateSession{Creator: store.FixtureCreator(), Engine: "codex", IdempotencyKey: "events"}) if err != nil { @@ -33,7 +34,10 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { errs := make(chan error, 8) for range 8 { wg.Add(1) - go func() { defer wg.Done(); errs <- s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch) }() + go func() { + defer wg.Done() + errs <- journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch) + }() } wg.Wait() close(errs) @@ -43,14 +47,14 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { } } conflict := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"changed"}`)}} - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, conflict); !errors.Is(err, sessions.ErrIdempotencyConflict) { + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, conflict); !errors.Is(err, sessions.ErrIdempotencyConflict) { t.Fatal(err) } - if err := s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, sessions.ErrTurnConflict) { + if err := journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal(err) } for _, owner := range []string{uuid.NewString()} { - if err := s.AppendTurnEvents(ctx, owner, session.ID, input.TurnID, 1, batch); !errors.Is(err, sessions.ErrNotFound) { + if err := journal.AppendTurnEvents(ctx, owner, session.ID, input.TurnID, 1, batch); !errors.Is(err, sessions.ErrNotFound) { t.Fatal(err) } if _, err := s.ListTurnEvents(ctx, owner, session.ID, input.TurnID, 0, 100); !errors.Is(err, sessions.ErrNotFound) { @@ -72,10 +76,10 @@ func TestTurnEventBatchesAreOrderedIsolatedAndDurable(t *testing.T) { if _, err = s.CompleteExecution(ctx, tenant, session.ID, input.TurnID, sessions.TurnCancelled, json.RawMessage(`{"done":{"content":""}}`), "", input.Sequence); err != nil { t.Fatal(err) } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 1, batch); err != nil { t.Fatal("retry after terminal", err) } - if err = s.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, sessions.ErrTurnConflict) { + if err = journal.AppendTurnEvents(ctx, tenant, session.ID, input.TurnID, 4, conflict); !errors.Is(err, sessions.ErrTurnConflict) { t.Fatal(err) } reopened, pool := store.NewTestStore(t) @@ -104,7 +108,7 @@ func TestEventLimitStillAllowsTerminalFailure(t *testing.T) { t.Fatal(err) } events := []sessions.ExecutionEvent{{Kind: "delta", Payload: json.RawMessage(`{"delta":"more"}`)}} - if err = h.s.AppendTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 1, events); !errors.Is(err, sessions.ErrEventLimit) { + if err = h.owner().Sessions.AppendTurnEvents(ctx, h.tenant, h.session.ID, input.TurnID, 1, events); !errors.Is(err, sessions.ErrEventLimit) { t.Fatal(err) } if _, err = h.s.CompleteExecution(ctx, h.tenant, h.session.ID, input.TurnID, sessions.TurnFailed, json.RawMessage(`{"error_code":"event_limit"}`), "", input.Sequence); err != nil { diff --git a/services/core/internal/store/turn_inputs.go b/services/core/internal/store/turn_inputs.go index 6c3ac9c60..c9f8285ce 100644 --- a/services/core/internal/store/turn_inputs.go +++ b/services/core/internal/store/turn_inputs.go @@ -175,16 +175,17 @@ func admitInput(ctx context.Context, q *sqlc.Queries, tenantID string, session p if err != nil { return sessions.InputReceipt{}, err } + tenant, err := parseID(tenantID) + if err != nil { + return sessions.InputReceipt{}, err + } + bound := sessionpg.BindSession(q, tenant, session) if input.Kind == "cancel" && turn.ID.Valid { - tenant, err := parseID(tenantID) - if err != nil { - return sessions.InputReceipt{}, err - } - if err := sessions.CancelTurn(ctx, sessionpg.BindSession(q, tenant, session), sessionpg.TurnFromRow(turn)); err != nil { + if err := sessions.CancelTurn(ctx, bound, sessionpg.TurnFromRow(turn)); err != nil { return sessions.InputReceipt{}, err } } - if err := indexInput(ctx, q, session, sequence); err != nil { + if err := sessions.ProjectInput(ctx, bound, sequence); err != nil { return sessions.InputReceipt{}, err } if created { diff --git a/services/core/internal/store/subagent_identities.go b/services/core/internal/store/turn_journal_reads_test.go similarity index 52% rename from services/core/internal/store/subagent_identities.go rename to services/core/internal/store/turn_journal_reads_test.go index 2d66352cc..5ac948041 100644 --- a/services/core/internal/store/subagent_identities.go +++ b/services/core/internal/store/turn_journal_reads_test.go @@ -2,21 +2,43 @@ package store import ( "context" - "encoding/json" "errors" - "strings" "time" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" ) -// SubagentIdentity is an internal execution binding, not a public Subagent resource. +// ListTurnEvents reads a Turn's journal after the given ordinal. Only tests +// read the journal back. +func (s *Store) ListTurnEvents(ctx context.Context, tenantID, sessionID, turnID string, after int32, limit int) ([]sessions.TurnEvent, error) { + p, err := sessionpg.TurnLookup(tenantID, sessionID, turnID) + if err != nil { + return nil, err + } + if after < 0 || limit < 1 || limit > 100 { + return nil, sessions.ErrInvalidInput + } + if _, err = s.GetTurn(ctx, tenantID, sessionID, turnID); err != nil { + return nil, err + } + rows, err := s.queries.ListTurnEvents(ctx, sqlc.ListTurnEventsParams{TenantID: p.TenantID, SessionID: p.SessionID, TurnID: p.ID, Ordinal: after, Limit: int32(limit)}) + if err != nil { + return nil, err + } + events := make([]sessions.TurnEvent, 0, len(rows)) + for _, row := range rows { + events = append(events, sessions.TurnEvent{Ordinal: row.Ordinal, Kind: row.Kind, Payload: row.Payload, CreatedAt: row.CreatedAt.Time}) + } + return events, nil +} + +// SubagentIdentity is a Subagent's internal execution binding, as tests read it +// back. type SubagentIdentity struct { ID, SessionID, NativeID, ParentNativeID string NativeCreatedAt int64 @@ -25,31 +47,8 @@ type SubagentIdentity struct { FirstObservedAt time.Time } -func projectSubagentIdentity(ctx context.Context, q *sqlc.Queries, session, turn pgtype.UUID, ordinal int32, raw json.RawMessage) error { - var identity proto.SubagentIdentityPayload - if json.Unmarshal(raw, &identity) != nil || identity.NativeCreatedAt <= 0 || identity.NativeID == identity.ParentNativeID { - return sessions.ErrInvalidInput - } - for _, value := range []string{identity.NativeID, identity.ParentNativeID, identity.ParentTurnID, identity.SourceItemID} { - if value == "" || len(value) > 512 || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\x00\r\n") { - return sessions.ErrInvalidInput - } - } - id, err := q.PutSubagentIdentity(ctx, sqlc.PutSubagentIdentityParams{ - ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, SessionID: session, - NativeID: identity.NativeID, ParentNativeID: identity.ParentNativeID, - NativeCreatedAt: identity.NativeCreatedAt, FirstTurnID: turn, FirstEventOrdinal: ordinal, - }) - if errors.Is(err, pgx.ErrNoRows) { - return sessions.ErrIdempotencyConflict - } - if err != nil { - return err - } - return publishSubagent(ctx, q, session, id, identity) -} - -// GetSubagentIdentity recovers a binding in its authorized, visible Session. +// GetSubagentIdentity reads a binding in its authorized, visible Session. Only +// tests read bindings back. func (s *Store) GetSubagentIdentity(ctx context.Context, tenantID, sessionID, nativeID string) (SubagentIdentity, error) { p, err := sessionpg.DeviceLookup(tenantID, sessionID) if err != nil { diff --git a/services/core/internal/store/turns.go b/services/core/internal/store/turns.go index 2023153ff..1ac432d8d 100644 --- a/services/core/internal/store/turns.go +++ b/services/core/internal/store/turns.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" @@ -40,7 +41,7 @@ func (s *Store) TransitionTurn(ctx context.Context, tenantID, sessionID, turnID if err != nil { return sessions.Turn{}, err } - if !validTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { + if !sessions.ValidTransition(input.ExpectedStatus, input.Status) || len(input.Outcome) > 512*1024 { return sessions.Turn{}, fmt.Errorf("%w: invalid turn transition or outcome size", sessions.ErrInvalidInput) } outcome, err := jsonobject.Normalize(input.Outcome) @@ -90,7 +91,8 @@ func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnPar } return row, nil } - if err = projectSource(ctx, q, row.SessionID, row.ID, "execution_"+row.Status, 0, row.Outcome, row.CompletedAt); err != nil { + outcome := sessions.Source{Turn: uuid.UUID(row.ID.Bytes).String(), Kind: "execution_" + row.Status, Payload: row.Outcome, CreatedAt: row.CompletedAt.Time} + if err = sessions.ProjectSource(ctx, sessionpg.BindSession(q, params.TenantID, row.SessionID), outcome); err != nil { return sqlc.Turn{}, err } if row, err = q.GetTurn(ctx, params); err != nil { @@ -106,19 +108,6 @@ func transitionTurn(ctx context.Context, q *sqlc.Queries, params sqlc.GetTurnPar return row, nil } -func validTransition(from, to string) bool { - switch from { - case sessions.TurnQueued: - return to == sessions.TurnInProgress || to == sessions.TurnFailed || to == sessions.TurnCancelled - case sessions.TurnInProgress: - return to == sessions.TurnWaiting || sessions.TerminalStatus(to) - case sessions.TurnWaiting: - return to == sessions.TurnInProgress || sessions.TerminalStatus(to) - default: - return false - } -} - // publicTurnLookup resolves caller-supplied path identifiers for a Turn or a // Turn-scoped resource. Unparsable values are indistinguishable from missing ones. func publicTurnLookup(tenantID, sessionID, turnID string) (sqlc.GetTurnParams, error) { diff --git a/services/core/tests/fixtures/items.go b/services/core/tests/fixtures/items.go index afd89e75a..af1242f96 100644 --- a/services/core/tests/fixtures/items.go +++ b/services/core/tests/fixtures/items.go @@ -3,11 +3,19 @@ package main import ( "context" "encoding/json" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/sessionpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) -func observeItems(ctx context.Context, s *store.Store, tenant, session, turn, status string) error { +// observeItems records the Turn's execution observations as the execution +// journal does, composing the journal procedure over a pooled Session +// transaction because the seeder holds no execution lease. +func observeItems(ctx context.Context, pool *pgxpool.Pool, tenantID, sessionID, turn, status string) error { events := []sessions.ExecutionEvent{ {Kind: "delta", Payload: json.RawMessage(`{"item_id":"answer","delta":"partial answer"}`)}, {Kind: "tool_call", Payload: json.RawMessage(`{"id":"command","stage":"after","observation":{"status":"failed","kind":"command","command":"exit 7","cwd":"/workspace","output":"command failed","exit_code":7,"duration_ms":8}}`)}, @@ -19,5 +27,19 @@ func observeItems(ctx context.Context, s *store.Store, tenant, session, turn, st if status == sessions.TurnCompleted || status == sessions.TurnFailed { events = append(events, sessions.ExecutionEvent{Kind: "output_message", Payload: json.RawMessage(`{"id":"answer","status":"completed","text":"final answer","phase":"final_answer"}`)}) } - return s.AppendTurnEvents(ctx, tenant, session, turn, 1, events) + batch, err := sessions.NewJournalBatch(turn, 1, events) + if err != nil { + return err + } + tenant, err := pgunit.ParseID(tenantID) + if err != nil { + return err + } + session, err := pgunit.ParseID(sessionID) + if err != nil { + return err + } + return sessionpg.WithSession(ctx, pgunit.NewPool(pool), tenant, session, func(ctx context.Context, q *sqlc.Queries, _ sessions.LockedSession) error { + return sessions.AppendTurnEvents(ctx, sessionpg.BindSession(q, tenant, session), batch) + }) } diff --git a/services/core/tests/fixtures/main.go b/services/core/tests/fixtures/main.go index 9c59768db..8ef4db2b0 100644 --- a/services/core/tests/fixtures/main.go +++ b/services/core/tests/fixtures/main.go @@ -61,7 +61,7 @@ func seed() error { if _, err = s.TransitionTurn(ctx, f.Tenant, f.Session, receipt.TurnID, sessions.TurnTransition{ExpectedStatus: sessions.TurnQueued, Status: sessions.TurnInProgress}); err != nil { return err } - if err = observeItems(ctx, s, f.Tenant, f.Session, receipt.TurnID, status); err != nil { + if err = observeItems(ctx, pool, f.Tenant, f.Session, receipt.TurnID, status); err != nil { return err } if status != sessions.TurnInProgress {