diff --git a/SECURITY.md b/SECURITY.md index 7d86e17..b366be5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,8 +31,6 @@ maintainer contact without disclosing vulnerability details. audited for hostile multi-tenant workloads. - `-strict` checks that authentication headers are present; it does not validate credentials or implement authorization. -- The deprecated SQLite backend is single-process and is not a production - target. - PostgreSQL journals tool attempts, but an external side effect can still be ambiguous if execution succeeds and its durable result is lost. Exactly-once behavior requires idempotency from the external system. diff --git a/cmd/managed-agent/main.go b/cmd/managed-agent/main.go index ea25d63..e2aab48 100644 --- a/cmd/managed-agent/main.go +++ b/cmd/managed-agent/main.go @@ -14,7 +14,6 @@ import ( "syscall" "time" - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" "github.com/yanpgwang/managed-agent-go/internal/app" "github.com/yanpgwang/managed-agent-go/internal/controlplane" "github.com/yanpgwang/managed-agent-go/internal/domain" @@ -23,7 +22,6 @@ import ( "github.com/yanpgwang/managed-agent-go/internal/model" "github.com/yanpgwang/managed-agent-go/internal/pg" "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" temporalpkg "github.com/yanpgwang/managed-agent-go/internal/temporal" ) @@ -69,11 +67,8 @@ const ( daytonaAutoPauseEnv = "DAYTONA_AUTO_PAUSE_MINUTES" ) -// resolveRuntime returns the self-hosted agent core and whether it is backed by -// a real (network-backed) model. It uses the real Messages API client when -// MANAGED_AGENT_MODEL_BASE_URL and MANAGED_AGENT_MODEL_API_KEY are set, -// otherwise the offline deterministic fake so the binary runs (and tests stay -// offline) with no configuration. +// resolveModelClient returns the worker model client and reports whether it is +// backed by a real, network-connected model. func resolveModelClient() (client model.Client, realModel bool, err error) { if client, ok, err := model.AnthropicFromEnv(); err != nil { return nil, false, err @@ -85,14 +80,6 @@ func resolveModelClient() (client model.Client, realModel bool, err error) { return model.NewFake(), false, nil } -func resolveRuntime() (rt agentruntime.AgentRuntime, realModel bool, err error) { - client, realModel, err := resolveModelClient() - if err != nil { - return nil, false, err - } - return agentruntime.NewAgentCore(client, domain.NewRandomIDGen()), realModel, nil -} - // sandboxProviderRegistry declares the adapters compiled into this worker. // Factories are lazy: selecting local never initializes Docker, and future // optional remote adapters will not require credentials unless selected. @@ -313,22 +300,6 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server { } } -func buildHandler(db *store.DB, cfg httpapi.Config, rt agentruntime.AgentRuntime, sbx sandbox.Provider) (http.Handler, *app.SessionService) { - ids := domain.NewRandomIDGen() - clk := realClock{} - hub := app.NewHub(256) - es := app.NewEventService(store.NewEventStore(db, ids, clk), hub) - agents := app.NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := app.NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := app.NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), - store.NewEnvironmentRepo(db), es, store.NewRunStore(db, ids, clk), - rt, sbx, ids, clk) - srv := httpapi.NewServer(httpapi.Deps{ - Agents: agents, Envs: envs, Sessions: sessions, Events: es, Hub: hub, - }, cfg) - return srv.Handler(), sessions -} - func main() { if len(os.Args) < 2 { log.Fatal("usage: managed-agent [flags]") @@ -346,66 +317,19 @@ func main() { func runServe() { fs := flag.NewFlagSet("serve", flag.ExitOnError) addr := fs.String("addr", defaultAddr, "listen address (default binds to loopback; use e.g. :8080 to expose on all interfaces)") - backend := fs.String("backend", "postgres", "control-plane backend: postgres (default) or sqlite (deprecated compatibility mode)") - dbPath := fs.String("db", "managed-agent.db", "sqlite path (used only with -backend sqlite)") strict := fs.Bool("strict", false, "require Claude API wire headers (auth, version, beta, content-type) to be present and valid; this is header validation, NOT authentication") _ = fs.Parse(os.Args[2:]) cfg := httpapi.Config{ RequireBeta: *strict, RequireAuth: *strict, RequireVersion: *strict, RequireContentType: *strict, } - switch *backend { - case "postgres": - runPostgresAPI(*addr, cfg) - case "sqlite": - log.Printf("startup: -backend sqlite is deprecated compatibility mode; new deployments should use postgres") - runSQLiteAPI(*addr, *dbPath, cfg) - default: - log.Fatalf("serve: unsupported backend %q (want postgres or sqlite)", *backend) - } -} - -func runSQLiteAPI(addr, dbPath string, cfg httpapi.Config) { - // resolveRuntime selects the real Messages-API-backed agent core when the - // model env is configured, otherwise the offline deterministic fake. - rt, realModel, err := resolveRuntime() - if err != nil { - log.Fatalf("runtime: %v", err) - } - - sbx, localSandbox, err := resolveSandboxProvider() - if err != nil { - log.Fatalf("sandbox: %v", err) - } - - // Refuse the unsafe real-model + local-sandbox pairing unless explicitly - // overridden. The zero-config fake + local default is always allowed. - allowUnsafe := os.Getenv(unsafeLocalSandboxEnv) == "1" - if err := guardModelSandbox(realModel, localSandbox, allowUnsafe); err != nil { - log.Fatalf("startup: %v", err) - } - - db, err := store.Open(dbPath) - if err != nil { - log.Fatalf("open db: %v", err) - } - defer db.Close() - - handler, sessions := buildHandler(db, cfg, rt, sbx) - if err := sessions.Recover(context.Background()); err != nil { - log.Printf("recovery: %v", err) - } - serveHTTP(addr, handler) + runPostgresAPI(*addr, cfg) } func runPostgresAPI(addr string, cfg httpapi.Config) { databaseURL := os.Getenv(envDatabaseURL) if databaseURL == "" { - log.Fatalf( - "serve: %s is required for the default PostgreSQL control plane "+ - "(use -backend sqlite only for deprecated compatibility mode)", - envDatabaseURL, - ) + log.Fatalf("serve: %s is required", envDatabaseURL) } ctx := context.Background() pool, err := pg.Pool(ctx, databaseURL) diff --git a/cmd/managed-agent/main_test.go b/cmd/managed-agent/main_test.go index 98776fd..150c8db 100644 --- a/cmd/managed-agent/main_test.go +++ b/cmd/managed-agent/main_test.go @@ -3,15 +3,11 @@ package main import ( "context" "net/http" - "net/http/httptest" "strings" "testing" "time" - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/httpapi" "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" ) // TestResolveSandboxProvider_DefaultsToLocal asserts that, with no @@ -122,21 +118,6 @@ func TestSandboxEnvironmentParsersRejectInvalidValues(t *testing.T) { } } -func TestResolveRuntime_UsesFakeModelWithoutEnv(t *testing.T) { - t.Setenv("MANAGED_AGENT_MODEL_BASE_URL", "") - t.Setenv("MANAGED_AGENT_MODEL_API_KEY", "") - rt, realModel, err := resolveRuntime() - if err != nil { - t.Fatal(err) - } - if realModel { - t.Fatalf("resolveRuntime realModel=true without model env; want false (offline fake)") - } - if _, ok := rt.(*agentruntime.AgentCore); !ok { - t.Fatalf("runtime = %T, want *agentruntime.AgentCore", rt) - } -} - // TestGuardModelSandbox_Matrix covers the safe-defaults startup guard: a real // model against the local (non-isolating) sandbox must fail unless the operator // explicitly opts in via MANAGED_AGENT_ALLOW_UNSAFE_LOCAL_SANDBOX=1. Every other @@ -204,37 +185,35 @@ func TestNewHTTPServer_Timeouts(t *testing.T) { } } -// TestResolveRuntime_ReportsRealModelWithEnv proves resolveRuntime reports +// TestResolveModelClient_ReportsRealModelWithEnv proves model selection reports // realModel=true when both the model base URL and API key are configured. It -// performs no network call: resolveRuntime only constructs the client -// (AnthropicFromEnv → NewAnthropic), which does not contact the endpoint. The -// base URL is a non-routable placeholder to make that guarantee explicit. -func TestResolveRuntime_ReportsRealModelWithEnv(t *testing.T) { +// performs no network call: construction does not contact the endpoint. +func TestResolveModelClient_ReportsRealModelWithEnv(t *testing.T) { t.Setenv("MANAGED_AGENT_MODEL_BASE_URL", "https://model.invalid") t.Setenv("MANAGED_AGENT_MODEL_API_KEY", "sk-test") - rt, realModel, err := resolveRuntime() + client, realModel, err := resolveModelClient() if err != nil { t.Fatal(err) } if !realModel { - t.Fatalf("resolveRuntime realModel=false with model env configured; want true") + t.Fatalf("resolveModelClient realModel=false with model env configured; want true") } - if _, ok := rt.(*agentruntime.AgentCore); !ok { - t.Fatalf("runtime = %T, want *agentruntime.AgentCore", rt) + if client == nil { + t.Fatal("resolveModelClient returned a nil client") } } -func TestBuildHandler_Health(t *testing.T) { - db, err := store.OpenMemory() +func TestResolveModelClient_UsesFakeWithoutEnv(t *testing.T) { + t.Setenv("MANAGED_AGENT_MODEL_BASE_URL", "") + t.Setenv("MANAGED_AGENT_MODEL_API_KEY", "") + client, realModel, err := resolveModelClient() if err != nil { t.Fatal(err) } - defer db.Close() - h, _ := buildHandler(db, httpapi.Config{}, agentruntime.NewFake(), sandbox.NewLocalProvider()) - ts := httptest.NewServer(h) - defer ts.Close() - resp, err := ts.Client().Get(ts.URL + "/healthz") - if err != nil || resp.StatusCode != 200 { - t.Fatalf("healthz: %v status=%v", err, resp) + if realModel { + t.Fatal("resolveModelClient realModel=true without model configuration") + } + if client == nil { + t.Fatal("resolveModelClient returned a nil fake client") } } diff --git a/cmd/managed-agent/orchestrate.go b/cmd/managed-agent/orchestrate.go index 9b204d5..d4b72b5 100644 --- a/cmd/managed-agent/orchestrate.go +++ b/cmd/managed-agent/orchestrate.go @@ -54,10 +54,8 @@ func runOrchestrate() { store.SetEventNotifier(broker) log.Printf("orchestrate: NATS live channel connected") - // The execution plane uses the same model selection as the SQLite path. New - // Workflow executions call it through granular model/tool Activities; the - // runtime also retains AgentCore internally for replaying older RunTurn - // histories. The offline fake model is sufficient with no configuration. + // Workflow executions call the selected model through granular model/tool + // Activities. The offline fake model needs no configuration. modelClient, realModel, err := resolveModelClient() if err != nil { log.Fatalf("orchestrate: runtime: %v", err) diff --git a/docs/api/events.md b/docs/api/events.md index 15acce8..ac261ea 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -23,7 +23,7 @@ shape; persisted events receive an `id` and `processed_at`. The list must be non-empty. Clients cannot provide `id` or `processed_at`, and server-emitted event types are rejected. -The default PostgreSQL/Temporal backend currently accepts: +The PostgreSQL/Temporal control plane currently accepts: | Event | Current behavior | | --- | --- | @@ -34,9 +34,7 @@ The default PostgreSQL/Temporal backend currently accepts: | `user.define_outcome` | Stored and validated | Generic `user.tool_result`, `system.message`, and targeted multi-agent interrupt -shapes return `422 unsupported_error` on the primary backend. The deprecated -`serve -backend sqlite` compatibility mode is frozen and remains only for -transition comparison. +shapes return `422 unsupported_error`. An interrupt is first committed to PostgreSQL and then delivered to the Session Workflow as a metadata-only wakeup. An interrupt that commits before diff --git a/docs/architecture.md b/docs/architecture.md index 46a04af..57a223e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,8 +12,8 @@ Compose stack runs those roles separately; they can also be packaged in one deployment for development. The selected stack is Temporal orchestration, PostgreSQL, NATS Core, and -replaceable sandbox providers. Object storage and provider-backed sandbox -leases are not implemented. +replaceable sandbox providers. Provider sandbox bindings are persisted in +PostgreSQL; object storage is not implemented. ```mermaid flowchart LR @@ -77,16 +77,15 @@ model vendor, sandbox backend, or worker topology. | --- | --- | | `cmd/managed-agent` | Composition root, configuration, process lifecycle | | `internal/httpapi` | HTTP routes, strict validation, DTO mapping, SSE | -| `internal/app` | Shared resource validation and legacy compatibility services | +| `internal/app` | Shared resource validation and transport-neutral use-case types | | `internal/controlplane` | PostgreSQL-backed public Session/Event use cases | | `internal/domain` | Resource, event, message, tool, and run semantics | | `internal/pg` | PostgreSQL repositories, ledger, outbox, and tool journal | | `internal/temporal` | Session Workflow, Activities, worker, and relay | | `internal/live` | NATS wakeups/previews plus PostgreSQL cursor reconciliation | -| `internal/store` | Deprecated SQLite compatibility implementation | -| `internal/agentruntime` | Model/tool orchestration behind `AgentRuntime` | +| `internal/agentruntime` | Reusable model, message, and tool execution primitives | | `internal/model` | Offline and Messages API model clients | -| `internal/sandbox` | Provider registry, lifecycle contract, and local/Docker adapters | +| `internal/sandbox` | Provider registry, lifecycle contract, and local/remote adapters | The dependency direction points inward: transport and infrastructure depend on application/domain semantics, while the domain has no HTTP, SQL, model-client, diff --git a/docs/compatibility.md b/docs/compatibility.md index e12e6f7..b652e88 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -44,12 +44,11 @@ compatibility. | Multi-agent orchestration | Not supported | `multiagent` configuration can be stored, but rosters, threads, delegation, and orchestration are not executed. | | Distributed workers | Limited | API and Temporal worker roles are separate and can be replicated around PostgreSQL/NATS. Sandbox ownership is durable: local and Docker references require workers connected to the same host filesystem or daemon, while remote references can reattach from workers sharing the same provider configuration. Task queues must remain configuration-homogeneous. Worker Versioning, production manifests, and rollout tests remain open. | -## Backend transition +## Runtime architecture -`serve` defaults to the PostgreSQL control plane. `serve -backend sqlite` -temporarily retains the former single-process behavior for transition -comparison. This is not a commitment to maintain two backends: SQLite is frozen -and will be removed after the PostgreSQL/Temporal conformance gate passes. +`serve` has one authoritative control-plane path: PostgreSQL for resources, +events, projections, and admission; Temporal for durable orchestration; and +NATS Core for ephemeral wakeups and previews. ## Integration contract @@ -76,7 +75,7 @@ the behavior: - raw HTTP tests for JSON shapes, status codes, headers, and validation; - official Go SDK tests for client interoperability; -- application and store tests for durable execution semantics; +- application and PostgreSQL tests for durable execution semantics; - end-to-end tests for runtime, tool, interrupt, and streaming workflows. Test names and edge-case details live beside the implementation and in the diff --git a/docs/getting-started.md b/docs/getting-started.md index 53957af..cd04b5b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -190,15 +190,3 @@ make local-down This keeps the PostgreSQL volume. Add `VOLUMES=1` only when you intentionally want to delete local data. PostgreSQL schema changes are applied by embedded, versioned `goose` migrations when API or worker processes start. - -## Temporary SQLite compatibility mode - -The former single-process runtime remains available during the final -PostgreSQL/Temporal conformance transition: - -```bash -go run ./cmd/managed-agent serve -backend sqlite -db managed-agent.db -``` - -It is a frozen comparison/rollback path, not a production backend. New -deployments should use the PostgreSQL/Temporal stack above. diff --git a/docs/intro.md b/docs/intro.md index b29312f..ddad0c3 100644 --- a/docs/intro.md +++ b/docs/intro.md @@ -59,10 +59,11 @@ messages and untargeted interrupts; cursor pagination and SSE; a multi-round model loop; durable custom-tool and `always_ask` waits; six `always_allow` built-ins; local and Docker sandboxes; and opt-in assistant text previews. -The remaining transition gate before removing the deprecated SQLite comparison -backend is black-box conformance coverage for the supported PostgreSQL/Temporal -surface. MCP execution, files/skills/memory/vaults, multi-agent orchestration, -remote self-hosted workers, schedules, and webhooks remain future product work. +The remaining infrastructure closure work is autonomous recovery for interrupted +session deletion, sandbox orphan reconciliation, and repeatable black-box +conformance for the PostgreSQL/Temporal path. MCP execution, +files/skills/memory/vaults, multi-agent orchestration, remote self-hosted +workers, schedules, and webhooks remain future product work. The runtime is the product; Claude API compatibility is an integration surface. Public behavior is derived from official documentation and tested through raw diff --git a/docs/roadmap.md b/docs/roadmap.md index d079a5a..096f5de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -13,9 +13,11 @@ not by reproducing Anthropic's internal implementation. These are the release-blocking capabilities for the managed-agent harness: -1. conformance tests for supported Managed Agents API behavior and removal of - the frozen SQLite comparison backend; -2. bounded context management and compaction over server-owned history. +1. autonomous recovery for interrupted deletion plus sandbox orphan + reconciliation; +2. repeatable black-box conformance for the supported PostgreSQL/Temporal and + external-sandbox paths; +3. bounded context management and compaction over server-owned history. Durable cross-process interrupt, deterministic finish-vs-interrupt ordering, Sandbox identity, worker-restart reattachment, and deletion cleanup are durable diff --git a/go.mod b/go.mod index ada1a48..e01741c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ require ( github.com/tencentcloud/CubeSandbox/sdk/go v0.0.0-20260730082406-8ad40de9ed5f go.temporal.io/api v1.63.0 go.temporal.io/sdk v1.46.0 - modernc.org/sqlite v1.54.0 ) require ( @@ -37,7 +36,6 @@ require ( github.com/daytona/clients/analytics-api-client-go v0.0.0-20260722121532-3d2223c79fe5 // indirect github.com/daytona/clients/api-client-go v0.202.0 // indirect github.com/daytona/clients/toolbox-api-client-go v0.202.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -52,16 +50,13 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.19.1 // indirect - github.com/mattn/go-isatty v0.0.23 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect github.com/nexus-rpc/sdk-go v0.6.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron v1.2.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect @@ -93,7 +88,4 @@ require ( google.golang.org/grpc v1.82.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.74.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 194e219..a5e7f75 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,6 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= @@ -74,8 +72,6 @@ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4z github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -187,8 +183,6 @@ golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2d golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -222,8 +216,6 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -246,31 +238,11 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= -modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= -modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= -modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/app/admission.go b/internal/app/admission.go deleted file mode 100644 index c46138b..0000000 --- a/internal/app/admission.go +++ /dev/null @@ -1,95 +0,0 @@ -package app - -import ( - "context" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -// bufferedSink is both the run's persistent EventSink and its live -// PreviewEmitter: Emit buffers drafts for commit at run completion, while -// PreviewStart/PreviewDelta forward stream-only preview frames the hub delivers -// to opted-in subscribers. -var ( - _ agentruntime.EventSink = (*bufferedSink)(nil) - _ agentruntime.PreviewEmitter = (*bufferedSink)(nil) -) - -// bufferedSink accumulates the runtime's emitted drafts for a single run and -// hands the runtime back committed event ids at emit time. It pre-assigns each -// draft's committed id from the shared IDGenerator (the same generator the -// store uses) and stamps it onto the draft, so the id the runtime reads back -// (for tool_result correlation, or to name parked events in a requires_action -// stop reason) is exactly the id the store will persist. Emission stays in -// memory; the drafts are committed together when the run completes. -// -// Independently of that persistent buffer, the sink is also a PreviewEmitter: it -// forwards live preview frames straight to the EventService (hub), so opted-in -// stream clients see an incremental assistant message before its full -// agent.message is committed. Previews are a real-time side channel — they never -// touch the drafts buffer, are never persisted, and never appear in history. The -// persistent buffer→commit path is unchanged by their presence. -type bufferedSink struct { - ids domain.IDGenerator - drafts []domain.EventDraft - events *EventService - sessionID string - // previewTypes remembers each previewed event's type from its PreviewStart so - // the following PreviewDelta frames can carry it. The hub routes preview - // frames by EventType (only opted-in subscribers receive them), and the core's - // PreviewDelta callback does not repeat the type, so the sink threads it here. - previewTypes map[string]string -} - -func newBufferedSink(ids domain.IDGenerator, events *EventService, sessionID string) *bufferedSink { - return &bufferedSink{ids: ids, events: events, sessionID: sessionID, previewTypes: map[string]string{}} -} - -// PreviewStart announces a streamed assistant message. eventID is the committed -// id the persisted agent.message will carry, so the preview and the eventual -// event correlate by that shared id. -func (s *bufferedSink) PreviewStart(eventID, eventType string) { - if s.events == nil { - return - } - s.previewTypes[eventID] = eventType - s.events.PublishPreview(s.sessionID, domain.PreviewFrame{ - Kind: domain.PreviewEventStart, - EventID: eventID, - EventType: eventType, - }) -} - -// PreviewDelta forwards one incremental text chunk of the streamed message. The -// frame carries the previewed event's type (recorded at PreviewStart) so the hub -// delivers it to the same opted-in subscribers; the delta wire shape itself does -// not surface the type. -func (s *bufferedSink) PreviewDelta(eventID string, index int, text string) { - if s.events == nil { - return - } - s.events.PublishPreview(s.sessionID, domain.PreviewFrame{ - Kind: domain.PreviewEventDelta, - EventID: eventID, - EventType: s.previewTypes[eventID], - Index: index, - Text: text, - }) -} - -func (s *bufferedSink) Emit(_ context.Context, drafts []domain.EventDraft) ([]domain.Event, error) { - events := make([]domain.Event, 0, len(drafts)) - for _, draft := range drafts { - if draft.ID == "" { - draft.ID = s.ids.NewID(domain.PrefixEvent) - } - s.drafts = append(s.drafts, draft) - events = append(events, domain.Event{ID: draft.ID, Type: draft.Type, Payload: draft.Payload}) - } - return events, nil -} - -func (s *bufferedSink) Drafts() []domain.EventDraft { - return append([]domain.EventDraft(nil), s.drafts...) -} diff --git a/internal/app/agent_service_test.go b/internal/app/agent_service_test.go index d209474..e63ecc3 100644 --- a/internal/app/agent_service_test.go +++ b/internal/app/agent_service_test.go @@ -9,14 +9,12 @@ import ( "time" "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/store" ) func newAgentService(t *testing.T) *AgentService { t.Helper() - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - return NewAgentService(store.NewAgentRepo(db), domain.NewSeqIDGen(), domain.FixedClock{T: time.Unix(1, 0).UTC()}) + return NewAgentService(newMemoryAgentRepository(), domain.NewSeqIDGen(), + domain.FixedClock{T: time.Unix(1, 0).UTC()}) } func TestAgentService_CreateThenVersionedUpdate(t *testing.T) { diff --git a/internal/app/environment_service_test.go b/internal/app/environment_service_test.go index a4abb6d..7173863 100644 --- a/internal/app/environment_service_test.go +++ b/internal/app/environment_service_test.go @@ -6,37 +6,28 @@ import ( "time" "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/store" ) -func newEnvService(t *testing.T) (*EnvironmentService, *store.SessionRepo) { +func newEnvService(t *testing.T) *EnvironmentService { t.Helper() - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - sr := store.NewSessionRepo(db) - svc := NewEnvironmentService(store.NewEnvironmentRepo(db), + return NewEnvironmentService(newMemoryEnvironmentRepository(), domain.NewSeqIDGen(), domain.FixedClock{T: time.Unix(1, 0).UTC()}) - return svc, sr } func TestEnvironmentService_DeleteReferenced(t *testing.T) { - db, _ := store.OpenMemory() - defer db.Close() - envSvc := NewEnvironmentService(store.NewEnvironmentRepo(db), + repository := newMemoryEnvironmentRepository() + envSvc := NewEnvironmentService(repository, domain.NewSeqIDGen(), domain.FixedClock{T: time.Unix(1, 0).UTC()}) ctx := context.Background() env, _ := envSvc.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - // reference it from a session row - sr := store.NewSessionRepo(db) - _ = sr.Put(ctx, domain.Session{ID: "sesn_1", EnvironmentID: env.ID, Status: domain.StatusIdle, - CreatedAt: time.Unix(1, 0).UTC(), UpdatedAt: time.Unix(1, 0).UTC()}) + repository.markReferenced(env.ID) if err := envSvc.Delete(ctx, env.ID); err == nil { t.Fatal("expected conflict deleting referenced environment") } } func TestEnvironmentService_DeleteUnreferencedSucceeds(t *testing.T) { - svc, _ := newEnvService(t) + svc := newEnvService(t) ctx := context.Background() env, err := svc.Create(ctx, domain.Environment{Name: "unreferenced", ConfigType: "cloud"}) @@ -59,7 +50,7 @@ func TestEnvironmentService_DeleteUnreferencedSucceeds(t *testing.T) { } func TestEnvironmentService_DeleteMissingReturnsNotFound(t *testing.T) { - svc, _ := newEnvService(t) + svc := newEnvService(t) ctx := context.Background() err := svc.Delete(ctx, "env_bogus_id") diff --git a/internal/app/event_service.go b/internal/app/event_service.go deleted file mode 100644 index ab4c521..0000000 --- a/internal/app/event_service.go +++ /dev/null @@ -1,64 +0,0 @@ -package app - -import ( - "context" - - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -type EventService struct { - es *store.EventStore - hub *Hub -} - -func NewEventService(es *store.EventStore, hub *Hub) *EventService { - return &EventService{es: es, hub: hub} -} - -func (s *EventService) Append(ctx context.Context, sessionID string, drafts []domain.EventDraft) ([]domain.Event, error) { - // Persist first (commits inside EventStore.Append). - out, err := s.es.Append(ctx, sessionID, drafts) - if err != nil { - return nil, err - } - s.PublishCommitted(out) - return out, nil -} - -// PublishCommitted publishes already-committed events in sequence order. -func (s *EventService) PublishCommitted(events []domain.Event) { - for _, event := range events { - s.hub.Publish(event.SessionID, event) - } -} - -// PublishPreview forwards a stream-only preview frame to the hub, which delivers -// it only to subscribers that opted in for its event type. Preview frames are a -// live side channel; they are never persisted and never appear in event history. -func (s *EventService) PublishPreview(sessionID string, f domain.PreviewFrame) { - s.hub.PublishPreview(sessionID, f) -} - -func (s *EventService) History(ctx context.Context, sessionID string, afterSeq int64, limit int) ([]domain.Event, error) { - return s.es.History(ctx, sessionID, afterSeq, limit) -} - -// HistoryTail returns the newest `limit` events in chronological order. Used for -// bounded model projection so an over-limit session carries recent context. -func (s *EventService) HistoryTail(ctx context.Context, sessionID string, limit int) ([]domain.Event, error) { - return s.es.HistoryTail(ctx, sessionID, limit) -} - -func (s *EventService) CloseSession(sessionID string, terminal domain.Event) { - s.hub.CloseSession(sessionID, terminal) -} - -// Query lists events applying the public List Events filters. -func (s *EventService) Query(ctx context.Context, sessionID string, q EventQuery) ([]domain.Event, error) { - return s.es.Query(ctx, sessionID, store.EventQuery{ - AfterSeq: q.AfterSeq, BeforeSeq: q.BeforeSeq, Limit: q.Limit, Desc: q.Desc, - Types: q.Types, CreatedAtGt: q.CreatedAtGt, CreatedAtGte: q.CreatedAtGte, - CreatedAtLt: q.CreatedAtLt, CreatedAtLte: q.CreatedAtLte, - }) -} diff --git a/internal/app/event_service_test.go b/internal/app/event_service_test.go deleted file mode 100644 index 5377000..0000000 --- a/internal/app/event_service_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package app - -import ( - "context" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -func newEventService(t *testing.T) *EventService { - t.Helper() - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - es := store.NewEventStore(db, domain.NewSeqIDGen(), domain.FixedClock{T: time.Unix(1, 0).UTC()}) - return NewEventService(es, NewHub(16)) -} - -func TestEventService_PersistThenPublish(t *testing.T) { - s := newEventService(t) - ch, cancel := s.hub.Subscribe("sesn_1", nil) - defer cancel() - ctx := context.Background() - out, err := s.Append(ctx, "sesn_1", []domain.EventDraft{{Type: domain.EvUserMessage}}) - if err != nil { - t.Fatal(err) - } - // event is in history (committed) ... - hist, _ := s.History(ctx, "sesn_1", 0, 100) - if len(hist) != 1 || hist[0].ID != out[0].ID { - t.Fatalf("history missing committed event: %+v", hist) - } - // ... and was published - select { - case f := <-ch: - if f.Event == nil || f.Event.ID != out[0].ID { - t.Fatalf("published wrong event: %#v", f) - } - case <-time.After(time.Second): - t.Fatal("event not published") - } -} diff --git a/internal/app/interrupt.go b/internal/app/interrupt.go deleted file mode 100644 index 68dc172..0000000 --- a/internal/app/interrupt.go +++ /dev/null @@ -1,122 +0,0 @@ -package app - -import ( - "context" - "errors" - "sync" -) - -// errInterrupted is the cancellation cause attached to an active run's context -// when a durably admitted user.interrupt cancels it. It is propagated as the -// cancel cause so the runtime (and its model/tool calls) can observe that a -// deliberate user interrupt — not an unrelated shutdown or deadline — is what -// stopped them. -// -// Classification of the finished run, however, does NOT key on this cause. A -// late interrupt can durably admit in the narrow window after the runtime call -// returns but before the run's completion commits, and context.Cause alone -// cannot linearize that against the completion. Instead the run's canceler token -// carries an explicit finish/interrupt state (see runCanceler) that is resolved -// under the session shard lock, so exactly one of "normal completion" or -// "interrupt" wins. errInterrupted remains the observable cause for the runtime; -// the token state is the source of truth for classification. -var errInterrupted = errors.New("run canceled by user.interrupt") - -// runCancelers tracks the cancel function of the single active run per session so -// a durably admitted user.interrupt can cancel it mid-execution. RunStore's -// at-most-one-running-run-per-session invariant means one canceler per session id -// is sufficient; keying by session id also guarantees one session can never -// cancel another's run. -// -// The map is guarded by its own mutex, so register/finish/cancel from multiple -// drain goroutines are safe and never panic. The *linearization* guarantee comes -// from the caller additionally holding the session's shard lock across -// claim+register and finish+classify+Complete (in drainRuns) and across -// admission+cancel (in SendEvent). Because a run's token records whether an -// interrupt claimed it (interrupted) or the run claimed completion first -// (finished), and both transitions happen under that shard lock, the finish-vs- -// interrupt outcome is unambiguous: whichever ran first under the lock wins. -type runCancelers struct { - mu sync.Mutex - m map[string]*runCanceler -} - -// runCanceler is the per-run token stored in the registry. Identity comparison of -// the pointer lets finish drop only the exact run it owns, so a finished run can -// never remove a newer run's canceler. -// -// interrupted and finished are the two mutually-racing transitions, both mutated -// only while the session shard lock is held (and under the registry mutex): -// - cancel sets interrupted (and invokes cancel) when an interrupt is durably -// admitted before the run claims completion. -// - finish sets finished when the run reaches its completion commit, and -// reports whether interrupted was already set. -// -// Serialized by the shard lock, at most one of these observes the other as unset, -// so the run is classified interrupted iff the interrupt won. -type runCanceler struct { - cancel context.CancelCauseFunc - interrupted bool - finished bool -} - -func newRunCancelers() *runCancelers { - return &runCancelers{m: map[string]*runCanceler{}} -} - -// register records cancel as the active-run canceler for sessionID and returns a -// token to pass back to finish. Given the one-running-run invariant there should -// be no existing entry for the session; if there is (a stale token whose run -// already finished), it is overwritten, which is harmless. Callers invoke this -// under the session shard lock so it is serialized with cancel and finish. -func (r *runCancelers) register(sessionID string, cancel context.CancelCauseFunc) *runCanceler { - tok := &runCanceler{cancel: cancel} - r.mu.Lock() - r.m[sessionID] = tok - r.mu.Unlock() - return tok -} - -// finish marks tok as having claimed completion and reports whether an interrupt -// had already claimed it. It removes the session's canceler only when it is still -// tok (identity check), so a finished run cannot drop a newer run's registration. -// -// finish MUST be called while holding the session shard lock, so it is serialized -// with SendEvent's admit+cancel under the same lock: if the interrupt's cancel -// ran first, tok.interrupted is already set and finish returns true (classify -// interrupted); if finish runs first, tok.finished is set and the later cancel is -// a no-op (normal completion wins, the interrupt is handled by its own control -// run). Because the whole finish→classify→Complete sequence is under the shard -// lock, no interrupt can admit between classification and the completion commit. -func (r *runCancelers) finish(sessionID string, tok *runCanceler) bool { - r.mu.Lock() - defer r.mu.Unlock() - tok.finished = true - if r.m[sessionID] == tok { - delete(r.m, sessionID) - } - return tok.interrupted -} - -// cancel cancels the session's active run (if one is registered and not yet -// finished) with the given cause, and records the interrupt on the token so a -// concurrent finish classifies the run as interrupted. It is an idempotent no-op -// when nothing is registered — an interrupt admitted while the session has no -// active run — or when the active run has already claimed completion (normal -// completion won the race). It cancels only the named session. The stored cancel -// func is invoked outside the registry mutex. -// -// cancel MUST be called while holding the session shard lock (in SendEvent, after -// the interrupt is durably admitted), so it is serialized with finish. -func (r *runCancelers) cancel(sessionID string, cause error) { - r.mu.Lock() - tok := r.m[sessionID] - fire := tok != nil && !tok.finished - if fire { - tok.interrupted = true - } - r.mu.Unlock() - if fire { - tok.cancel(cause) - } -} diff --git a/internal/app/interrupt_test.go b/internal/app/interrupt_test.go deleted file mode 100644 index a57ba64..0000000 --- a/internal/app/interrupt_test.go +++ /dev/null @@ -1,910 +0,0 @@ -package app - -import ( - "context" - "strings" - "sync" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/model" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -// newSessionServiceWithRuntime builds a SessionService over in-memory stores with -// a caller-supplied runtime, so interrupt tests can inject a runtime that blocks -// until its context is canceled. -func newSessionServiceWithRuntime(t *testing.T, rt agentruntime.AgentRuntime) (*SessionService, string, string) { - t.Helper() - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - ss := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, store.NewRunStore(db, ids, clk), rt, sandbox.NewLocalProvider(), ids, clk, - ) - ag, err := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - if err != nil { - t.Fatal(err) - } - env, err := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - return ss, ag.ID, env.ID -} - -type blockEnd struct { - sessionID string - cause error -} - -// blockingRuntime blocks a user.message whose text contains "block" until its -// context is canceled, recording the cancellation cause per session. Every other -// user.message echoes and idles; a user.interrupt emits the single idle end_turn -// the way the real runtime's no-op interrupt handling does. It lets a test hold a -// run mid-execution, admit a user.interrupt, and observe the cancellation the -// interrupt propagates — scoped to the right session. -type blockingRuntime struct { - started chan string // sessionID when a blocking run begins - ended chan blockEnd // sessionID + observed cause when a blocking run unblocks -} - -func newBlockingRuntime(buf int) *blockingRuntime { - return &blockingRuntime{ - started: make(chan string, buf), - ended: make(chan blockEnd, buf), - } -} - -func (r *blockingRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - switch req.Trigger.Type { - case domain.EvUserInterrupt: - _, err := sink.Emit(ctx, []domain.EventDraft{idleEndTurnDraft()}) - return agentruntime.RunOutcome{}, err - case domain.EvUserMessage: - text := contentText(req.Trigger.Payload) - if strings.Contains(text, "block") { - r.started <- req.SessionID - <-ctx.Done() - r.ended <- blockEnd{sessionID: req.SessionID, cause: context.Cause(ctx)} - return agentruntime.RunOutcome{}, ctx.Err() - } - _, err := sink.Emit(ctx, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": textBlocks("reply: " + text)}}, - idleEndTurnDraft(), - }) - return agentruntime.RunOutcome{}, err - default: - return agentruntime.RunOutcome{}, nil - } -} - -// lateInterruptRuntime deterministically reproduces the late-interrupt window: a -// user.message run emits its authoritative output AND a terminal idle draft into -// the buffered sink, then signals the test and blocks in its return path until the -// test releases it. That lets the test admit a user.interrupt in the exact window -// after the runtime has produced its result but before drainRuns commits the -// completion — the window a context.Cause-only check could not linearize. A -// user.interrupt run emits the single idle end_turn like the real no-op handler. -type lateInterruptRuntime struct { - atReturn chan struct{} // signaled once the message run has emitted and is about to return - release chan struct{} // closed by the test to let the message run return -} - -func newLateInterruptRuntime() *lateInterruptRuntime { - return &lateInterruptRuntime{ - atReturn: make(chan struct{}, 1), - release: make(chan struct{}), - } -} - -func (r *lateInterruptRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - switch req.Trigger.Type { - case domain.EvUserInterrupt: - _, err := sink.Emit(ctx, []domain.EventDraft{idleEndTurnDraft()}) - return agentruntime.RunOutcome{}, err - case domain.EvUserMessage: - // Emit an authoritative agent.message plus a terminal idle draft into the - // buffered sink — the runtime "finished" its work. Then signal and block in - // the return path so the test can admit the interrupt before the completion - // commits. The buffered idle draft must be stripped on the interrupted path. - if _, err := sink.Emit(ctx, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": textBlocks("reply: " + contentText(req.Trigger.Payload))}}, - idleEndTurnDraft(), - }); err != nil { - return agentruntime.RunOutcome{}, err - } - r.atReturn <- struct{}{} - <-r.release - return agentruntime.RunOutcome{}, nil - default: - return agentruntime.RunOutcome{}, nil - } -} - -// TestSessionService_LateInterruptWinsClassifiesInterrupted proves the finish-vs- -// admit linearization at the integration level: an interrupt durably admitted in -// the narrow window after the runtime returned its result but before the run's -// completion commits is classified as interrupted. Because the token records the -// interrupt under the shard lock before finish observes it, the completing run -// commits its authoritative agent.message, its own buffered terminal idle draft is -// stripped, and it neither idles the session nor appends a redundant -// session.status_running; the interrupt's own control run produces the single -// public idle{end_turn}. Existing blocking-runtime tests cancel the runtime -// mid-call and so never exercise this post-return window. -func TestSessionService_LateInterruptWinsClassifiesInterrupted(t *testing.T) { - rt := newLateInterruptRuntime() - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("go-late")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Wait until the message run has emitted its output and is parked in its return - // path — this is the late window. - select { - case <-rt.atReturn: - case <-time.After(2 * time.Second): - t.Fatal("message run did not reach its return path") - } - - // Admit the interrupt now: it commits durably and cancels the (already - // returning) run under the shard lock, marking the token interrupted BEFORE - // drainRuns reaches finish. - sent, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}) - if err != nil { - t.Fatalf("SendEvent interrupt: %v", err) - } - - // Release the message run so its completion commits — now classified interrupted. - close(rt.release) - - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - pollUntilEventProcessed(t, ss, sess.ID, sent[0].ID) - - history, err := ss.events.History(ctx, sess.ID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var idleEndTurns, statusRunning int - var sawReply bool - for _, e := range history { - switch e.Type { - case domain.EvSessionError: - t.Fatalf("late interrupt emitted session.error: %#v", e.Payload) - case domain.EvSessionStatusTerminated: - t.Fatalf("late interrupt terminated the session") - case domain.EvSessionStatusRunning: - statusRunning++ - case domain.EvSessionStatusIdle: - stop, _ := e.Payload["stop_reason"].(map[string]any) - if stop["type"] == "end_turn" { - idleEndTurns++ - } - case domain.EvAgentMessage: - if contentBlockText(e.Payload["content"]) == "reply: go-late" { - sawReply = true - } - } - } - // The interrupted run's authoritative output is committed honestly... - if !sawReply { - t.Fatal("interrupted run's buffered agent.message was not committed") - } - // ...but its buffered terminal idle draft was stripped, so exactly one idle - // end_turn exists — from the interrupt's own control run. - if idleEndTurns != 1 { - t.Fatalf("idle end_turn count = %d, want exactly 1 (from the interrupt control run)", idleEndTurns) - } - // Exactly one session.status_running exists — the initial admission's. The - // interrupted completion passed StatusRunning explicitly, so it appended NO - // redundant session.status_running for the still-queued interrupt run. - if statusRunning != 1 { - t.Fatalf("session.status_running count = %d, want exactly 1 (no redundant running from the interrupted completion)", statusRunning) - } -} - -// pollUntilEventProcessed waits until the event with eventID in sessionID is -// marked processed_at, failing the test on timeout. -func pollUntilEventProcessed(t *testing.T, ss *SessionService, sessionID, eventID string) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - hist, err := ss.events.History(context.Background(), sessionID, 0, 100000) - if err == nil { - for _, e := range hist { - if e.ID == eventID && e.ProcessedAt != nil { - return - } - } - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("timed out waiting for event %s in session %s to be processed", eventID, sessionID) -} - -func idleEndTurnDraft() domain.EventDraft { - return domain.EventDraft{ - Type: domain.EvSessionStatusIdle, - Payload: map[string]any{"stop_reason": map[string]any{"type": "end_turn"}}, - } -} - -// TestSessionService_InterruptCancelsActiveRunScopedAndCleansUp proves the core -// cancellation contract: after SendEvent durably admits a user.interrupt, the -// session's currently active run receives a context cancellation carrying the -// errInterrupted cause; the cancellation is scoped to that session only (a second -// session's blocking run is untouched); and the registry is cleaned up so no -// canceler leaks once the session settles. -func TestSessionService_InterruptCancelsActiveRunScopedAndCleansUp(t *testing.T) { - rt := newBlockingRuntime(4) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - // Two sessions each start a run that blocks mid-execution. - sessA, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-A")}}}, - }) - if err != nil { - t.Fatalf("Create A: %v", err) - } - sessB, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-B")}}}, - }) - if err != nil { - t.Fatalf("Create B: %v", err) - } - - // Both blocking runs must be active before we interrupt, so the interrupt hits - // a registered active run rather than a not-yet-claimed one. - started := map[string]bool{} - for i := 0; i < 2; i++ { - select { - case sid := <-rt.started: - started[sid] = true - case <-time.After(2 * time.Second): - t.Fatal("blocking runs did not both start") - } - } - if !started[sessA.ID] || !started[sessB.ID] { - t.Fatalf("expected both sessions to start, got %v", started) - } - - // Interrupt only session A. - if _, err := ss.SendEvent(ctx, sessA.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}); err != nil { - t.Fatalf("SendEvent interrupt A: %v", err) - } - - // Exactly session A's run unblocks, and it observed the interrupt cause. - select { - case end := <-rt.ended: - if end.sessionID != sessA.ID { - t.Fatalf("wrong session canceled: got %s, want %s", end.sessionID, sessA.ID) - } - if end.cause != errInterrupted { - t.Fatalf("cancel cause = %v, want errInterrupted", end.cause) - } - case <-time.After(2 * time.Second): - t.Fatal("session A's run was not canceled by the interrupt") - } - - // Session B must NOT be canceled by session A's interrupt: no further ended - // signal arrives within a window. - select { - case end := <-rt.ended: - t.Fatalf("session B run canceled by session A interrupt: %+v", end) - case <-time.After(200 * time.Millisecond): - } - - // Session A settles to idle (canceled run completed, then the interrupt run - // idled). Its canceler must be cleaned up — no leak. - pollUntilStatus(t, ss, sessA.ID, domain.StatusIdle) - ss.cancelers.mu.Lock() - _, present := ss.cancelers.m[sessA.ID] - ss.cancelers.mu.Unlock() - if present { - t.Fatal("session A canceler leaked after the run settled") - } - - // Clean up session B by interrupting it so its blocking goroutine exits. - if _, err := ss.SendEvent(ctx, sessB.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}); err != nil { - t.Fatalf("SendEvent interrupt B: %v", err) - } - select { - case end := <-rt.ended: - if end.sessionID != sessB.ID || end.cause != errInterrupted { - t.Fatalf("session B cleanup cancel = %+v", end) - } - case <-time.After(2 * time.Second): - t.Fatal("session B did not cancel on its own interrupt") - } - pollUntilStatus(t, ss, sessB.ID, domain.StatusIdle) -} - -// TestSessionService_InterruptBatchRedirectRunsNormally is the end-to-end batch -// case: while a run is blocking, one events request carries [user.interrupt, -// user.message]. The blocking run cancels (no session.error / terminated); the -// interrupt event is marked processed; the redirect message then runs normally -// and its agent reply persists. -func TestSessionService_InterruptBatchRedirectRunsNormally(t *testing.T) { - rt := newBlockingRuntime(4) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-me")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - select { - case <-rt.started: - case <-time.After(2 * time.Second): - t.Fatal("initial blocking run did not start") - } - - sent, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{ - {Type: domain.EvUserInterrupt, Payload: map[string]any{}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("redirect")}}, - }) - if err != nil { - t.Fatalf("SendEvent batch: %v", err) - } - interruptID := sent[0].ID - - // The blocking run cancels on the interrupt. - select { - case end := <-rt.ended: - if end.cause != errInterrupted { - t.Fatalf("cancel cause = %v, want errInterrupted", end.cause) - } - case <-time.After(2 * time.Second): - t.Fatal("blocking run was not canceled by the interrupt") - } - - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - history, err := ss.events.History(ctx, sess.ID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var sawRedirectReply, interruptProcessed bool - for _, e := range history { - switch e.Type { - case domain.EvSessionError: - t.Fatalf("interrupt cancellation emitted session.error: %#v", e.Payload) - case domain.EvSessionStatusTerminated: - t.Fatalf("interrupt cancellation terminated the session") - case domain.EvAgentMessage: - if contentBlockText(e.Payload["content"]) == "reply: redirect" { - sawRedirectReply = true - } - } - if e.ID == interruptID && e.ProcessedAt != nil { - interruptProcessed = true - } - } - if !interruptProcessed { - t.Fatal("user.interrupt was not marked processed") - } - if !sawRedirectReply { - t.Fatal("redirect message did not run to an agent reply") - } -} - -// TestSessionService_InterruptOnlyEndsSingleIdleAndAllowsArchiveDelete proves the -// public handoff shape for an interrupt with no redirect: the canceled work -// contributes NO idle terminal, and the interrupt's own run produces exactly one -// session.status_idle{end_turn}. Afterward the idle session can be archived and -// deleted. -func TestSessionService_InterruptOnlyEndsSingleIdleAndAllowsArchiveDelete(t *testing.T) { - rt := newBlockingRuntime(4) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-solo")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - select { - case <-rt.started: - case <-time.After(2 * time.Second): - t.Fatal("blocking run did not start") - } - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}); err != nil { - t.Fatalf("SendEvent interrupt: %v", err) - } - select { - case end := <-rt.ended: - if end.cause != errInterrupted { - t.Fatalf("cancel cause = %v, want errInterrupted", end.cause) - } - case <-time.After(2 * time.Second): - t.Fatal("run was not canceled by the interrupt") - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - history, err := ss.events.History(ctx, sess.ID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var idleEndTurns int - for _, e := range history { - if e.Type == domain.EvSessionError || e.Type == domain.EvSessionStatusTerminated { - t.Fatalf("interrupt produced %s", e.Type) - } - if e.Type == domain.EvSessionStatusIdle { - stop, _ := e.Payload["stop_reason"].(map[string]any) - if stop["type"] != "end_turn" { - t.Fatalf("idle stop_reason = %#v, want end_turn", stop) - } - idleEndTurns++ - } - } - if idleEndTurns != 1 { - t.Fatalf("expected exactly one idle end_turn from the interruption, got %d", idleEndTurns) - } - - // The interrupted session is idle: archive and delete must succeed. - if _, err := ss.Archive(ctx, sess.ID); err != nil { - t.Fatalf("Archive after interrupt: %v", err) - } - if err := ss.Delete(ctx, sess.ID); err != nil { - t.Fatalf("Delete after interrupt: %v", err) - } -} - -// TestSessionService_InterruptWhileIdleIsNoOpControlEvent proves an interrupt that -// arrives with no active run is a safe control event: it is durably processed, the -// session stays idle, and the model is never invoked. Uses the real AgentCore over -// a recording fake model to prove no model call happens. -func TestSessionService_InterruptWhileIdleIsNoOpControlEvent(t *testing.T) { - fakeModel := model.NewFake() - ids := domain.NewSeqIDGen() - rt := agentruntime.NewAgentCore(fakeModel, ids) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - if sess.Status != domain.StatusIdle { - t.Fatalf("session not idle at start: %s", sess.Status) - } - - sent, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}) - if err != nil { - t.Fatalf("SendEvent interrupt: %v", err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // The interrupt run must not have called the model. - if got := fakeModel.LastRequest(); len(got.Messages) != 0 { - t.Fatalf("interrupt-while-idle invoked the model: %#v", got.Messages) - } - // The interrupt event is durably processed and the session did not terminate. - history, err := ss.events.History(ctx, sess.ID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var processed bool - for _, e := range history { - if e.Type == domain.EvSessionStatusTerminated || e.Type == domain.EvSessionError { - t.Fatalf("idle interrupt produced %s", e.Type) - } - if e.Type == domain.EvAgentMessage { - t.Fatalf("interrupt-while-idle unexpectedly drove a model turn: %#v", e.Payload) - } - if e.ID == sent[0].ID { - processed = e.ProcessedAt != nil - } - } - if !processed { - t.Fatal("idle interrupt was not marked processed") - } -} - -// TestSessionService_DuplicateConcurrentInterruptsSafe fires several interrupts at -// a session concurrently — some batched, some from separate goroutines — while a -// run is blocking. It must not panic, leak, or cross-cancel; the session settles -// to idle. Run under -race to stress the registry synchronization. -func TestSessionService_DuplicateConcurrentInterruptsSafe(t *testing.T) { - rt := newBlockingRuntime(64) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-dup")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - select { - case <-rt.started: - case <-time.After(2 * time.Second): - t.Fatal("blocking run did not start") - } - - var wg sync.WaitGroup - for i := 0; i < 8; i++ { - wg.Add(1) - go func() { - defer wg.Done() - // Some goroutines send a batch of two interrupts, some a single one. - drafts := []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}} - if i%2 == 0 { - drafts = append(drafts, domain.EventDraft{Type: domain.EvUserInterrupt, Payload: map[string]any{}}) - } - _, _ = ss.SendEvent(ctx, sess.ID, drafts) - }() - } - wg.Wait() - - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // No canceler leak after everything settles. - ss.cancelers.mu.Lock() - _, present := ss.cancelers.m[sess.ID] - ss.cancelers.mu.Unlock() - if present { - t.Fatal("canceler leaked after concurrent interrupts settled") - } -} - -// TestRunCancelers_FinishBeforeCancelIsNormalCompletion is the deterministic unit -// proof of the finish-vs-interrupt linearization when NORMAL COMPLETION wins. It -// reproduces the exact late window blocker 1 described: the run reaches its -// completion (finish) and only afterward does an interrupt durably admit and -// cancel. finish must report NOT interrupted, and the later cancel must be an -// idempotent no-op that does not invoke the (already-finished) run's cancel func. -// Both transitions are the ones drainRuns/SendEvent run under the shard lock, so -// this asserts the state machine directly without any timing. -func TestRunCancelers_FinishBeforeCancelIsNormalCompletion(t *testing.T) { - r := newRunCancelers() - var canceled bool - tok := r.register("sess", func(error) { canceled = true }) - - // Completion wins first. - if interrupted := r.finish("sess", tok); interrupted { - t.Fatal("finish reported interrupted before any interrupt was admitted") - } - // A late interrupt now admits and cancels: it must not fire the finished run's - // cancel func and must not retroactively mark it interrupted. - r.cancel("sess", errInterrupted) - if canceled { - t.Fatal("cancel fired the already-finished run's cancel func") - } - if tok.interrupted { - t.Fatal("a post-completion interrupt reclassified the finished run") - } - // The token was removed on finish: no canceler leak. - r.mu.Lock() - _, present := r.m["sess"] - r.mu.Unlock() - if present { - t.Fatal("finish did not remove the canceler") - } -} - -// TestRunCancelers_CancelBeforeFinishIsInterrupt is the mirror: the interrupt -// wins first (cancel), then the run reaches finish. cancel must fire the run's -// cancel func exactly once and record the interrupt; finish must then report -// interrupted so drainRuns classifies the run interrupted and emits no idle of -// its own. -func TestRunCancelers_CancelBeforeFinishIsInterrupt(t *testing.T) { - r := newRunCancelers() - var cancels int - var cause error - tok := r.register("sess", func(c error) { cancels++; cause = c }) - - r.cancel("sess", errInterrupted) - if cancels != 1 || cause != errInterrupted { - t.Fatalf("cancel = %d calls cause=%v, want 1 call errInterrupted", cancels, cause) - } - if interrupted := r.finish("sess", tok); !interrupted { - t.Fatal("finish did not report the interrupt that won the race") - } - // Idempotent: a duplicate interrupt after finish must not fire cancel again. - r.cancel("sess", errInterrupted) - if cancels != 1 { - t.Fatalf("cancel fired again after finish: %d calls", cancels) - } -} - -// TestRunCancelers_CancelWithNoActiveRunIsNoOp proves an interrupt admitted while -// the session has no registered active run (interrupt while idle) is a safe no-op: -// nothing to cancel, no panic. -func TestRunCancelers_CancelWithNoActiveRunIsNoOp(t *testing.T) { - r := newRunCancelers() - r.cancel("sess", errInterrupted) // must not panic -} - -// bufferingBlockingRuntime emits an agent.message AND a terminal -// session.status_idle draft, then blocks until its context is canceled. It models -// a Fake/custom runtime that buffered a terminal status draft before an interrupt -// won the completion race (blocker 3): the app must strip that buffered idle so -// the interrupt's own control run is the single public idle, and must not append a -// synthetic session.status_running for the still-queued interrupt run (blocker 2). -type bufferingBlockingRuntime struct { - started chan string - ended chan blockEnd -} - -func newBufferingBlockingRuntime(buf int) *bufferingBlockingRuntime { - return &bufferingBlockingRuntime{started: make(chan string, buf), ended: make(chan blockEnd, buf)} -} - -func (r *bufferingBlockingRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - switch req.Trigger.Type { - case domain.EvUserInterrupt: - _, err := sink.Emit(ctx, []domain.EventDraft{idleEndTurnDraft()}) - return agentruntime.RunOutcome{}, err - case domain.EvUserMessage: - // Buffer authoritative output AND a terminal idle before blocking, so the - // interrupt races a runtime that already staged its own terminal status. - if _, err := sink.Emit(ctx, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": textBlocks("partial")}}, - idleEndTurnDraft(), - }); err != nil { - return agentruntime.RunOutcome{}, err - } - r.started <- req.SessionID - <-ctx.Done() - r.ended <- blockEnd{sessionID: req.SessionID, cause: context.Cause(ctx)} - return agentruntime.RunOutcome{}, ctx.Err() - default: - return agentruntime.RunOutcome{}, nil - } -} - -// TestSessionService_InterruptStripsBufferedTerminalAndNoRedundantRunning is the -// strongly-controlled event-shape regression for blockers 2 and 3. A runtime -// buffers agent.message + session.status_idle then blocks; an interrupt cancels -// it. The interrupted run's buffered idle must be stripped (so the interrupt's own -// control run is the single public session.status_idle{end_turn}) and its -// completion must NOT append a synthetic session.status_running for the queued -// interrupt run. The session's authoritative agent.message stays committed. -func TestSessionService_InterruptStripsBufferedTerminalAndNoRedundantRunning(t *testing.T) { - rt := newBufferingBlockingRuntime(4) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-buf")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - select { - case <-rt.started: - case <-time.After(2 * time.Second): - t.Fatal("blocking run did not start") - } - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}); err != nil { - t.Fatalf("SendEvent interrupt: %v", err) - } - select { - case end := <-rt.ended: - if end.cause != errInterrupted { - t.Fatalf("cancel cause = %v, want errInterrupted", end.cause) - } - case <-time.After(2 * time.Second): - t.Fatal("run was not canceled by the interrupt") - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - history, err := ss.events.History(ctx, sess.ID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var idleEndTurns, statusRunning, agentMessages int - for _, e := range history { - switch e.Type { - case domain.EvSessionError, domain.EvSessionStatusTerminated: - t.Fatalf("interrupt produced %s", e.Type) - case domain.EvSessionStatusRunning: - statusRunning++ - case domain.EvAgentMessage: - agentMessages++ - case domain.EvSessionStatusIdle: - stop, _ := e.Payload["stop_reason"].(map[string]any) - if stop["type"] != "end_turn" { - t.Fatalf("idle stop_reason = %#v, want end_turn", stop) - } - idleEndTurns++ - } - } - // Exactly one status_running (admission of the original message); the canceled - // run's completion must NOT add a second one for the queued interrupt run. - if statusRunning != 1 { - t.Fatalf("session.status_running count = %d, want 1 (no synthetic running on interrupted completion)", statusRunning) - } - // Exactly one idle end_turn (from the interrupt's control run); the runtime's - // buffered idle draft must have been stripped from the interrupted run. - if idleEndTurns != 1 { - t.Fatalf("idle end_turn count = %d, want 1 (buffered terminal stripped)", idleEndTurns) - } - // The authoritative partial agent.message stays committed honestly. - if agentMessages != 1 { - t.Fatalf("agent.message count = %d, want 1 (buffered authoritative draft kept)", agentMessages) - } -} - -// TestSessionService_InterruptStreamPreservesCommitOrder guards the live-stream -// ordering guarantee the publish-under-lock change provides: for a single session, -// every committed Admit/ClaimNext/Complete result is published while holding the -// shard lock, in commit order, so a subscriber observes persisted events in -// strictly increasing durable Sequence across an interrupt+cancel. Here a run -// blocks after buffering higher-sequence output, then an interrupt is admitted and -// cancels it; the subscriber must see the lower-sequence user.interrupt before the -// canceled run's later-sequence agent.message/idle, and the sequence must never -// regress. -// -// This asserts the invariant rather than forcing the pre-fix interleave: the racy -// window (SendEvent publishing after unlocking while the canceled drain publishes -// its higher-sequence completion) cannot be driven deterministically without a -// hook inside RunStore.Complete, which is out of scope. With publishing under the -// lock the ordering holds structurally; the test is a standing guard that also -// runs under `-race`. -func TestSessionService_InterruptStreamPreservesCommitOrder(t *testing.T) { - rt := newBufferingBlockingRuntime(4) - ss, agID, envID := newSessionServiceWithRuntime(t, rt) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("block-order")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Subscribe before the interrupt so every published frame from here on is - // observed. A generous buffer avoids the slow-consumer drop policy. - ch, cancel := ss.events.hub.Subscribe(sess.ID, nil) - defer cancel() - - select { - case <-rt.started: - case <-time.After(2 * time.Second): - t.Fatal("blocking run did not start") - } - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{Type: domain.EvUserInterrupt, Payload: map[string]any{}}}); err != nil { - t.Fatalf("SendEvent interrupt: %v", err) - } - select { - case end := <-rt.ended: - if end.cause != errInterrupted { - t.Fatalf("cancel cause = %v, want errInterrupted", end.cause) - } - case <-time.After(2 * time.Second): - t.Fatal("run was not canceled by the interrupt") - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // Drain the delivered frames and assert non-decreasing durable sequence, and - // that the interrupt was delivered before the canceled run's later output. - deadline := time.After(2 * time.Second) - var lastSeq int64 - var sawInterrupt bool - var interruptSeq int64 - // Collect until the terminal idle end_turn (the interrupt control run's) is seen. - for { - select { - case f := <-ch: - if f.Event == nil { - continue - } - e := f.Event - if e.Sequence <= lastSeq { - t.Fatalf("live stream regressed in sequence: event %s (seq %d) delivered after seq %d", e.Type, e.Sequence, lastSeq) - } - lastSeq = e.Sequence - switch e.Type { - case domain.EvUserInterrupt: - sawInterrupt = true - interruptSeq = e.Sequence - case domain.EvAgentMessage: - // The canceled run's buffered authoritative output. It must arrive - // AFTER the lower-sequence interrupt event on the live stream. - if !sawInterrupt { - t.Fatal("canceled run's agent.message delivered before the user.interrupt on the live stream") - } - if e.Sequence <= interruptSeq { - t.Fatalf("agent.message seq %d not after interrupt seq %d", e.Sequence, interruptSeq) - } - case domain.EvSessionStatusIdle: - stop, _ := e.Payload["stop_reason"].(map[string]any) - if stop["type"] == "end_turn" { - if !sawInterrupt { - t.Fatal("idle end_turn delivered before the user.interrupt on the live stream") - } - return // done: the interrupt control run's terminal idle was delivered last - } - } - case <-deadline: - t.Fatalf("timed out draining stream; sawInterrupt=%v lastSeq=%d", sawInterrupt, lastSeq) - } - } -} - -// ctxCanceledRuntime returns context.Canceled as an ordinary error WITHOUT any -// interrupt ever being admitted. It proves the interrupt detection keys on the -// registered cancel cause (errInterrupted), not merely on a context.Canceled -// error surfacing from the runtime: such a run must take the normal terminate -// path, never the graceful interrupt path. -type ctxCanceledRuntime struct{} - -func (ctxCanceledRuntime) Run( - context.Context, - agentruntime.RunRequest, - agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - return agentruntime.RunOutcome{}, context.Canceled -} - -// TestSessionService_NonInterruptCancellationStillTerminates proves constraint 7: -// a context.Canceled error that did NOT originate from a durably admitted -// interrupt is an ordinary runtime error and terminates the session -// (session.error + session.status_terminated), never a graceful interrupt. -func TestSessionService_NonInterruptCancellationStillTerminates(t *testing.T) { - ss, agID, envID := newSessionServiceWithRuntime(t, ctxCanceledRuntime{}) - ctx := context.Background() - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agID, EnvironmentID: envID, - InitialEvents: []domain.EventDraft{{Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("go")}}}, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusTerminated) - - if !hasEventType(t, ss, sess.ID, domain.EvSessionError) { - t.Fatal("non-interrupt cancellation did not emit session.error") - } - if hasEventType(t, ss, sess.ID, domain.EvSessionStatusIdle) { - t.Fatal("non-interrupt cancellation emitted session.status_idle") - } -} diff --git a/internal/app/recovery.go b/internal/app/recovery.go deleted file mode 100644 index e04212d..0000000 --- a/internal/app/recovery.go +++ /dev/null @@ -1,14 +0,0 @@ -package app - -import "context" - -func (s *SessionService) Recover(ctx context.Context) error { - sessionIDs, err := s.runs.Recover(ctx) - if err != nil { - return err - } - for _, sessionID := range sessionIDs { - s.kick(sessionID) - } - return nil -} diff --git a/internal/app/recovery_test.go b/internal/app/recovery_test.go deleted file mode 100644 index e2472ea..0000000 --- a/internal/app/recovery_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package app - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -func TestRecover_RequeuesAndCompletesInterruptedRun(t *testing.T) { - path := filepath.Join(t.TempDir(), "recovery.db") - db, err := store.Open(path) - if err != nil { - t.Fatal(err) - } - ids := domain.NewRandomIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - runs := store.NewRunStore(db, ids, clk) - ctx := context.Background() - - sr := store.NewSessionRepo(db) - _ = sr.Put(ctx, domain.Session{ID: "sesn_1", Status: domain.StatusIdle, - CreatedAt: clk.T, UpdatedAt: clk.T}) - admission, err := runs.Admit(ctx, "sesn_1", []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{ - "content": []any{map[string]any{"type": "text", "text": "recover"}}, - }}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 1 { - t.Fatalf("admission runs = %d, want 1", len(admission.Runs)) - } - if _, ok, err := runs.ClaimNext(ctx, "sesn_1"); err != nil || !ok { - t.Fatalf("claim before simulated crash: ok=%v err=%v", ok, err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - - reopened, err := store.Open(path) - if err != nil { - t.Fatal(err) - } - defer reopened.Close() - recoveryIDs := domain.NewRandomIDGen() - recoveryEvents := NewEventService( - store.NewEventStore(reopened, recoveryIDs, clk), NewHub(64), - ) - recoveryRuns := store.NewRunStore(reopened, recoveryIDs, clk) - ss := NewSessionService( - store.NewSessionRepo(reopened), store.NewAgentRepo(reopened), - store.NewEnvironmentRepo(reopened), recoveryEvents, recoveryRuns, - agentruntime.NewFake(), sandbox.NewLocalProvider(), recoveryIDs, clk, - ) - if err := ss.Recover(ctx); err != nil { - t.Fatal(err) - } - got := pollUntilStatus(t, ss, "sesn_1", domain.StatusIdle) - if got.Status != domain.StatusIdle { - t.Fatalf("expected recovered idle, got %s", got.Status) - } - run, err := recoveryRuns.Get(ctx, admission.Runs[0].ID) - if err != nil { - t.Fatal(err) - } - if run.State != domain.RunCompleted { - t.Fatalf("recovered run state = %s, want completed", run.State) - } - history, err := recoveryEvents.History(ctx, "sesn_1", 0, 100) - if err != nil { - t.Fatal(err) - } - var sawAgentMessage bool - for _, event := range history { - if event.Type == domain.EvAgentMessage { - sawAgentMessage = true - } - } - if !sawAgentMessage { - t.Fatal("recovered run did not emit agent.message") - } -} diff --git a/internal/app/repositories_test.go b/internal/app/repositories_test.go new file mode 100644 index 0000000..a24f581 --- /dev/null +++ b/internal/app/repositories_test.go @@ -0,0 +1,198 @@ +package app + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/yanpgwang/managed-agent-go/internal/domain" +) + +// These repositories are deliberately test-only. They exercise application +// validation without introducing a second production persistence backend. +type memoryAgentRepository struct { + mu sync.Mutex + versions map[string][]domain.Agent +} + +func newMemoryAgentRepository() *memoryAgentRepository { + return &memoryAgentRepository{versions: make(map[string][]domain.Agent)} +} + +func (r *memoryAgentRepository) PutVersion(_ context.Context, agent domain.Agent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.versions[agent.ID] = append(r.versions[agent.ID], agent) + return nil +} + +func (r *memoryAgentRepository) UpdateVersion( + _ context.Context, + id string, + update func(domain.Agent) (domain.Agent, bool, error), +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + current := versions[len(versions)-1] + next, changed, err := update(current) + if err != nil { + return domain.Agent{}, err + } + if !changed { + return current, nil + } + next.Version = current.Version + 1 + r.versions[id] = append(versions, next) + return next, nil +} + +func (r *memoryAgentRepository) Archive( + _ context.Context, + id string, + at time.Time, +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + if versions[len(versions)-1].ArchivedAt == nil { + for index := range versions { + versions[index].ArchivedAt = &at + versions[index].UpdatedAt = at + } + r.versions[id] = versions + } + return versions[len(versions)-1], nil +} + +func (r *memoryAgentRepository) Latest(_ context.Context, id string) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + return versions[len(versions)-1], nil +} + +func (r *memoryAgentRepository) GetVersion( + _ context.Context, + id string, + version int, +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, agent := range r.versions[id] { + if agent.Version == version { + return agent, nil + } + } + return domain.Agent{}, domain.NotFound("agent version not found") +} + +func (r *memoryAgentRepository) Versions( + _ context.Context, + id string, +) ([]domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return nil, domain.NotFound("agent not found") + } + return append([]domain.Agent(nil), versions...), nil +} + +func (r *memoryAgentRepository) List(_ context.Context) ([]domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + agents := make([]domain.Agent, 0, len(r.versions)) + for _, versions := range r.versions { + agents = append(agents, versions[len(versions)-1]) + } + sort.Slice(agents, func(i, j int) bool { + return agents[i].CreatedAt.Before(agents[j].CreatedAt) || + (agents[i].CreatedAt.Equal(agents[j].CreatedAt) && agents[i].ID < agents[j].ID) + }) + return agents, nil +} + +type memoryEnvironmentRepository struct { + mu sync.Mutex + values map[string]domain.Environment + referenced map[string]bool +} + +func newMemoryEnvironmentRepository() *memoryEnvironmentRepository { + return &memoryEnvironmentRepository{ + values: make(map[string]domain.Environment), + referenced: make(map[string]bool), + } +} + +func (r *memoryEnvironmentRepository) Put( + _ context.Context, + environment domain.Environment, +) error { + r.mu.Lock() + defer r.mu.Unlock() + r.values[environment.ID] = environment + return nil +} + +func (r *memoryEnvironmentRepository) Get( + _ context.Context, + id string, +) (domain.Environment, error) { + r.mu.Lock() + defer r.mu.Unlock() + environment, ok := r.values[id] + if !ok { + return domain.Environment{}, domain.NotFound("environment not found") + } + return environment, nil +} + +func (r *memoryEnvironmentRepository) List(_ context.Context) ([]domain.Environment, error) { + r.mu.Lock() + defer r.mu.Unlock() + environments := make([]domain.Environment, 0, len(r.values)) + for _, environment := range r.values { + environments = append(environments, environment) + } + sort.Slice(environments, func(i, j int) bool { + return environments[i].CreatedAt.Before(environments[j].CreatedAt) || + (environments[i].CreatedAt.Equal(environments[j].CreatedAt) && + environments[i].ID < environments[j].ID) + }) + return environments, nil +} + +func (r *memoryEnvironmentRepository) DeleteIfUnreferenced( + _ context.Context, + id string, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.values[id]; !ok { + return domain.NotFound("environment not found") + } + if r.referenced[id] { + return domain.Conflict("environment is referenced by a session") + } + delete(r.values, id) + return nil +} + +func (r *memoryEnvironmentRepository) markReferenced(id string) { + r.mu.Lock() + defer r.mu.Unlock() + r.referenced[id] = true +} diff --git a/internal/app/sandbox_test_helpers_test.go b/internal/app/sandbox_test_helpers_test.go deleted file mode 100644 index f4f02bd..0000000 --- a/internal/app/sandbox_test_helpers_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package app - -import ( - "context" - "sync/atomic" - - "github.com/yanpgwang/managed-agent-go/internal/sandbox" -) - -// provisionCountingProvider wraps a Provider and counts Create calls so a test -// can assert a session creates its logical sandbox exactly once across -// repeated runs. -type provisionCountingProvider struct { - inner sandbox.Provider - provisions atomic.Int64 -} - -func (p *provisionCountingProvider) Name() string { return p.inner.Name() } - -func (p *provisionCountingProvider) Create( - ctx context.Context, - sessionKey string, - spec sandbox.Spec, -) (sandbox.Ref, sandbox.Sandbox, error) { - p.provisions.Add(1) - return p.inner.Create(ctx, sessionKey, spec) -} - -func (p *provisionCountingProvider) Attach( - ctx context.Context, - sessionKey string, - ref sandbox.Ref, - spec sandbox.Spec, -) (sandbox.Sandbox, error) { - return p.inner.Attach(ctx, sessionKey, ref, spec) -} - -func (p *provisionCountingProvider) count() int64 { return p.provisions.Load() } - -// destroyCountingProvider hands out sandboxes that count their own Destroy calls -// so a test can assert session deletion tears the sandbox down exactly once. -type destroyCountingProvider struct { - inner sandbox.Provider - destroys atomic.Int64 -} - -func (p *destroyCountingProvider) Name() string { return p.inner.Name() } - -func (p *destroyCountingProvider) Create( - ctx context.Context, - sessionKey string, - spec sandbox.Spec, -) (sandbox.Ref, sandbox.Sandbox, error) { - ref, box, err := p.inner.Create(ctx, sessionKey, spec) - if err != nil { - return sandbox.Ref{}, nil, err - } - return ref, &destroyCountingSandbox{inner: box, provider: p}, nil -} - -func (p *destroyCountingProvider) Attach( - ctx context.Context, - sessionKey string, - ref sandbox.Ref, - spec sandbox.Spec, -) (sandbox.Sandbox, error) { - box, err := p.inner.Attach(ctx, sessionKey, ref, spec) - if err != nil { - return nil, err - } - return &destroyCountingSandbox{inner: box, provider: p}, nil -} - -func (p *destroyCountingProvider) destroyCount() int64 { return p.destroys.Load() } - -type destroyCountingSandbox struct { - inner sandbox.Sandbox - provider *destroyCountingProvider -} - -func (s *destroyCountingSandbox) Exec(ctx context.Context, cmd sandbox.Command) (*sandbox.Result, error) { - return s.inner.Exec(ctx, cmd) -} -func (s *destroyCountingSandbox) ReadFile(ctx context.Context, path string) ([]byte, error) { - return s.inner.ReadFile(ctx, path) -} -func (s *destroyCountingSandbox) WriteFile(ctx context.Context, path string, data []byte) error { - return s.inner.WriteFile(ctx, path, data) -} -func (s *destroyCountingSandbox) Root() string { return s.inner.Root() } -func (s *destroyCountingSandbox) Destroy(ctx context.Context) error { - s.provider.destroys.Add(1) - return s.inner.Destroy(ctx) -} diff --git a/internal/app/session_sandbox_test.go b/internal/app/session_sandbox_test.go deleted file mode 100644 index 403c1f9..0000000 --- a/internal/app/session_sandbox_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package app - -import ( - "context" - "strings" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -// fileToolRuntime is a test AgentRuntime that exercises the request sandbox -// directly. It interprets the trigger's user text as a tiny script: -// -// "write " writes data to path in the sandbox, then -// "read " reads path and emits its contents as an agent.message. -// -// It lets a test prove that filesystem state a tool creates in one run is (or is -// not) visible to a later run through the sandbox the app hands the runtime. -type fileToolRuntime struct{} - -func (fileToolRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - text := triggerText(req.Trigger) - fields := strings.Fields(text) - reply := "" - if len(fields) >= 2 && req.Sandbox != nil { - switch fields[0] { - case "write": - data := "" - if len(fields) >= 3 { - data = strings.Join(fields[2:], " ") - } - if err := req.Sandbox.WriteFile(ctx, fields[1], []byte(data)); err != nil { - reply = "write-error: " + err.Error() - } else { - reply = "wrote " + fields[1] - } - case "read": - data, err := req.Sandbox.ReadFile(ctx, fields[1]) - if err != nil { - reply = "read-error: " + err.Error() - } else { - reply = "read: " + string(data) - } - } - } - _, err := sink.Emit(ctx, []domain.EventDraft{{ - Type: domain.EvAgentMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": reply}}}, - }}) - return agentruntime.RunOutcome{}, err -} - -func triggerText(ev domain.Event) string { - if text, ok := ev.Payload["text"].(string); ok { - return text - } - blocks, _ := ev.Payload["content"].([]any) - for _, b := range blocks { - if m, ok := b.(map[string]any); ok { - if t, _ := m["text"].(string); t != "" { - return t - } - } - } - return "" -} - -// toolAgent creates an agent whose toolset is non-empty so the session provisions -// a sandbox, plus a cloud environment. Returns their ids. -func toolAgentAndEnv(t *testing.T, as *AgentService, envs *EnvironmentService) (string, string) { - t.Helper() - ctx := context.Background() - ag, err := as.Create(ctx, domain.Agent{ - Name: "tool-agent", - Model: domain.Model{ID: "claude-opus-4-8"}, - Tools: []any{map[string]any{"type": domain.BuiltinToolsetType}}, - }) - if err != nil { - t.Fatal(err) - } - env, err := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - return ag.ID, env.ID -} - -// lastAgentText returns the text of the most recent agent.message in history. -func lastAgentText(t *testing.T, ss *SessionService, sessionID string) string { - t.Helper() - hist, err := ss.events.History(context.Background(), sessionID, 0, 100000) - if err != nil { - t.Fatal(err) - } - var text string - for _, e := range hist { - if e.Type == domain.EvAgentMessage { - text = contentBlockText(e.Payload["content"]) - } - } - return text -} - -func newFileToolService(t *testing.T) (*SessionService, *AgentService, *EnvironmentService, *sandbox.SessionManager) { - t.Helper() - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - es := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - as := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - ss := NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - es, store.NewRunStore(db, ids, clk), fileToolRuntime{}, sandbox.NewLocalProvider(), ids, clk) - return ss, as, envs, ss.sandbox -} - -// TestSessionService_SandboxPersistsAcrossRuns proves the core session-scoped -// property: a file written by the first run is readable by a later run in the -// same session. -func TestSessionService_SandboxPersistsAcrossRuns(t *testing.T) { - ss, as, envs, _ := newFileToolService(t) - ctx := context.Background() - agID, envID := toolAgentAndEnv(t, as, envs) - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - // Release the session's sandbox so its temp dir does not leak; Release is - // idempotent, so a later Delete in the test is still fine. - t.Cleanup(func() { _ = ss.sandbox.Release(context.Background(), sess.ID) }) - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "write state.txt hello-across-runs"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "read state.txt"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - if got := lastAgentText(t, ss, sess.ID); got != "read: hello-across-runs" { - t.Fatalf("second run read %q, want the file written by the first run", got) - } -} - -// TestSessionService_SandboxIsolatedBetweenSessions proves a file written in one -// session's sandbox is not visible from a different session, even when both use -// the same agent and environment. -func TestSessionService_SandboxIsolatedBetweenSessions(t *testing.T) { - ss, as, envs, _ := newFileToolService(t) - ctx := context.Background() - agID, envID := toolAgentAndEnv(t, as, envs) - - writer, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = ss.sandbox.Release(context.Background(), writer.ID) }) - if _, err := ss.SendEvent(ctx, writer.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "write secret.txt only-in-writer"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, writer.ID, domain.StatusIdle) - - other, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = ss.sandbox.Release(context.Background(), other.ID) }) - if _, err := ss.SendEvent(ctx, other.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "read secret.txt"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, other.ID, domain.StatusIdle) - - got := lastAgentText(t, ss, other.ID) - if !strings.HasPrefix(got, "read-error:") { - t.Fatalf("different session saw %q, want a read error (isolated sandbox)", got) - } -} - -// TestSessionService_SandboxProvisionedOncePerSession proves repeated runs in one -// session reuse a single logical sandbox instead of provisioning a new one each -// run. -func TestSessionService_SandboxProvisionedOncePerSession(t *testing.T) { - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - es := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - as := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - counting := &provisionCountingProvider{inner: sandbox.NewLocalProvider()} - ss := NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - es, store.NewRunStore(db, ids, clk), fileToolRuntime{}, counting, ids, clk) - - ctx := context.Background() - agID, envID := toolAgentAndEnv(t, as, envs) - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = ss.sandbox.Release(context.Background(), sess.ID) }) - - for i := 0; i < 3; i++ { - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "write f.txt data"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - } - - if got := counting.count(); got != 1 { - t.Fatalf("provisioned %d sandboxes across 3 runs, want 1", got) - } -} - -// TestSessionService_IdleDoesNotDestroySandbox proves that reaching idle after a -// run leaves the session's sandbox intact (still holding its files), rather than -// destroying it. -func TestSessionService_IdleDoesNotDestroySandbox(t *testing.T) { - ss, as, envs, mgr := newFileToolService(t) - ctx := context.Background() - agID, envID := toolAgentAndEnv(t, as, envs) - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = mgr.Release(context.Background(), sess.ID) }) - - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "write keep.txt still-here"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // After idle the sandbox must still exist and hold the file: acquiring it - // again returns the same live instance rather than a fresh empty one. - box, err := mgr.Acquire(ctx, sess.ID, sandbox.Spec{}) - if err != nil { - t.Fatal(err) - } - data, err := box.ReadFile(ctx, "keep.txt") - if err != nil || string(data) != "still-here" { - t.Fatalf("sandbox lost its file after idle: data=%q err=%v", data, err) - } -} - -// TestSessionService_DeleteReleasesSandboxExactlyOnce proves deleting a session -// tears its sandbox down exactly once. -func TestSessionService_DeleteReleasesSandboxExactlyOnce(t *testing.T) { - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - es := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - as := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - counting := &destroyCountingProvider{inner: sandbox.NewLocalProvider()} - ss := NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - es, store.NewRunStore(db, ids, clk), fileToolRuntime{}, counting, ids, clk) - - ctx := context.Background() - agID, envID := toolAgentAndEnv(t, as, envs) - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: agID, EnvironmentID: envID}) - if err != nil { - t.Fatal(err) - } - if _, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, Payload: map[string]any{"text": "write f.txt data"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - if err := ss.Delete(ctx, sess.ID); err != nil { - t.Fatal(err) - } - if got := counting.destroyCount(); got != 1 { - t.Fatalf("session delete destroyed the sandbox %d times, want 1", got) - } -} diff --git a/internal/app/session_service.go b/internal/app/session_service.go deleted file mode 100644 index 942b81a..0000000 --- a/internal/app/session_service.go +++ /dev/null @@ -1,768 +0,0 @@ -package app - -import ( - "context" - "hash/maphash" - "log" - "sync" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -type CreateSessionInput struct { - AgentID string - AgentVersion *int // nil -> latest; set -> pinned version - Overrides *domain.AgentOverrides - EnvironmentID string - Title string - Metadata map[string]any - InitialEvents []domain.EventDraft -} - -type ListPage struct { - AgentID string - AgentVersion *int - CreatedAtGt *time.Time - CreatedAtGte *time.Time - CreatedAtLt *time.Time - CreatedAtLte *time.Time - IncludeArchived bool - Statuses []domain.Status - DeploymentID *string - MemoryStoreID *string - Boundary *SessionPageBoundary - Limit int - Desc bool -} - -type SessionPageBoundary struct { - CreatedAt time.Time - ID string - Backward bool -} - -type SessionListPage struct { - Sessions []domain.Session - HasPrev bool - HasNext bool -} - -const sessionLockShardCount = 256 - -// historyProjectionLimit bounds how many events are replayed into the model -// conversation per turn. Projection uses the NEWEST window of this size (see -// RunStore.ModelHistory): an over-limit session carries its most recent causal -// context rather than the oldest events. Compaction is a later slice; until -// then this is a generous ceiling that keeps a single unbounded session from -// OOMing a turn. -const historyProjectionLimit = 10000 - -// sandboxReleaseTimeout bounds the detached sandbox teardown performed during -// session deletion. The durable delete has already committed before teardown -// runs, so teardown executes on a context detached from the request's -// cancellation; this ceiling keeps that detached cleanup finite instead of -// letting a stuck provider Destroy block forever. -const sandboxReleaseTimeout = 30 * time.Second - -type SessionService struct { - sess *store.SessionRepo - agents *store.AgentRepo - envs *store.EnvironmentRepo - events *EventService - runs *store.RunStore - rt agentruntime.AgentRuntime - sandbox *sandbox.SessionManager - ids domain.IDGenerator - clock domain.Clock - - lockSeed maphash.Seed - lockShards [sessionLockShardCount]sync.Mutex - - // cancelers holds the cancel function of the single active run per session so - // a durably admitted user.interrupt can cancel it mid-execution. See - // interrupt.go for the ordering contract with the shard locks above. - cancelers *runCancelers -} - -func NewSessionService(sess *store.SessionRepo, agents *store.AgentRepo, envs *store.EnvironmentRepo, - events *EventService, runs *store.RunStore, rt agentruntime.AgentRuntime, - sandboxProvider sandbox.Provider, ids domain.IDGenerator, clock domain.Clock, -) *SessionService { - return &SessionService{sess: sess, agents: agents, envs: envs, events: events, runs: runs, rt: rt, - sandbox: sandbox.NewSessionManager(sandboxProvider), ids: ids, clock: clock, lockSeed: maphash.MakeSeed(), - cancelers: newRunCancelers()} -} - -func (s *SessionService) lockFor(id string) *sync.Mutex { - index := maphash.String(s.lockSeed, id) % uint64(len(s.lockShards)) - return &s.lockShards[index] -} - -func (s *SessionService) Create(ctx context.Context, in CreateSessionInput) (domain.Session, error) { - if err := validateMetadata(in.Metadata); err != nil { - return domain.Session{}, err - } - var ag domain.Agent - var err error - if in.AgentVersion != nil { - ag, err = s.agents.GetVersion(ctx, in.AgentID, *in.AgentVersion) - } else { - ag, err = s.agents.Latest(ctx, in.AgentID) - } - if err != nil { - return domain.Session{}, domain.Validation("agent not found") - } - if ag.ArchivedAt != nil { - return domain.Session{}, domain.Validation("agent is archived") - } - env, err := s.envs.Get(ctx, in.EnvironmentID) - if err != nil { - return domain.Session{}, domain.Validation("environment not found") - } - if env.ArchivedAt != nil { - return domain.Session{}, domain.Validation("environment is archived") - } - if env.ConfigType == "self_hosted" { - return domain.Session{}, domain.Unsupported("sessions against self_hosted environments are not supported in v0.1") - } - if len(in.InitialEvents) > 50 { - return domain.Session{}, domain.Validation("initial_events exceeds 50") - } - for _, e := range in.InitialEvents { - if !domain.IsInitialEventType(e.Type) { - return domain.Session{}, domain.Validation("initial_events may contain only user.message or user.define_outcome") - } - } - // Resolve the immutable snapshot: pinned/latest version plus any per-session - // overrides. The snapshot keeps the base agent's id and version. - snapshot := ag - if in.Overrides != nil { - snapshot = ag.WithOverrides(*in.Overrides) - } - metadata := in.Metadata - if metadata == nil { - metadata = map[string]any{} - } - now := s.clock.Now().UTC() - sess := domain.Session{ - ID: s.ids.NewID(domain.PrefixSession), - AgentID: ag.ID, - AgentVersion: ag.Version, - EnvironmentID: env.ID, - Status: domain.StatusIdle, - Title: in.Title, - Metadata: metadata, - AgentSnapshot: snapshot, - CreatedAt: now, - UpdatedAt: now, - } - if len(in.InitialEvents) == 0 { - if err := s.sess.CreateIfDependenciesActive(ctx, sess); err != nil { - return domain.Session{}, err - } - return sess, nil - } - admission, err := s.runs.CreateSession(ctx, sess, in.InitialEvents) - if err != nil { - return domain.Session{}, err - } - s.events.PublishCommitted(admission.Events) - if len(admission.Runs) > 0 { - s.kick(sess.ID) - } - return admission.Session, nil -} - -func (s *SessionService) Get(ctx context.Context, id string) (domain.Session, error) { - return s.sess.Get(ctx, id) -} - -func (s *SessionService) List(ctx context.Context, p ListPage) (SessionListPage, error) { - query := store.SessionListQuery{ - AgentID: p.AgentID, - AgentVersion: p.AgentVersion, - CreatedAtGt: p.CreatedAtGt, - CreatedAtGte: p.CreatedAtGte, - CreatedAtLt: p.CreatedAtLt, - CreatedAtLte: p.CreatedAtLte, - IncludeArchived: p.IncludeArchived, - Statuses: p.Statuses, - HasDeploymentFilter: p.DeploymentID != nil, - HasMemoryStoreFilter: p.MemoryStoreID != nil, - Limit: p.Limit, - Desc: p.Desc, - } - if p.Boundary != nil { - query.Boundary = &store.SessionListBoundary{ - CreatedAt: p.Boundary.CreatedAt, - ID: p.Boundary.ID, - Backward: p.Boundary.Backward, - } - } - result, err := s.sess.List(ctx, query) - if err != nil { - return SessionListPage{}, err - } - return SessionListPage{ - Sessions: result.Sessions, - HasPrev: result.HasBefore, - HasNext: result.HasAfter, - }, nil -} - -func (s *SessionService) SendEvent(ctx context.Context, id string, drafts []domain.EventDraft) ([]domain.Event, error) { - lock := s.lockFor(id) - lock.Lock() - admission, err := s.runs.Admit(ctx, id, drafts) - if err != nil { - lock.Unlock() - return nil, err - } - // Publish the admitted events to subscribers BEFORE firing the cancel, while - // still holding the shard lock. Hub publication is nonblocking, and every - // same-session commit (Admit here, ClaimNext/Complete in drainRuns) publishes - // under this same lock in commit order. Publishing the interrupt admission - // before the cancel — and before releasing the lock — guarantees the - // user.interrupt event reaches subscribers ahead of any later-sequence output - // the canceled drain commits when it next takes the lock. Publishing after the - // unlock (as before) left a window where the canceled drain could acquire the - // lock, complete, and publish its higher-sequence output before this admission - // was ever delivered, reordering the live stream relative to durable sequence. - s.events.PublishCommitted(admission.Events) - // The interrupt is durably admitted the moment Admit's transaction committed - // above. Only now — never before durable admission — cancel the session's - // active run, and do it while still holding the shard lock so this cancel is - // serialized against drainRuns' claim+register under the same lock: either the - // active run's canceler is already registered (we cancel it) or the run has not - // yet been claimed (a no-op, and drainRuns will observe the interrupt through - // normal claim ordering). This is what stops a running session from missing the - // interrupt. Cancel is scoped to this session id and is a safe no-op when no run - // is active (interrupt while idle). Repeated cancellation is idempotent. - if containsInterrupt(admission.Events) { - s.cancelers.cancel(id, errInterrupted) - } - lock.Unlock() - - if len(admission.Runs) > 0 { - s.kick(id) - } - // Send Events echoes only the caller-submitted events, not the status event - // emitted by the same admission transaction. - return admission.Events[:len(drafts)], nil -} - -// containsInterrupt reports whether an admitted batch carried a user.interrupt. -func containsInterrupt(events []domain.Event) bool { - for _, e := range events { - if e.Type == domain.EvUserInterrupt { - return true - } - } - return false -} - -func (s *SessionService) UpdateTitle(ctx context.Context, id, title string) (domain.Session, error) { - lock := s.lockFor(id) - lock.Lock() - defer lock.Unlock() - - sess, events, err := s.runs.UpdateTitle(ctx, id, title) - if err != nil { - return domain.Session{}, err - } - s.events.PublishCommitted(events) - return sess, nil -} - -func (s *SessionService) Archive(ctx context.Context, id string) (domain.Session, error) { - lock := s.lockFor(id) - lock.Lock() - defer lock.Unlock() - - sess, err := s.sess.Get(ctx, id) - if err != nil { - return domain.Session{}, err - } - if sess.Status == domain.StatusRunning { - return domain.Session{}, domain.Conflict("cannot archive a running session; interrupt first") - } - now := s.clock.Now().UTC() - sess.ArchivedAt = &now - sess.UpdatedAt = now - return sess, s.sess.Put(ctx, sess) -} - -func (s *SessionService) Delete(ctx context.Context, id string) error { - lock := s.lockFor(id) - lock.Lock() - - sess, err := s.sess.Get(ctx, id) - if err != nil { - lock.Unlock() - return err - } - if sess.Status == domain.StatusRunning { - lock.Unlock() - return domain.Conflict("cannot delete a running session; interrupt first") - } - if err := s.sess.Delete(ctx, id); err != nil { - lock.Unlock() - return err - } - // The durable delete is committed under the shard lock. Drop the lock before - // the external provider teardown and stream close: sandbox Destroy is an - // out-of-process call (a Docker container or host temp dir) that must not - // hold the shard — and thus stall every other session hashing to it — for - // its whole duration. Each path above unlocks exactly once, so there is no - // double unlock. - lock.Unlock() - - // Permanently clean up the session's logical sandbox. Release is idempotent - // and a no-op when the session never provisioned one; when it did, it runs - // the provider teardown exactly once. This is an external call made after the - // durable delete, outside the store transaction and outside the shard lock. - // - // Teardown must not inherit cancellation from the request context. The - // durable delete has already committed, so if the caller cancels (client - // disconnects, request deadline elapses) we still have to finish tearing the - // sandbox down or leak a container / host temp dir. context.WithoutCancel - // detaches from the request's cancellation while preserving its context - // values (trace metadata, etc.); the timeout then bounds the detached - // teardown so a stuck provider Destroy cannot hang deletion forever. - cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sandboxReleaseTimeout) - defer cancel() - if err := s.sandbox.Release(cleanupCtx, id); err != nil { - // A teardown failure can leak sandbox resources (a host temp dir or a - // container). Log it; the session is already durably deleted. - log.Printf("delete: sandbox release failed session_id=%s: %v", id, err) - } - - now := s.clock.Now().UTC() - s.events.CloseSession(id, domain.Event{ - ID: s.ids.NewID(domain.PrefixEvent), - SessionID: id, - Type: domain.EvSessionDeleted, - Payload: map[string]any{}, - CreatedAt: now, - ProcessedAt: &now, - }) - return nil -} - -func (s *SessionService) kick(sessionID string) { - go s.drainRuns(sessionID) -} - -func (s *SessionService) drainRuns(sessionID string) { - // Base context for durable store work (claim, history, sandbox acquire, - // completion). It is deliberately NOT the runtime context: an interrupt - // cancels only the runtime call below, while the completion commit that - // records the interrupted run's buffered output must still run. - ctx := context.Background() - for { - // Claim the next run and register its cancellation token atomically under - // the session's shard lock. This is the other half of the ordering contract - // with SendEvent (see interrupt.go and SendEvent): SendEvent admits an - // interrupt and cancels the active run under the same lock, so once the - // interrupt is durably admitted either this claim has already registered the - // active run's canceler (SendEvent cancels it) or the run is not yet claimed - // (SendEvent's cancel is a no-op and the interrupt is handled through normal - // claim ordering). Claiming without the lock would open a window where a - // just-claimed run has no registered canceler and would miss the interrupt. - lock := s.lockFor(sessionID) - lock.Lock() - claim, ok, err := s.runs.ClaimNext(ctx, sessionID) - if err != nil { - lock.Unlock() - // A claim failure ends the drain loop and leaves the run queued for a - // later kick/restart-recovery. Log it so it does not vanish silently. - log.Printf("drain: claim next run failed session_id=%s: %v", sessionID, err) - return - } - if !ok { - lock.Unlock() - return - } - // runCtx derives from the base context and is canceled (with cause - // errInterrupted) when a durably admitted user.interrupt targets this - // session. Only the runtime call receives it, so cancellation propagates - // through the model and tool calls while the completion commit stays on the - // uncanceled base context. - runCtx, cancelRun := context.WithCancelCause(ctx) - tok := s.cancelers.register(sessionID, cancelRun) - // Publish the claim's committed events (e.g. session.status_running) while - // still holding the shard lock and after the canceler is registered, so the - // live stream preserves durable commit order for this session: every - // same-session commit publishes under this lock in commit order (admission in - // SendEvent, this claim, and completion below). Registering the canceler first - // keeps the interrupt-linearization contract intact; publishing before the - // unlock closes the window where a concurrent SendEvent could otherwise - // interleave its admission publish out of sequence order. - s.events.PublishCommitted(claim.Events) - lock.Unlock() - - runID := claim.Run.ID - - sink := newBufferedSink(s.ids, s.events, sessionID) - var runErr error - var outcome agentruntime.RunOutcome - var toolJournal agentruntime.ToolExecutionJournal - - // Every claimed logical run gets an immutable execution attempt before - // runtime work begins. Recovery may requeue an unfinished logical run, but - // BeginAttempt refuses to cross an active/completed attempt or prior tool - // execution until a recovery policy has classified it. That conservative - // stop prevents a side-effecting tool from being silently replayed. - attempt, attemptErr := s.runs.BeginAttempt(ctx, runID) - if attemptErr != nil { - log.Printf("drain: begin attempt failed session_id=%s run_id=%s: %v", sessionID, runID, attemptErr) - runErr = attemptErr - } else { - toolJournal = runToolJournal{ - durableCtx: ctx, - runs: s.runs, - attemptID: attempt.ID, - } - } - - // Resolve the session's tool configuration from the immutable agent - // snapshot. A parse failure here is a misconfigured session and is - // surfaced as a session error via the terminate path below. - var toolSet domain.ToolSet - if runErr == nil { - var toolErr error - toolSet, toolErr = domain.ParseTools(claim.AgentSnapshot.Tools) - if toolErr != nil { - runErr = toolErr - } - } - - // Resolve the session's logical sandbox when the session has tools. The - // sandbox is scoped to the session, not this run: the first run that needs - // tools provisions it and later runs in the same session reuse the same - // instance, so filesystem state a tool created in an earlier run is visible - // now. Acquisition is an external call made outside any transaction or - // process lock. A provisioning failure terminates the session like any - // other unrecoverable runtime error. The sandbox is NOT destroyed when the - // run ends; it is released only when the session is deleted (see Delete). - var box sandbox.Sandbox - if runErr == nil && toolSetHasTools(toolSet) { - box, err = s.sandbox.Acquire(ctx, sessionID, sandbox.Spec{Timeout: 120 * time.Second}) - if err != nil { - log.Printf("drain: sandbox acquire failed session_id=%s run_id=%s: %v", sessionID, runID, err) - runErr = err - } else { - log.Printf("drain: sandbox acquired session_id=%s run_id=%s", sessionID, runID) - } - } - - if runErr == nil { - // Each claim carries exactly one trigger (admission enqueues one durable - // run per processable event, in admission order). The run projects - // history reconstructed from run causality — RunStore.ModelHistory walks - // prior completed/failed runs in admission order, replaying each run's - // trigger events followed by that run's persisted output events, then - // this run's own trigger. That deliberately differs from raw - // receipt/commit order (EventStore.History): a later trigger admitted - // before this run finished is excluded, so a turn never sees a future - // user message. Because run N commits its output and marks its trigger - // processed before run N+1 is claimed, run N+1's causal history already - // includes run N's committed agent reply. History is read outside the - // runtime call — the server owns history via the event log. - // ProjectMessages merges adjacent same-role events and drops a dangling - // tool_use / orphan tool_result, so each snapshot is a legal Messages-API - // request even when the bounded window cut a pair. The loop below - // iterates the claim's single trigger; the requires_action break still - // parks the run so its awaited result is admitted as the next trigger. - for _, trigger := range claim.Triggers { - history, histErr := s.runs.ModelHistory(ctx, claim.Run, historyProjectionLimit) - if histErr != nil { - runErr = histErr - break - } - // A user.tool_confirmation resumes a parked always_ask built-in. Recover - // the ORIGINAL committed agent.tool_use from server-owned causal history - // (never from client-supplied name/input) so the runtime can execute - // (allow) or reject (deny) it and emit the paired agent.tool_result. The - // event is one of the parked run's output events, so it is present in the - // reconstructed history. A confirmation with no resolvable original action - // leaves ConfirmedToolUse nil; the runtime then fails safely without - // executing anything. - var confirmedToolUse *domain.Event - if trigger.Type == domain.EvUserToolConfirmation { - confirmedToolUse = confirmedToolUseFromHistory(trigger, history) - } - if outcome, runErr = s.rt.Run(runCtx, agentruntime.RunRequest{ - SessionID: sessionID, - Trigger: trigger, - Messages: domain.ProjectMessages(history), - AgentSnapshot: claim.AgentSnapshot, - ToolSet: toolSet, - Sandbox: box, - ConfirmedToolUse: confirmedToolUse, - ToolJournal: toolJournal, - }, sink); runErr != nil { - log.Printf("drain: runtime execution failed session_id=%s run_id=%s: %v", sessionID, runID, runErr) - break - } - // A parked run (custom tool / always_ask) cannot make progress - // until the app admits the awaited result as a new trigger. Stop - // draining this claim's remaining triggers; the terminal - // session.status_idle{requires_action} is appended below. - if outcome.RequiresAction { - break - } - } - } - - if box != nil { - // The sandbox is session-scoped: it deliberately outlives this run so - // the next run in the session sees the filesystem state this run left - // behind. Teardown happens on session deletion (Delete releases it), - // not here. - log.Printf("drain: sandbox retained for session session_id=%s run_id=%s", sessionID, runID) - } - - drafts := sink.Drafts() - - // Linearize finish-vs-interrupt and the completion commit under the session - // shard lock, serialized with SendEvent's admit+cancel under the same lock. - // finish records that this run reached completion and reports whether an - // interrupt had already claimed it (tok.interrupted, set by cancel). Holding - // the lock across finish → classification → RunStore.Complete closes the late - // window the old context.Cause-only check left open: an interrupt can no - // longer durably admit after we classify but before we commit, so a run is - // classified interrupted iff the interrupt won the race, and a run that - // completed first is a normal completion the later interrupt cannot re-open. - // cancelRun(nil) then releases the run context's resources; finish has - // already deregistered the token, so this cannot mask the interrupt state. - lock.Lock() - interrupted := s.cancelers.finish(sessionID, tok) - cancelRun(nil) - - var errorMessage *string - var pendingActionEventIDs []string - - // Close the execution attempt before publishing any buffered tool result or - // closing the logical run. A completed attempt requires all prepared steps - // to have durable results; any unclassified started step therefore stops - // here and leaves the logical run mid-flight for recovery rather than - // claiming success or risking replay. - if attempt.ID != "" { - attemptState := domain.RunAttemptCompleted - var attemptError *string - switch { - case interrupted: - attemptState = domain.RunAttemptInterrupted - case runErr != nil: - attemptState = domain.RunAttemptFailed - message := runErr.Error() - attemptError = &message - } - if _, err := s.runs.FinishAttempt(ctx, attempt.ID, attemptState, attemptError); err != nil { - lock.Unlock() - log.Printf( - "drain: finish attempt failed session_id=%s run_id=%s attempt_id=%s: %v", - sessionID, runID, attempt.ID, err, - ) - return - } - } - - switch { - case interrupted: - // Deliberate user interrupt. This is NOT a failure: commit the - // already-buffered authoritative drafts honestly (a partial agent.message - // the model streamed before cancellation stays committed), close the run - // as completed, and emit no session.error, no session.status_terminated, - // and no idle terminal here. We also drop any RequiresAction outcome that - // raced with the cancellation — no requires_action terminal and no durable - // pending action are persisted from it. A Fake/custom runtime may have - // buffered its own session terminal status draft (e.g. - // session.status_idle) before the interrupt won the completion race; strip - // those so the interrupt's own durable control run is the single public - // idle/end_turn. Authoritative nonterminal drafts (agent.message, - // agent.tool_use, …) are kept honestly. Because more queued work exists - // (the interrupt control run, plus any redirect message), the session - // never actually left running; status is forced to running below so the - // completion neither idles it here nor appends a synthetic - // session.status_running for that still-queued work. - drafts = stripSessionTerminalStatusDrafts(drafts) - log.Printf("drain: run canceled by user.interrupt session_id=%s run_id=%s", sessionID, runID) - case runErr != nil: - message := runErr.Error() - errorMessage = &message - // An unrecoverable runtime error terminates the session. The execution - // journal prevents unsafe replay but does not yet resume a run from a - // durable tool result, so projecting to `rescheduling` would promise an - // automatic retry that never happens; `terminated` is the honest public - // status. We deliberately do NOT emit a - // session.status_idle with a stop_reason here: the documented - // stop_reason.type union is only end_turn | requires_action, so a - // stop_reason of "error" would be an invented wire field. - drafts = append(drafts, - domain.EventDraft{ - Type: domain.EvSessionError, - Payload: map[string]any{"error": map[string]any{ - "type": "api_error", "message": message, - }}, - }, - domain.EventDraft{ - Type: domain.EvSessionStatusTerminated, - Payload: map[string]any{}, - }, - ) - case outcome.RequiresAction: - // The run parked on a custom tool or an always_ask built-in. The - // session goes idle and waits: the terminal carries a requires_action - // stop_reason naming the committed agent.custom_tool_use / - // agent.tool_use events the client must answer (with - // user.custom_tool_result / user.tool_confirmation). Those results are - // admitted as ordinary triggers that start a fresh run and resume the - // loop. event_ids is the documented wire field for this handoff. - eventIDs := make([]any, len(outcome.ActionEventIDs)) - for i, id := range outcome.ActionEventIDs { - eventIDs[i] = id - } - drafts = append(drafts, domain.EventDraft{ - Type: domain.EvSessionStatusIdle, - Payload: map[string]any{ - "stop_reason": map[string]any{ - "type": "requires_action", - "event_ids": eventIDs, - }, - }, - }) - // When the run parked, its outcome names the committed action events; the - // store persists one durable pending action per id in the same transaction - // that closes the run, deriving the expected response kind from each event's - // type. A normal end_turn / error passes no action ids. - pendingActionEventIDs = outcome.ActionEventIDs - default: - if !hasTerminalDraft(drafts) { - drafts = append(drafts, domain.EventDraft{ - Type: domain.EvSessionStatusIdle, - Payload: map[string]any{ - "stop_reason": map[string]any{"type": "end_turn"}, - }, - }) - } - } - status := terminalStatus(drafts) - if interrupted { - // The interrupted run committed no terminal status of its own (any - // buffered terminal draft was stripped above), so terminalStatus(drafts) - // would report idle. But the session never actually left running: the - // interrupt's own durable control run — plus any redirect message — is - // still queued and drives the single public idle/end_turn. Pass - // StatusRunning explicitly so Complete keeps the session running and does - // NOT append a synthetic session.status_running for that still-queued - // work (which would appear as a spurious running→running blip). The - // queued interrupt claim is preserved; the drain loop claims it next. - status = domain.StatusRunning - } - completion, err := s.runs.Complete(ctx, claim.Run.ID, drafts, status, errorMessage, pendingActionEventIDs) - if err != nil { - // The run's terminal drafts could not be committed. Ending the loop - // leaves the run mid-flight for restart recovery; log so the failure - // is observable rather than silent. Release the shard lock first — a - // bare return under the held lock would deadlock every later operation - // hashing to this shard. - lock.Unlock() - log.Printf("drain: run completion failed session_id=%s run_id=%s status=%s: %v", sessionID, runID, status, err) - return - } - // Publish the completion's committed events while still holding the shard - // lock, in commit order, so this run's output reaches subscribers in durable - // sequence relative to a concurrent SendEvent's admission publish (which also - // holds this lock). Hub publication is nonblocking; the external/runtime work - // above already ran outside the lock, so this adds no blocking call under it. - s.events.PublishCommitted(completion.Events) - lock.Unlock() - } -} - -// confirmedToolUseFromHistory resolves the original committed agent.tool_use -// event a user.tool_confirmation trigger references. The referenced id lives in -// the trigger's tool_use_id payload field (validated at admission); the event -// itself is recovered from server-owned causal history, so the client cannot -// substitute a different tool name or input. It returns nil when the reference -// is missing or resolves to anything other than a persisted agent.tool_use — -// the runtime then fails the resume safely without executing a tool. -func confirmedToolUseFromHistory(trigger domain.Event, history []domain.Event) *domain.Event { - actionEventID, kind, ok := domain.ResolutionReference(trigger.Type, trigger.Payload) - if !ok || kind != domain.PendingToolConfirmation { - return nil - } - for i := range history { - if history[i].ID == actionEventID && history[i].Type == domain.EvAgentToolUse { - e := history[i] - return &e - } - } - return nil -} - -// toolSetHasTools reports whether a resolved toolset offers any tool, in which -// case the run needs a provisioned sandbox for built-in execution. -func toolSetHasTools(ts domain.ToolSet) bool { - return ts.Builtin != nil || len(ts.Custom) > 0 || len(ts.MCP) > 0 -} - -func hasTerminalDraft(drafts []domain.EventDraft) bool { - for _, draft := range drafts { - switch draft.Type { - case domain.EvSessionStatusIdle, - domain.EvSessionStatusRescheduling, - domain.EvSessionStatusTerminated, - domain.EvAgentCustomToolUse: - return true - } - } - return false -} - -// stripSessionTerminalStatusDrafts removes any buffered session terminal-status -// draft (session.status_idle / _rescheduled / _terminated) from an interrupted -// run's drafts, keeping every authoritative nonterminal draft (agent.message, -// agent.tool_use, …) in order. AgentCore leaves terminal ownership to the app and -// buffers none, but a Fake/custom runtime can emit session.status_idle before an -// interrupt wins the completion race; stripping it guarantees the interrupt's own -// control run is the single public idle/end_turn. agent.custom_tool_use is NOT a -// session status event, so it is preserved here — the interrupted path already -// drops the RequiresAction outcome, so no pending action or requires_action -// terminal is persisted from it regardless. -func stripSessionTerminalStatusDrafts(drafts []domain.EventDraft) []domain.EventDraft { - kept := drafts[:0:0] - for _, draft := range drafts { - switch draft.Type { - case domain.EvSessionStatusIdle, - domain.EvSessionStatusRescheduling, - domain.EvSessionStatusTerminated: - continue - } - kept = append(kept, draft) - } - return kept -} - -func terminalStatus(drafts []domain.EventDraft) domain.Status { - status := domain.StatusIdle - for _, draft := range drafts { - switch draft.Type { - case domain.EvSessionStatusRunning: - status = domain.StatusRunning - case domain.EvSessionStatusIdle, domain.EvAgentCustomToolUse: - status = domain.StatusIdle - case domain.EvSessionStatusRescheduling: - status = domain.StatusRescheduling - case domain.EvSessionStatusTerminated: - status = domain.StatusTerminated - } - } - return status -} diff --git a/internal/app/session_service_test.go b/internal/app/session_service_test.go deleted file mode 100644 index 14e9a41..0000000 --- a/internal/app/session_service_test.go +++ /dev/null @@ -1,2131 +0,0 @@ -package app - -import ( - "context" - "fmt" - "strings" - "sync" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/model" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -func newSessionService(t *testing.T) (*SessionService, *AgentService, *EnvironmentService) { - t.Helper() - db, _ := store.OpenMemory() - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - es := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - as := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - ss := NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - es, store.NewRunStore(db, ids, clk), agentruntime.NewFake(), sandbox.NewLocalProvider(), ids, clk) - return ss, as, envs -} - -type failingRuntime struct{} - -func (failingRuntime) Run( - context.Context, - agentruntime.RunRequest, - agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - return agentruntime.RunOutcome{}, fmt.Errorf("runtime exploded") -} - -type recordingRuntime struct { - requests chan agentruntime.RunRequest -} - -func (r recordingRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - r.requests <- req - _, err := sink.Emit(ctx, []domain.EventDraft{{ - Type: domain.EvAgentMessage, - Payload: map[string]any{ - "content": []any{map[string]any{"type": "text", "text": "ok"}}, - }, - }}) - return agentruntime.RunOutcome{}, err -} - -type blockingSessionIDGen struct { - reached chan struct{} - release chan struct{} - once sync.Once -} - -func (g *blockingSessionIDGen) NewID(prefix string) string { - if prefix != domain.PrefixSession { - return prefix + "dependency_race" - } - g.once.Do(func() { close(g.reached) }) - <-g.release - return "sesn_dependency_race" -} - -func TestSessionService_CreateCannotRaceEnvironmentDeleteIntoOrphan(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - - ctx := context.Background() - now := time.Unix(1, 0).UTC() - clock := domain.FixedClock{T: now} - agentRepo := store.NewAgentRepo(db) - environmentRepo := store.NewEnvironmentRepo(db) - sessionRepo := store.NewSessionRepo(db) - if err := agentRepo.PutVersion(ctx, domain.Agent{ - ID: "agent_dependency_race", Version: 1, Name: "agent", - Model: domain.Model{ID: "claude-opus-4-8"}, CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := environmentRepo.Put(ctx, domain.Environment{ - ID: "env_dependency_race", Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - - blockingIDs := &blockingSessionIDGen{ - reached: make(chan struct{}), - release: make(chan struct{}), - } - eventIDs := domain.NewSeqIDGen() - events := NewEventService(store.NewEventStore(db, eventIDs, clock), NewHub(8)) - sessions := NewSessionService( - sessionRepo, agentRepo, environmentRepo, events, - store.NewRunStore(db, blockingIDs, clock), agentruntime.NewFake(), sandbox.NewLocalProvider(), blockingIDs, clock, - ) - environments := NewEnvironmentService(environmentRepo, eventIDs, clock) - - createDone := make(chan error, 1) - go func() { - _, createErr := sessions.Create(ctx, CreateSessionInput{ - AgentID: "agent_dependency_race", EnvironmentID: "env_dependency_race", - }) - createDone <- createErr - }() - - // NewID is reached only after Create has read and validated both - // dependencies. Delete in this window reproduced the old check-then-insert - // orphan deterministically. - <-blockingIDs.reached - deleteErr := environments.Delete(ctx, "env_dependency_race") - close(blockingIDs.release) - if deleteErr != nil { - t.Fatalf("delete while create paused: %v", deleteErr) - } - if createErr := <-createDone; createErr == nil { - t.Fatal("create succeeded after its environment was deleted") - } - if _, err := sessionRepo.Get(ctx, "sesn_dependency_race"); err == nil { - t.Fatal("orphan session was persisted") - } - if _, err := environmentRepo.Get(ctx, "env_dependency_race"); err == nil { - t.Fatal("environment should have been deleted") - } -} - -func TestSessionService_CreateRequiresEnvAndAgent(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - // no initial events -> idle - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil || sess.Status != domain.StatusIdle { - t.Fatalf("create idle: %+v err=%v", sess, err) - } - // missing env -> error - if _, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: "nope"}); err == nil { - t.Fatal("expected error for missing environment") - } -} - -func TestSessionService_SelfHostedUnsupported(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "self_hosted"}) - _, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - de, ok := err.(*domain.DomainError) - if !ok || de.Kind != domain.KindUnsupported { - t.Fatalf("expected unsupported, got %v", err) - } -} - -// pollUntilStatus polls ss.Get(id) every 10ms for up to 2s, returning -// when the session reaches wantStatus or the deadline expires. -func pollUntilStatus(t *testing.T, ss *SessionService, id string, wantStatus domain.Status) domain.Session { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - s, err := ss.Get(context.Background(), id) - if err == nil && s.Status == wantStatus { - return s - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("timed out waiting for session %s to reach status %s", id, wantStatus) - return domain.Session{} -} - -// hasEventType returns true if the event history for sessionID contains an event of evType. -func hasEventType(t *testing.T, ss *SessionService, sessionID, evType string) bool { - t.Helper() - hist, err := ss.events.History(context.Background(), sessionID, 0, 100000) - if err != nil { - t.Fatalf("history: %v", err) - } - for _, e := range hist { - if e.Type == evType { - return true - } - } - return false -} - -// TestSessionService_InitialEventsRunToIdle verifies that a session created -// with initial events drives the fake runtime to agent.message + status_idle. -func TestSessionService_InitialEventsRunToIdle(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, - EnvironmentID: env.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "hello"}}, - }, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - // Returned status should be running. - if sess.Status != domain.StatusRunning { - t.Fatalf("expected running, got %s", sess.Status) - } - - // Poll until fake drives to idle. - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - if !hasEventType(t, ss, sess.ID, domain.EvAgentMessage) { - t.Error("expected agent.message in history") - } - if !hasEventType(t, ss, sess.ID, domain.EvSessionStatusIdle) { - t.Error("expected session.status_idle in history") - } - if !hasEventType(t, ss, sess.ID, domain.EvSessionStatusRunning) { - t.Error("expected session.status_running in history") - } -} - -func TestSessionService_InitialEventBatchProcessesEveryEventInOrder(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, - EnvironmentID: env.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("first")}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("second")}}, - }, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - assertBatchProcessedInOrder(t, ss, sess.ID, nil, []string{"echo: first", "echo: second"}) -} - -// TestSessionService_SendEventDrivesRun verifies that SendEvent on an idle -// session triggers the fake runtime and drives the session back to idle. -func TestSessionService_SendEventDrivesRun(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - // Create an idle session. - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - if sess.Status != domain.StatusIdle { - t.Fatalf("expected idle, got %s", sess.Status) - } - - sent, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "hello"}}, - }) - if err != nil { - t.Fatalf("SendEvent: %v", err) - } - if len(sent) != 1 || sent[0].ProcessedAt != nil { - t.Fatalf("sent trigger should initially be queued: %+v", sent) - } - - // Poll until fake drives back to idle. - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - if !hasEventType(t, ss, sess.ID, domain.EvAgentMessage) { - t.Error("expected agent.message in history") - } - if !hasEventType(t, ss, sess.ID, domain.EvSessionStatusRunning) { - t.Error("expected session.status_running in history") - } - history, err := ss.events.History(ctx, sess.ID, 0, 100) - if err != nil { - t.Fatalf("history: %v", err) - } - var processed bool - for _, event := range history { - if event.ID == sent[0].ID { - processed = event.ProcessedAt != nil - } - } - if !processed { - t.Fatal("runtime-completed trigger still has nil processed_at") - } -} - -func TestSessionService_RuntimeReceivesImmutableAgentSnapshot(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(16)) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - runtimeRequests := make(chan agentruntime.RunRequest, 1) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, store.NewRunStore(db, ids, clk), - recordingRuntime{requests: runtimeRequests}, sandbox.NewLocalProvider(), ids, clk, - ) - - system := "saved system prompt" - agent, err := agents.Create(ctx, domain.Agent{ - Name: "snapshot-agent", - Model: domain.Model{ - ID: "claude-opus-4-8", - }, - System: &system, - }) - if err != nil { - t.Fatal(err) - } - environment, err := environments.Create(ctx, domain.Environment{ - Name: "snapshot-env", ConfigType: "cloud", - }) - if err != nil { - t.Fatal(err) - } - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, EnvironmentID: environment.ID, - }) - if err != nil { - t.Fatal(err) - } - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{ - "content": []any{map[string]any{"type": "text", "text": "hello"}}, - }, - }}); err != nil { - t.Fatal(err) - } - - select { - case req := <-runtimeRequests: - if req.SessionID != session.ID { - t.Fatalf("runtime session = %q, want %q", req.SessionID, session.ID) - } - if req.AgentSnapshot.Model.ID != agent.Model.ID { - t.Fatalf("runtime model = %q, want %q", - req.AgentSnapshot.Model.ID, agent.Model.ID) - } - if req.AgentSnapshot.System == nil || *req.AgentSnapshot.System != system { - t.Fatalf("runtime system = %#v, want %q", req.AgentSnapshot.System, system) - } - case <-time.After(time.Second): - t.Fatal("runtime did not receive the admitted turn") - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) -} - -func TestSessionService_UpdateTitleEmitsOnlyOnChange(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, EnvironmentID: env.ID, Title: "before", - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - unchanged, err := ss.UpdateTitle(ctx, sess.ID, "before") - if err != nil { - t.Fatalf("no-op UpdateTitle: %v", err) - } - if !unchanged.UpdatedAt.Equal(sess.UpdatedAt) { - t.Fatalf("no-op changed updated_at: got %s want %s", unchanged.UpdatedAt, sess.UpdatedAt) - } - if hasEventType(t, ss, sess.ID, domain.EvSessionUpdated) { - t.Fatal("no-op update emitted session.updated") - } - - updated, err := ss.UpdateTitle(ctx, sess.ID, "after") - if err != nil { - t.Fatalf("UpdateTitle: %v", err) - } - if updated.Title != "after" { - t.Fatalf("updated title = %q", updated.Title) - } - history, err := ss.events.History(ctx, sess.ID, 0, 100) - if err != nil { - t.Fatalf("history: %v", err) - } - var update *domain.Event - for i := range history { - if history[i].Type == domain.EvSessionUpdated { - update = &history[i] - break - } - } - if update == nil || update.Payload["title"] != "after" || update.ProcessedAt == nil { - t.Fatalf("session.updated = %#v", update) - } -} - -func TestSessionService_RuntimeFailureClosesDurableRun(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - hub := NewHub(64) - events := NewEventService(store.NewEventStore(db, ids, clk), hub) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, failingRuntime{}, sandbox.NewLocalProvider(), ids, clk, - ) - agent, _ := agents.Create(ctx, domain.Agent{ - Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}, - }) - environment, _ := environments.Create(ctx, domain.Environment{ - Name: "e", ConfigType: "cloud", - }) - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, EnvironmentID: environment.ID, - }) - if err != nil { - t.Fatal(err) - } - sent, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{ - "content": []any{map[string]any{"type": "text", "text": "fail"}}, - }, - }}) - if err != nil { - t.Fatal(err) - } - // An unrecoverable runtime failure terminates the session. We have no retry - // mechanism, so calling it "rescheduling" would be a lie; the honest public - // projection is `terminated`. - pollUntilStatus(t, sessions, session.ID, domain.StatusTerminated) - - var failed int - if err := db.QueryRow(` -SELECT count(*) FROM session_runs WHERE session_id=? AND state=?`, - session.ID, string(domain.RunFailed)).Scan(&failed); err != nil { - t.Fatal(err) - } - if failed != 1 { - t.Fatalf("failed run count = %d, want 1", failed) - } - history, err := events.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - var sawError, sawTerminated, triggerProcessed bool - for _, event := range history { - if event.Type == domain.EvSessionError { - sawError = true - } - if event.Type == domain.EvSessionStatusTerminated { - sawTerminated = true - } - // The runtime error must never land on the wire as a status_idle with a - // fabricated stop_reason. The documented stop_reason.type union is only - // end_turn | requires_action. - if event.Type == domain.EvSessionStatusIdle { - t.Fatalf("runtime failure emitted session.status_idle: %#v", event.Payload) - } - if event.ID == sent[0].ID && event.ProcessedAt != nil { - triggerProcessed = true - } - } - if !sawError || !sawTerminated || !triggerProcessed { - t.Fatalf("failure history: saw_error=%v saw_terminated=%v trigger_processed=%v", - sawError, sawTerminated, triggerProcessed) - } -} - -func TestSessionService_RuntimeWithoutTerminalOutputGetsIdleEvent(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - agent, _ := as.Create(ctx, domain.Agent{ - Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}, - }) - environment, _ := envs.Create(ctx, domain.Environment{ - Name: "e", ConfigType: "cloud", - }) - session, err := ss.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, EnvironmentID: environment.ID, - }) - if err != nil { - t.Fatal(err) - } - if _, err := ss.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserDefineOutcome, - Payload: map[string]any{ - "description": "quality", - "rubric": map[string]any{"type": "text", "content": "good"}, - }, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, ss, session.ID, domain.StatusIdle) - if !hasEventType(t, ss, session.ID, domain.EvSessionStatusIdle) { - t.Fatal("run without terminal output did not emit session.status_idle") - } -} - -func TestSessionService_SendEventBatchProcessesEveryEventInOrder(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - - sent, err := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("first")}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("second")}}, - }) - if err != nil { - t.Fatalf("SendEvent: %v", err) - } - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - assertBatchProcessedInOrder(t, ss, sess.ID, sent, []string{"echo: first", "echo: second"}) -} - -func textBlocks(text string) []any { - return []any{map[string]any{"type": "text", "text": text}} -} - -// projectingRuntime records the projected Messages it receives for each run and -// emits a distinct agent.message per trigger so a later run's projection can be -// asserted to include an earlier run's committed output. -type projectingRuntime struct { - projections chan []domain.Message -} - -func (r projectingRuntime) Run( - ctx context.Context, - req agentruntime.RunRequest, - sink agentruntime.EventSink, -) (agentruntime.RunOutcome, error) { - r.projections <- req.Messages - _, err := sink.Emit(ctx, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{ - "content": []any{map[string]any{ - "type": "text", - "text": "reply-to: " + contentText(req.Trigger.Payload), - }}, - }}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{ - "stop_reason": map[string]any{"type": "end_turn"}, - }}, - }) - return agentruntime.RunOutcome{}, err -} - -func contentText(payload map[string]any) string { - blocks, _ := payload["content"].([]any) - if len(blocks) == 0 { - return "" - } - block, _ := blocks[0].(map[string]any) - text, _ := block["text"].(string) - return text -} - -// TestSessionService_SecondUserEventObservesFirstAgentOutput proves the -// completion-before-next-claim guarantee end to end with EXACT projections: two -// user events admitted in one batch produce two runs. Run A's projected Messages -// are exactly user(A). Run B's are exactly user(A), assistant(reply-to:A), -// user(B) — the second run observes the first run's committed reply, in causal -// order, and nothing more. The public event history preserves receipt/commit -// order and is not rewritten. -func TestSessionService_SecondUserEventObservesFirstAgentOutput(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - projections := make(chan []domain.Message, 4) - ss := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, store.NewRunStore(db, ids, clk), - projectingRuntime{projections: projections}, sandbox.NewLocalProvider(), ids, clk, - ) - ag, _ := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, - EnvironmentID: env.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("first")}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("second")}}, - }, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - first := receiveProjection(t, projections) - second := receiveProjection(t, projections) - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // Run A's projection is exactly the first user message. - assertMessages(t, "run A", first, []wantMessage{ - {domain.RoleUser, "first"}, - }) - // Run B's projection is exactly user(A), assistant(reply-to:A), user(B). - assertMessages(t, "run B", second, []wantMessage{ - {domain.RoleUser, "first"}, - {domain.RoleAssistant, "reply-to: first"}, - {domain.RoleUser, "second"}, - }) - - // The public event history preserves receipt/commit order and is not - // rewritten: both user triggers are committed (with ascending seq) before the - // agent replies they caused. - assertUserTriggersBeforeOutputs(t, ss, sess.ID, []string{"first", "second"}) -} - -// TestSessionService_BatchedTriplePerRunCausalProjection admits A,B,C in one -// batch and asserts each run's projection contains ONLY the completed prior -// trigger/output turns plus its current trigger, in exact role/content order — -// never a later, still-queued trigger. -func TestSessionService_BatchedTriplePerRunCausalProjection(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - projections := make(chan []domain.Message, 8) - ss := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, store.NewRunStore(db, ids, clk), - projectingRuntime{projections: projections}, sandbox.NewLocalProvider(), ids, clk, - ) - ag, _ := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, - EnvironmentID: env.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("A")}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("B")}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": textBlocks("C")}}, - }, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - runA := receiveProjection(t, projections) - runB := receiveProjection(t, projections) - runC := receiveProjection(t, projections) - pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - assertMessages(t, "run A", runA, []wantMessage{ - {domain.RoleUser, "A"}, - }) - assertMessages(t, "run B", runB, []wantMessage{ - {domain.RoleUser, "A"}, - {domain.RoleAssistant, "reply-to: A"}, - {domain.RoleUser, "B"}, - }) - assertMessages(t, "run C", runC, []wantMessage{ - {domain.RoleUser, "A"}, - {domain.RoleAssistant, "reply-to: A"}, - {domain.RoleUser, "B"}, - {domain.RoleAssistant, "reply-to: B"}, - {domain.RoleUser, "C"}, - }) - - // Public history keeps receipt/commit order: A,B,C triggers are all committed - // before any of the agent replies they produced. - assertUserTriggersBeforeOutputs(t, ss, sess.ID, []string{"A", "B", "C"}) -} - -type wantMessage struct { - role domain.Role - text string -} - -// assertMessages requires msgs to equal want exactly: same length, same role -// and single-text-block content per message, in order. -func assertMessages(t *testing.T, label string, msgs []domain.Message, want []wantMessage) { - t.Helper() - if len(msgs) != len(want) { - t.Fatalf("%s: projection has %d messages, want %d: %#v", label, len(msgs), len(want), msgs) - } - for i, w := range want { - m := msgs[i] - if m.Role != w.role { - t.Fatalf("%s: message[%d] role = %s, want %s: %#v", label, i, m.Role, w.role, msgs) - } - if len(m.Content) != 1 || m.Content[0].Text != w.text { - t.Fatalf("%s: message[%d] content = %#v, want single text %q", label, i, m.Content, w.text) - } - } -} - -// assertUserTriggersBeforeOutputs proves the public event history is authentic -// receipt/commit order: the user.message triggers (in wantUserText order, with -// strictly ascending sequence) all precede the agent.message replies. Nothing -// rewrites event seq to match causal projection. -func assertUserTriggersBeforeOutputs(t *testing.T, ss *SessionService, sessionID string, wantUserText []string) { - t.Helper() - history, err := ss.events.History(context.Background(), sessionID, 0, 1000) - if err != nil { - t.Fatalf("history: %v", err) - } - var userText []string - var lastSeq int64 - var firstAgentSeq int64 = -1 - var lastUserTriggerSeq int64 - for _, e := range history { - if e.Sequence <= lastSeq { - t.Fatalf("event history not in ascending seq order: %d after %d", e.Sequence, lastSeq) - } - lastSeq = e.Sequence - switch e.Type { - case domain.EvUserMessage: - userText = append(userText, contentBlockText(e.Payload["content"])) - lastUserTriggerSeq = e.Sequence - case domain.EvAgentMessage: - if firstAgentSeq < 0 { - firstAgentSeq = e.Sequence - } - } - } - if len(userText) != len(wantUserText) { - t.Fatalf("user triggers = %q, want %q", userText, wantUserText) - } - for i := range wantUserText { - if userText[i] != wantUserText[i] { - t.Fatalf("user trigger order = %q, want %q", userText, wantUserText) - } - } - if firstAgentSeq >= 0 && lastUserTriggerSeq >= firstAgentSeq { - t.Fatalf("public history reordered: a user trigger (seq %d) landed after an agent reply (seq %d)", - lastUserTriggerSeq, firstAgentSeq) - } -} - -func receiveProjection(t *testing.T, ch chan []domain.Message) []domain.Message { - t.Helper() - select { - case msgs := <-ch: - return msgs - case <-time.After(2 * time.Second): - t.Fatal("runtime did not receive a projected turn") - return nil - } -} - -// blockingDestroySandbox is a sandbox whose Destroy blocks until the test -// releases it, and which records the state of the context it was destroyed -// under. It lets a test hold a teardown mid-flight, cancel the original request -// context, and then assert on the context the teardown actually ran with. -type blockingDestroySandbox struct { - started chan struct{} // closed when Destroy begins - release chan struct{} // closed by the test to let Destroy finish - - // Captured inside Destroy, after the test has cancelled the original request - // context. Read only after Destroy has returned (via the delete result - // channel), so no synchronization beyond that happens-before is needed. - errAfterReqCancel error - hasDeadline bool -} - -func (b *blockingDestroySandbox) Exec(context.Context, sandbox.Command) (*sandbox.Result, error) { - return &sandbox.Result{}, nil -} -func (b *blockingDestroySandbox) ReadFile(context.Context, string) ([]byte, error) { return nil, nil } -func (b *blockingDestroySandbox) WriteFile(context.Context, string, []byte) error { return nil } -func (b *blockingDestroySandbox) Root() string { return "" } - -func (b *blockingDestroySandbox) Destroy(ctx context.Context) error { - close(b.started) - <-b.release - // The test cancels the original request context before closing release, so - // this read observes the teardown context after that cancellation. Because - // Delete detaches teardown from the request context, Err must still be nil. - b.errAfterReqCancel = ctx.Err() - _, b.hasDeadline = ctx.Deadline() - return nil -} - -// stubSandboxProvider returns a single pre-built sandbox, so a test can -// inject a controllable box into a SessionService's SessionManager. -type stubSandboxProvider struct{ box sandbox.Sandbox } - -func (stubSandboxProvider) Name() string { return "stub" } - -func (p stubSandboxProvider) Create( - context.Context, - string, - sandbox.Spec, -) (sandbox.Ref, sandbox.Sandbox, error) { - return sandbox.Ref{Provider: p.Name(), ID: "box"}, p.box, nil -} - -func (p stubSandboxProvider) Attach( - context.Context, - string, - sandbox.Ref, - sandbox.Spec, -) (sandbox.Sandbox, error) { - return p.box, nil -} - -// TestSessionService_DeleteTeardownSurvivesRequestCancellation proves that once -// sandbox teardown has started during Delete, cancelling the original request -// context does not cancel that teardown, while the teardown context is still -// bounded by a deadline. Coordination is by channels only — no timing sleeps. -func TestSessionService_DeleteTeardownSurvivesRequestCancellation(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - - box := &blockingDestroySandbox{ - started: make(chan struct{}), - release: make(chan struct{}), - } - ss := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, store.NewRunStore(db, ids, clk), - agentruntime.NewFake(), stubSandboxProvider{box: box}, ids, clk, - ) - ag, _ := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - // An idle session (no initial events) that Delete is allowed to remove. - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - // Provision the session's sandbox so Delete's Release has a box to destroy. - if _, err := ss.sandbox.Acquire(ctx, sess.ID, sandbox.Spec{}); err != nil { - t.Fatalf("Acquire: %v", err) - } - - reqCtx, cancelReq := context.WithCancel(context.Background()) - deleteDone := make(chan error, 1) - go func() { deleteDone <- ss.Delete(reqCtx, sess.ID) }() - - // Wait for teardown to start under a timeout so a regression (Delete never - // reaching Release, or Release never invoking Destroy) fails the test instead - // of hanging it forever. - select { - case <-box.started: // teardown is now in flight - case <-time.After(2 * time.Second): - cancelReq() - t.Fatal("sandbox teardown never started during Delete") - } - cancelReq() // cancel the ORIGINAL request context mid-teardown - close(box.release) // let Destroy observe its context and return - - select { - case err := <-deleteDone: - if err != nil { - t.Fatalf("Delete: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("Delete did not return after teardown was released") - } - if box.errAfterReqCancel != nil { - t.Fatalf("teardown context was cancelled by request cancellation: %v", box.errAfterReqCancel) - } - if !box.hasDeadline { - t.Fatal("teardown context was not bounded by a deadline") - } -} - -func assertBatchProcessedInOrder( - t *testing.T, - ss *SessionService, - sessionID string, - sent []domain.Event, - wantAgentText []string, -) { - t.Helper() - history, err := ss.events.History(context.Background(), sessionID, 0, 100) - if err != nil { - t.Fatalf("history: %v", err) - } - - if sent == nil { - for _, event := range history { - if event.Type == domain.EvUserMessage { - sent = append(sent, event) - } - } - } - if len(sent) != len(wantAgentText) { - t.Fatalf("client event count=%d want=%d", len(sent), len(wantAgentText)) - } - for _, event := range sent { - var found *domain.Event - for i := range history { - if history[i].ID == event.ID { - found = &history[i] - break - } - } - if found == nil || found.ProcessedAt == nil { - t.Fatalf("client event %s was not processed: %+v", event.ID, found) - } - } - - var gotAgentText []string - for _, event := range history { - if event.Type != domain.EvAgentMessage { - continue - } - gotAgentText = append(gotAgentText, contentBlockText(event.Payload["content"])) - if event.ProcessedAt == nil { - t.Fatalf("server event %s has nil processed_at", event.ID) - } - } - if len(gotAgentText) != len(wantAgentText) { - t.Fatalf("agent messages=%q want=%q", gotAgentText, wantAgentText) - } - for i := range wantAgentText { - if gotAgentText[i] != wantAgentText[i] { - t.Fatalf("agent message order=%q want=%q", gotAgentText, wantAgentText) - } - } -} - -func contentBlockText(value any) string { - blocks, _ := value.([]any) - if len(blocks) == 0 { - return "" - } - block, _ := blocks[0].(map[string]any) - text, _ := block["text"].(string) - return text -} - -// TestSessionService_ArchiveWhileRunningConflict puts a session row directly -// into StatusRunning via the repo and asserts Archive returns KindConflict. -func TestSessionService_ArchiveWhileRunningConflict(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Force the session to running via the repo directly (deterministic, no goroutine timing). - sess.Status = domain.StatusRunning - if err := ss.sess.Put(ctx, sess); err != nil { - t.Fatalf("force running: %v", err) - } - - _, archErr := ss.Archive(ctx, sess.ID) - de, ok := archErr.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("expected KindConflict, got %v", archErr) - } -} - -// TestSessionService_DeleteWhileRunningConflict puts a session row directly -// into StatusRunning via the repo and asserts Delete returns KindConflict. -func TestSessionService_DeleteWhileRunningConflict(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Force the session to running via the repo directly (deterministic, no goroutine timing). - sess.Status = domain.StatusRunning - if err := ss.sess.Put(ctx, sess); err != nil { - t.Fatalf("force running: %v", err) - } - - delErr := ss.Delete(ctx, sess.ID) - de, ok := delErr.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("expected KindConflict, got %v", delErr) - } -} - -func TestSessionService_DeletePublishesTerminalEventAndClosesStream(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - ch, cancel := ss.events.hub.Subscribe(sess.ID, nil) - defer cancel() - - if err := ss.Delete(ctx, sess.ID); err != nil { - t.Fatalf("Delete: %v", err) - } - f, open := <-ch - if !open { - t.Fatal("stream closed before session.deleted was delivered") - } - deleted := f.Event - if deleted == nil { - t.Fatalf("terminal frame carried no event: %#v", f) - } - if deleted.Type != domain.EvSessionDeleted || deleted.SessionID != sess.ID { - t.Fatalf("terminal event=%+v", deleted) - } - if deleted.ID == "" || deleted.ProcessedAt == nil { - t.Fatalf("terminal event lacks persisted-event fields: %+v", deleted) - } - if _, open := <-ch; open { - t.Fatal("stream remained open after session.deleted") - } - if _, err := ss.Get(ctx, sess.ID); err == nil { - t.Fatal("deleted session is still readable") - } -} - -// TestSessionService_SendEventToArchivedRejected verifies that SendEvent on an -// archived session returns KindConflict and does NOT wedge the session into -// running (Delete must still succeed). -func TestSessionService_SendEventToArchivedRejected(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Archive the session. - if _, err := ss.Archive(ctx, sess.ID); err != nil { - t.Fatalf("Archive: %v", err) - } - - // SendEvent must be rejected with KindConflict. - _, sendErr := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "hello"}}, - }) - de, ok := sendErr.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("expected KindConflict, got %v", sendErr) - } - - // Session must NOT be wedged into running — Delete must succeed. - if err := ss.Delete(ctx, sess.ID); err != nil { - t.Fatalf("Delete after rejected SendEvent failed (session wedged?): %v", err) - } -} - -// TestSessionService_SendEventToTerminatedRejected verifies that SendEvent on a -// terminated session returns KindConflict. -func TestSessionService_SendEventToTerminatedRejected(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - sess, err := ss.Create(ctx, CreateSessionInput{AgentID: ag.ID, EnvironmentID: env.ID}) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Force the session into terminated status via the repo directly. - sess.Status = domain.StatusTerminated - if err := ss.sess.Put(ctx, sess); err != nil { - t.Fatalf("force terminated: %v", err) - } - - _, sendErr := ss.SendEvent(ctx, sess.ID, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "hello"}}, - }) - de, ok := sendErr.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("expected KindConflict, got %v", sendErr) - } -} - -// TestSessionService_ConcurrentTitleAndRunNoClobber is a race-detector -// regression test. It creates a session with initial events (triggering an -// admitRun goroutine) and concurrently calls UpdateTitle in a tight loop. -// After the run settles, the title set by UpdateTitle must be preserved and -// the session must reach idle status (no status corruption). -func TestSessionService_ConcurrentTitleAndRunNoClobber(t *testing.T) { - ss, as, envs := newSessionService(t) - ctx := context.Background() - ag, _ := as.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - env, _ := envs.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - - const wantTitle = "concurrent-title" - - // Create with initial events: spawns admitRun in background. - sess, err := ss.Create(ctx, CreateSessionInput{ - AgentID: ag.ID, - EnvironmentID: env.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "race"}}, - }, - }) - if err != nil { - t.Fatalf("Create: %v", err) - } - - // Hammer UpdateTitle concurrently with the running admitRun goroutine. - var wg sync.WaitGroup - for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _, _ = ss.UpdateTitle(ctx, sess.ID, wantTitle) - }() - } - wg.Wait() - - // Wait for the fake runtime to drive the session back to idle. - final := pollUntilStatus(t, ss, sess.ID, domain.StatusIdle) - - // The title written by UpdateTitle must survive (not clobbered by reconcile). - if final.Title != wantTitle { - t.Errorf("title clobbered: got %q, want %q", final.Title, wantTitle) - } -} - -func TestSessionService_MissingIDsDoNotGrowLockState(t *testing.T) { - ss, _, _ := newSessionService(t) - ctx := context.Background() - - allowed := make(map[*sync.Mutex]struct{}, sessionLockShardCount) - for i := range ss.lockShards { - allowed[&ss.lockShards[i]] = struct{}{} - } - if len(allowed) != sessionLockShardCount { - t.Fatalf("lock shard count = %d, want %d", len(allowed), sessionLockShardCount) - } - - const missingIDs = 10_000 - used := make(map[*sync.Mutex]struct{}, sessionLockShardCount) - for i := 0; i < missingIDs; i++ { - id := fmt.Sprintf("sesn_missing_%d", i) - lock := ss.lockFor(id) - if _, ok := allowed[lock]; !ok { - t.Fatalf("missing id %q allocated a lock outside the fixed shard set", id) - } - used[lock] = struct{}{} - - if _, err := ss.UpdateTitle(ctx, id, "ignored"); err == nil { - t.Fatalf("missing id %q unexpectedly updated", id) - } - } - if len(ss.lockShards) != sessionLockShardCount { - t.Fatalf("lock state grew to %d shards", len(ss.lockShards)) - } - if len(used) > sessionLockShardCount { - t.Fatalf("%d missing IDs created %d locks", missingIDs, len(used)) - } -} - -func TestSessionService_ShardedLocksSerializeSameID(t *testing.T) { - ss, _, _ := newSessionService(t) - const sessionID = "sesn_same" - - first := ss.lockFor(sessionID) - if second := ss.lockFor(sessionID); second != first { - t.Fatal("the same session id mapped to different lock shards") - } - - first.Lock() - acquired := make(chan struct{}) - release := make(chan struct{}) - finished := make(chan struct{}) - go func() { - lock := ss.lockFor(sessionID) - lock.Lock() - close(acquired) - <-release - lock.Unlock() - close(finished) - }() - - select { - case <-acquired: - first.Unlock() - close(release) - <-finished - t.Fatal("second same-session writer acquired the lock concurrently") - case <-time.After(25 * time.Millisecond): - // Expected: the first writer still owns the shard. - } - first.Unlock() - - select { - case <-acquired: - case <-time.After(time.Second): - close(release) - t.Fatal("same-session writer did not proceed after unlock") - } - close(release) - <-finished -} - -func TestSessionService_MultiTurnProjectsPriorHistory(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - hub := NewHub(64) - events := NewEventService(store.NewEventStore(db, ids, clk), hub) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - fakeModel := model.NewFake() - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(fakeModel, ids), sandbox.NewLocalProvider(), ids, clk, - ) - agent, _ := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "m"}}) - environment, _ := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - session, err := sessions.Create(ctx, CreateSessionInput{AgentID: agent.ID, EnvironmentID: environment.ID}) - if err != nil { - t.Fatal(err) - } - - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "first"}}}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "second"}}}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // The second turn's model request must include the first turn: user "first", - // assistant "echo: first", user "second". - last := fakeModel.LastRequest() - if len(last.Messages) != 3 { - t.Fatalf("second-turn projection has %d messages, want 3: %#v", len(last.Messages), last.Messages) - } - if last.Messages[0].Role != domain.RoleUser || last.Messages[0].Content[0].Text != "first" { - t.Fatalf("messages[0] = %#v, want user 'first'", last.Messages[0]) - } - if last.Messages[1].Role != domain.RoleAssistant || last.Messages[1].Content[0].Text != "echo: first" { - t.Fatalf("messages[1] = %#v, want assistant 'echo: first'", last.Messages[1]) - } - if last.Messages[2].Role != domain.RoleUser || last.Messages[2].Content[0].Text != "second" { - t.Fatalf("messages[2] = %#v, want user 'second'", last.Messages[2]) - } -} - -func TestSessionService_DifferentShardsCanProceedConcurrently(t *testing.T) { - ss, _, _ := newSessionService(t) - first := ss.lockFor("sesn_first") - - var other *sync.Mutex - for i := 0; i < sessionLockShardCount*2; i++ { - candidate := ss.lockFor(fmt.Sprintf("sesn_other_%d", i)) - if candidate != first { - other = candidate - break - } - } - if other == nil { - t.Fatal("could not find IDs assigned to distinct shards") - } - - first.Lock() - acquired := make(chan struct{}) - go func() { - other.Lock() - close(acquired) - other.Unlock() - }() - select { - case <-acquired: - // A different shard is not blocked by the first session. - case <-time.After(time.Second): - first.Unlock() - t.Fatal("different lock shard was unnecessarily serialized") - } - first.Unlock() -} - -// TestSessionService_BuiltinToolRunEndToEnd drives a full built-in tool round -// through the real AgentCore + local sandbox: the fake model requests a tool on -// the first turn, the core executes it in a provisioned sandbox, feeds the -// result back, and the second model turn ends the turn. The public event -// history must contain the paired tool_use/tool_result plus a final -// agent.message and session.status_idle. -func TestSessionService_BuiltinToolRunEndToEnd(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(model.NewFake(), ids), sandbox.NewLocalProvider(), ids, clk, - ) - - agent, err := agents.Create(ctx, domain.Agent{ - Name: "tool-agent", - Model: domain.Model{ID: "claude-opus-4-8"}, - Tools: []any{map[string]any{"type": domain.BuiltinToolsetType}}, - }) - if err != nil { - t.Fatal(err) - } - environment, err := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, - EnvironmentID: environment.ID, - InitialEvents: []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "run a tool"}}}, - }}, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sessions.sandbox.Release(context.Background(), session.ID) }) - - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - for _, evType := range []string{ - domain.EvAgentToolUse, - domain.EvAgentToolResult, - domain.EvAgentMessage, - domain.EvSessionStatusIdle, - } { - if !hasEventType(t, sessions, session.ID, evType) { - t.Errorf("expected %s in event history", evType) - } - } - - var runID string - if err := db.QueryRowContext(ctx, ` -SELECT id FROM session_runs WHERE session_id=? ORDER BY rowid LIMIT 1`, - session.ID).Scan(&runID); err != nil { - t.Fatal(err) - } - attempts, err := runs.ListAttempts(ctx, runID) - if err != nil { - t.Fatal(err) - } - if len(attempts) != 1 || attempts[0].State != domain.RunAttemptCompleted { - t.Fatalf("attempts = %#v, want one completed attempt", attempts) - } - steps, err := runs.ListToolSteps(ctx, attempts[0].ID) - if err != nil { - t.Fatal(err) - } - if len(steps) != 1 || steps[0].State != domain.ToolStepCompleted || steps[0].Result == nil { - t.Fatalf("tool steps = %#v, want one completed step with a durable result", steps) - } - history, err := events.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - var publicToolUseID string - for _, event := range history { - if event.Type == domain.EvAgentToolUse { - publicToolUseID = event.ID - break - } - } - if publicToolUseID == "" || steps[0].ToolUseEventID != publicToolUseID { - t.Fatalf( - "journal tool_use_event_id = %q, want public event id %q", - steps[0].ToolUseEventID, publicToolUseID, - ) - } -} - -// TestSessionService_CustomToolParksAndResumes drives the full park/resume loop -// through the real AgentCore + model.Fake: an agent with a custom tool receives -// a user.message; model.Fake calls the (first-offered) custom tool, so the run -// parks — the session goes idle with a session.status_idle carrying -// stop_reason.type == "requires_action" and event_ids naming the emitted -// agent.custom_tool_use. A user.custom_tool_result then resumes a fresh run that -// pairs the result in history and reaches end_turn. -func TestSessionService_CustomToolParksAndResumes(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(model.NewFake(), ids), sandbox.NewLocalProvider(), ids, clk, - ) - - agent, err := agents.Create(ctx, domain.Agent{ - Name: "sre-agent", - Model: domain.Model{ID: "claude-opus-4-8"}, - Tools: []any{map[string]any{ - "type": "custom", "name": "get_metrics", "description": "d", - "input_schema": map[string]any{"type": "object"}, - }}, - }) - if err != nil { - t.Fatal(err) - } - environment, err := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, - EnvironmentID: environment.ID, - InitialEvents: []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "metrics?"}}}, - }}, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sessions.sandbox.Release(context.Background(), session.ID) }) - - // The run parks: session goes idle awaiting the custom tool result. - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - history, err := events.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - var customToolUseID string - var parkedIdle *domain.Event - for i := range history { - switch history[i].Type { - case domain.EvAgentCustomToolUse: - customToolUseID = history[i].ID - case domain.EvSessionStatusIdle: - parkedIdle = &history[i] - } - } - if customToolUseID == "" { - t.Fatal("no agent.custom_tool_use in history") - } - if parkedIdle == nil { - t.Fatal("no session.status_idle in history") - } - stop, _ := parkedIdle.Payload["stop_reason"].(map[string]any) - if stop == nil || stop["type"] != "requires_action" { - t.Fatalf("stop_reason = %#v, want type requires_action", parkedIdle.Payload["stop_reason"]) - } - eventIDs, _ := stop["event_ids"].([]any) - if len(eventIDs) != 1 || eventIDs[0] != customToolUseID { - t.Fatalf("stop_reason.event_ids = %#v, want [%s]", stop["event_ids"], customToolUseID) - } - - // Resume: return the custom tool result referencing the parked use id. - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{ - "custom_tool_use_id": customToolUseID, - "content": []any{map[string]any{"type": "text", "text": "cpu 99%"}}, - }, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // The resumed run must reach a normal end_turn. - resumed, err := events.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - var sawEndTurn, sawAgentMessage bool - for _, e := range resumed { - if e.Type == domain.EvAgentMessage { - sawAgentMessage = true - } - if e.Type == domain.EvSessionStatusIdle { - if stop, _ := e.Payload["stop_reason"].(map[string]any); stop != nil && stop["type"] == "end_turn" { - sawEndTurn = true - } - } - } - if !sawAgentMessage { - t.Error("resumed run produced no agent.message") - } - if !sawEndTurn { - t.Error("resumed run did not reach end_turn") - } -} - -// TestSessionService_PendingGateReleasesPriorQueuedWork drives the full pending -// gate through the real AgentCore + model.Fake. A batch admits two user -// messages: the first parks on the custom tool, the second is ordinary work -// queued BEFORE the park. While the pending action is unresolved the second run -// must stay gated (no second agent.custom_tool_use appears and the session stays -// idle). A matching user.custom_tool_result resumes and clears the gate; only -// then does the previously queued ordinary run execute to end_turn. -func TestSessionService_PendingGateReleasesPriorQueuedWork(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(model.NewFake(), ids), sandbox.NewLocalProvider(), ids, clk, - ) - - agent, err := agents.Create(ctx, domain.Agent{ - Name: "sre-agent", - Model: domain.Model{ID: "claude-opus-4-8"}, - Tools: []any{map[string]any{ - "type": "custom", "name": "get_metrics", "description": "d", - "input_schema": map[string]any{"type": "object"}, - }}, - }) - if err != nil { - t.Fatal(err) - } - environment, err := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - // Two messages in one admission: run 1 parks, run 2 is prior-queued ordinary work. - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, - EnvironmentID: environment.ID, - InitialEvents: []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "metrics?"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "ordinary-queued"}}}}, - }, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sessions.sandbox.Release(context.Background(), session.ID) }) - - // Run 1 parks; the session goes idle awaiting the custom tool result. - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // The gate holds: exactly one agent.custom_tool_use so far (run 2 did not run - // and park a second time). Give the drain loop a moment to prove it stays put. - time.Sleep(100 * time.Millisecond) - history, err := events.History(ctx, session.ID, 0, 1000) - if err != nil { - t.Fatal(err) - } - var customToolUseID string - var toolUseCount int - for i := range history { - if history[i].Type == domain.EvAgentCustomToolUse { - toolUseCount++ - customToolUseID = history[i].ID - } - } - if toolUseCount != 1 { - t.Fatalf("agent.custom_tool_use count while gated = %d, want 1 (prior-queued run must not run)", toolUseCount) - } - - // Resolve the pending action; the resume run reaches end_turn and clears the - // gate. Only then is the previously queued ordinary run claimed. With the - // deterministic fake it re-requests the same custom tool (tools are offered - // and its own history has no tool_result yet), so releasing the gate is proven - // by a SECOND agent.custom_tool_use appearing. - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{ - "custom_tool_use_id": customToolUseID, - "content": []any{map[string]any{"type": "text", "text": "cpu 99%"}}, - }, - }}); err != nil { - t.Fatal(err) - } - - // Wait until the prior-queued ordinary run has executed (a second tool_use). - deadline := time.Now().Add(3 * time.Second) - released := false - for time.Now().Before(deadline) && !released { - hist, err := events.History(ctx, session.ID, 0, 1000) - if err != nil { - t.Fatal(err) - } - count := 0 - for _, e := range hist { - if e.Type == domain.EvAgentCustomToolUse { - count++ - } - } - // Releasing the gate lets the prior-queued ordinary run be claimed; with the - // deterministic fake it re-requests the same custom tool, so a SECOND - // agent.custom_tool_use is the proof the gate cleared and prior work ran. - if count == 2 { - released = true - } - time.Sleep(10 * time.Millisecond) - } - if !released { - t.Fatal("prior-queued ordinary run never executed after the gate cleared") - } -} - -// run through the real AgentCore + model.Fake and proves the preview contract: -// - a subscriber opted into agent.message previews receives event_start and at -// least one event_delta, all carrying the same event id, followed by the -// persisted agent.message with that identical id. -// - a subscriber that did NOT opt in receives only the persisted agent.message -// (no preview frames at all). -// - after the run, the event history (List Events) contains the agent.message -// but ZERO preview frames — the never-persisted proof. -func TestSessionService_PreviewStreamsToOptedInOnlyNeverPersisted(t *testing.T) { - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - hub := NewHub(256) - events := NewEventService(store.NewEventStore(db, ids, clk), hub) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(model.NewFake(), ids), sandbox.NewLocalProvider(), ids, clk, - ) - agent, _ := agents.Create(ctx, domain.Agent{Name: "a", Model: domain.Model{ID: "claude-opus-4-8"}}) - environment, _ := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - session, err := sessions.Create(ctx, CreateSessionInput{AgentID: agent.ID, EnvironmentID: environment.ID}) - if err != nil { - t.Fatal(err) - } - - // Subscribe both consumers before driving a turn so neither can miss a frame. - optedCh, optedCancel := hub.Subscribe(session.ID, map[string]bool{domain.EvAgentMessage: true}) - defer optedCancel() - plainCh, plainCancel := hub.Subscribe(session.ID, nil) - defer plainCancel() - - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "hello"}}}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // Drain the opted-in stream until we see the persisted agent.message. - var startID, deltaID, persistedID string - var startCount, deltaCount int - deadline := time.After(2 * time.Second) -optedLoop: - for { - select { - case f, open := <-optedCh: - if !open { - break optedLoop - } - switch { - case f.Preview != nil: - switch f.Preview.Kind { - case domain.PreviewEventStart: - startCount++ - startID = f.Preview.EventID - if f.Preview.EventType != domain.EvAgentMessage { - t.Fatalf("event_start EventType = %q, want agent.message", f.Preview.EventType) - } - case domain.PreviewEventDelta: - deltaCount++ - deltaID = f.Preview.EventID - } - case f.Event != nil: - if f.Event.Type == domain.EvAgentMessage { - persistedID = f.Event.ID - break optedLoop - } - } - case <-deadline: - t.Fatal("opted-in stream did not deliver a persisted agent.message in time") - } - } - - if startCount < 1 { - t.Errorf("opted-in stream received %d event_start frames, want >=1", startCount) - } - if deltaCount < 1 { - t.Errorf("opted-in stream received %d event_delta frames, want >=1", deltaCount) - } - if persistedID == "" { - t.Fatal("opted-in stream never saw the persisted agent.message") - } - if startID != persistedID || deltaID != persistedID { - t.Fatalf("preview/persist id mismatch: start=%q delta=%q persisted=%q", startID, deltaID, persistedID) - } - - // The non-opted subscriber must never see a preview frame; it sees only the - // persisted agent.message. - deadline2 := time.After(2 * time.Second) -plainLoop: - for { - select { - case f, open := <-plainCh: - if !open { - t.Fatal("plain stream closed before persisted agent.message") - } - if f.Preview != nil { - t.Fatalf("non-opted subscriber received a preview frame: %#v", *f.Preview) - } - if f.Event != nil && f.Event.Type == domain.EvAgentMessage { - if f.Event.ID != persistedID { - t.Fatalf("plain stream agent.message id=%q, want %q", f.Event.ID, persistedID) - } - break plainLoop - } - case <-deadline2: - t.Fatal("plain stream did not deliver the persisted agent.message") - } - } - - // Never-persisted proof: history holds the agent.message and NO preview. - hist, err := events.History(ctx, session.ID, 0, 100000) - if err != nil { - t.Fatal(err) - } - var sawAgentMessage bool - for _, e := range hist { - switch e.Type { - case domain.EvAgentMessage: - sawAgentMessage = true - if e.ID != persistedID { - t.Fatalf("history agent.message id=%q, want %q", e.ID, persistedID) - } - case domain.PreviewEventStart, domain.PreviewEventDelta: - t.Fatalf("preview frame leaked into persisted history: %+v", e) - } - } - if !sawAgentMessage { - t.Fatal("history is missing the persisted agent.message") - } -} - -// confirmClient is a scripted model.Client for the confirmation-resume tests. On -// the first turn (no tool_result yet) it emits assistant text AND requests the -// always_ask built-in with a real input, which parks the run. Emitting text -// alongside the tool_use exercises the alternation-merge path: the dangling -// tool_use is dropped from projected history, so the resume seeds the recovered -// tool_use into the trailing assistant text message. Once the projected history -// carries a tool_result for that call (the confirmation was resolved), it ends -// the turn with a plain text reply. This lets an end-to-end test observe a -// genuine side effect on allow and its absence on deny. -// -// It is strict about the Messages contract: every request it receives must have -// strictly alternating roles, so a merge regression that produces two -// consecutive assistant messages fails the test here rather than passing under a -// lenient fake. Failures are recorded via t. -type confirmClient struct { - t *testing.T - toolName string - input map[string]any -} - -func (c confirmClient) CreateMessage(_ context.Context, req model.Request) (model.Response, error) { - if c.t != nil { - c.t.Helper() - for i := 1; i < len(req.Messages); i++ { - if req.Messages[i].Role == req.Messages[i-1].Role { - c.t.Errorf("model received consecutive %s messages at index %d: %#v", - req.Messages[i].Role, i, req.Messages) - } - } - } - if !hasToolResultMsg(req.Messages) { - return model.Response{ - Content: []domain.ContentBlock{ - {Type: "text", Text: "I'll write the file."}, - {Type: "tool_use", ToolUseID: "fake_use", ToolName: c.toolName, Input: c.input}, - }, - StopReason: "tool_use", - }, nil - } - return model.Response{ - Content: []domain.ContentBlock{{Type: "text", Text: "done"}}, - StopReason: "end_turn", - }, nil -} - -func (c confirmClient) CreateMessageStream(ctx context.Context, req model.Request, _ func(int, string)) (model.Response, error) { - return c.CreateMessage(ctx, req) -} - -func hasToolResultMsg(msgs []domain.Message) bool { - for _, m := range msgs { - for _, b := range m.Content { - if b.Type == "tool_result" { - return true - } - } - } - return false -} - -// setupConfirmSession builds a session whose agent enables exactly the write -// built-in under an always_ask policy, using the given scripted model client. It -// returns the wired services and the parked session, having driven the initial -// turn to the requires_action park. -func setupConfirmSession(t *testing.T, client model.Client) (*SessionService, *EventService, domain.Session) { - t.Helper() - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - ctx := context.Background() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1, 0).UTC()} - events := NewEventService(store.NewEventStore(db, ids, clk), NewHub(64)) - runs := store.NewRunStore(db, ids, clk) - agents := NewAgentService(store.NewAgentRepo(db), ids, clk) - environments := NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := NewSessionService( - store.NewSessionRepo(db), store.NewAgentRepo(db), store.NewEnvironmentRepo(db), - events, runs, agentruntime.NewAgentCore(client, ids), sandbox.NewLocalProvider(), ids, clk, - ) - - enabled := true - agent, err := agents.Create(ctx, domain.Agent{ - Name: "ask-agent", - Model: domain.Model{ID: "claude-opus-4-8"}, - Tools: []any{map[string]any{ - "type": domain.BuiltinToolsetType, - "default_config": map[string]any{"enabled": false}, - "configs": []any{map[string]any{ - "name": "write", - "enabled": enabled, - "permission_policy": map[string]any{"type": "always_ask"}, - }}, - }}, - }) - if err != nil { - t.Fatal(err) - } - environment, err := environments.Create(ctx, domain.Environment{Name: "e", ConfigType: "cloud"}) - if err != nil { - t.Fatal(err) - } - session, err := sessions.Create(ctx, CreateSessionInput{ - AgentID: agent.ID, - EnvironmentID: environment.ID, - InitialEvents: []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "write the file"}}}, - }}, - }) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = sessions.sandbox.Release(context.Background(), session.ID) }) - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - return sessions, events, session -} - -// parkedToolUseID returns the committed always_ask agent.tool_use id from the -// session history and asserts the park's requires_action stop_reason names it. -func parkedToolUseID(t *testing.T, events *EventService, sessionID string) string { - t.Helper() - hist, err := events.History(context.Background(), sessionID, 0, 100) - if err != nil { - t.Fatal(err) - } - var useID string - var parked *domain.Event - for i := range hist { - switch hist[i].Type { - case domain.EvAgentToolUse: - useID = hist[i].ID - case domain.EvSessionStatusIdle: - e := hist[i] - parked = &e - } - } - if useID == "" { - t.Fatal("no agent.tool_use in history") - } - if parked == nil { - t.Fatal("no session.status_idle in history") - } - stop, _ := parked.Payload["stop_reason"].(map[string]any) - if stop == nil || stop["type"] != "requires_action" { - t.Fatalf("stop_reason = %#v, want requires_action", parked.Payload["stop_reason"]) - } - eventIDs, _ := stop["event_ids"].([]any) - if len(eventIDs) != 1 || eventIDs[0] != useID { - t.Fatalf("stop_reason.event_ids = %#v, want [%s]", stop["event_ids"], useID) - } - return useID -} - -// TestSessionService_ConfirmationAllowResumeExecutesSideEffect drives the full -// always_ask allow flow end-to-end: park → durable confirmation → resume → -// agent.tool_result → end_turn. The allowed side effect (the file write) occurs -// and the pending gate clears. -func TestSessionService_ConfirmationAllowResumeExecutesSideEffect(t *testing.T) { - ctx := context.Background() - sessions, events, session := setupConfirmSession(t, confirmClient{ - t: t, - toolName: "write", - input: map[string]any{"path": "confirmed.txt", "file_text": "allowed content"}, - }) - useID := parkedToolUseID(t, events, session.ID) - - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserToolConfirmation, - Payload: map[string]any{"tool_use_id": useID, "result": "allow"}, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // The allowed side effect occurred: the file is present in the session sandbox. - box, err := sessions.sandbox.Acquire(ctx, session.ID, sandbox.Spec{Timeout: 5 * time.Second}) - if err != nil { - t.Fatal(err) - } - data, err := box.ReadFile(ctx, "confirmed.txt") - if err != nil { - t.Fatalf("allowed write did not occur: %v", err) - } - if string(data) != "allowed content" { - t.Fatalf("written content = %q, want %q", data, "allowed content") - } - - // The resumed run emitted an agent.tool_result correlated to the original id - // and reached end_turn. - hist, err := events.History(ctx, session.ID, 0, 200) - if err != nil { - t.Fatal(err) - } - var sawResult, sawEndTurn bool - for _, e := range hist { - if e.Type == domain.EvAgentToolResult && e.Payload["tool_use_id"] == useID { - sawResult = true - if isErr, _ := e.Payload["is_error"].(bool); isErr { - t.Fatalf("allow tool_result is_error = true, want false") - } - } - if e.Type == domain.EvSessionStatusIdle { - if stop, _ := e.Payload["stop_reason"].(map[string]any); stop != nil && stop["type"] == "end_turn" { - sawEndTurn = true - } - } - } - if !sawResult { - t.Error("no agent.tool_result correlated to the original tool_use id") - } - if !sawEndTurn { - t.Error("resumed run did not reach end_turn") - } -} - -// TestSessionService_ConfirmationDenyResumeSkipsSideEffect drives the full -// always_ask deny flow end-to-end: park → durable confirmation → resume → -// rejection agent.tool_result → end_turn. The side effect does NOT occur, the -// tool_result is an error carrying the deny_message, and the gate clears. -func TestSessionService_ConfirmationDenyResumeSkipsSideEffect(t *testing.T) { - ctx := context.Background() - sessions, events, session := setupConfirmSession(t, confirmClient{ - t: t, - toolName: "write", - input: map[string]any{"path": "denied.txt", "file_text": "should not be written"}, - }) - useID := parkedToolUseID(t, events, session.ID) - - if _, err := sessions.SendEvent(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserToolConfirmation, - Payload: map[string]any{ - "tool_use_id": useID, "result": "deny", "deny_message": "policy forbids writes", - }, - }}); err != nil { - t.Fatal(err) - } - pollUntilStatus(t, sessions, session.ID, domain.StatusIdle) - - // The denied side effect did NOT occur: the file is absent from the sandbox. - box, err := sessions.sandbox.Acquire(ctx, session.ID, sandbox.Spec{Timeout: 5 * time.Second}) - if err != nil { - t.Fatal(err) - } - if _, err := box.ReadFile(ctx, "denied.txt"); err == nil { - t.Fatal("denied write occurred, want no side effect") - } - - hist, err := events.History(ctx, session.ID, 0, 200) - if err != nil { - t.Fatal(err) - } - var sawRejection, sawEndTurn bool - for _, e := range hist { - if e.Type == domain.EvAgentToolResult && e.Payload["tool_use_id"] == useID { - isErr, _ := e.Payload["is_error"].(bool) - if !isErr { - t.Fatalf("deny tool_result is_error = false, want true") - } - content, _ := e.Payload["content"].([]any) - var text string - for _, item := range content { - if m, ok := item.(map[string]any); ok { - if s, _ := m["text"].(string); s != "" { - text += s - } - } - } - if !contains(text, "policy forbids writes") { - t.Fatalf("deny tool_result text = %q, want it to include deny_message", text) - } - sawRejection = true - } - if e.Type == domain.EvSessionStatusIdle { - if stop, _ := e.Payload["stop_reason"].(map[string]any); stop != nil && stop["type"] == "end_turn" { - sawEndTurn = true - } - } - } - if !sawRejection { - t.Error("no rejection agent.tool_result correlated to the original tool_use id") - } - if !sawEndTurn { - t.Error("resumed run did not reach end_turn") - } -} - -func contains(s, sub string) bool { return strings.Contains(s, sub) } diff --git a/internal/app/session_types.go b/internal/app/session_types.go new file mode 100644 index 0000000..e7cee7b --- /dev/null +++ b/internal/app/session_types.go @@ -0,0 +1,48 @@ +package app + +import ( + "time" + + "github.com/yanpgwang/managed-agent-go/internal/domain" +) + +// CreateSessionInput is the storage-independent command accepted by the +// control-plane session service. +type CreateSessionInput struct { + AgentID string + AgentVersion *int + Overrides *domain.AgentOverrides + EnvironmentID string + Title string + Metadata map[string]any + InitialEvents []domain.EventDraft +} + +// ListPage describes a keyset-paginated session query. +type ListPage struct { + AgentID string + AgentVersion *int + CreatedAtGt *time.Time + CreatedAtGte *time.Time + CreatedAtLt *time.Time + CreatedAtLte *time.Time + IncludeArchived bool + Statuses []domain.Status + DeploymentID *string + MemoryStoreID *string + Boundary *SessionPageBoundary + Limit int + Desc bool +} + +type SessionPageBoundary struct { + CreatedAt time.Time + ID string + Backward bool +} + +type SessionListPage struct { + Sessions []domain.Session + HasPrev bool + HasNext bool +} diff --git a/internal/app/tool_journal.go b/internal/app/tool_journal.go deleted file mode 100644 index 78068aa..0000000 --- a/internal/app/tool_journal.go +++ /dev/null @@ -1,48 +0,0 @@ -package app - -import ( - "context" - - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -// runToolJournal binds runtime tool calls to one durable run attempt. Store -// writes use durableCtx rather than the runtime's cancelable context: after an -// interrupt reaches a tool executor, its returned result must still be -// recorded before the run can be classified and closed. -type runToolJournal struct { - durableCtx context.Context - runs *store.RunStore - attemptID string -} - -func (j runToolJournal) Prepare( - _ context.Context, - ordinal int, - toolUseEventID string, - toolName string, - input map[string]any, -) (string, error) { - step, err := j.runs.PrepareToolStep( - j.durableCtx, j.attemptID, ordinal, toolUseEventID, toolName, input, - ) - if err != nil { - return "", err - } - return step.ID, nil -} - -func (j runToolJournal) Start(_ context.Context, stepID string) error { - _, err := j.runs.StartToolStep(j.durableCtx, stepID) - return err -} - -func (j runToolJournal) Complete( - _ context.Context, - stepID string, - result domain.ToolStepResult, -) error { - _, err := j.runs.CompleteToolStep(j.durableCtx, stepID, result) - return err -} diff --git a/internal/httpapi/restart_test.go b/internal/httpapi/restart_test.go deleted file mode 100644 index e809738..0000000 --- a/internal/httpapi/restart_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package httpapi - -import ( - "encoding/json" - "net/http" - "path/filepath" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/app" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" -) - -func handlerOn(t *testing.T, db *store.DB) http.Handler { - t.Helper() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1000, 0).UTC()} - hub := app.NewHub(64) - es := app.NewEventService(store.NewEventStore(db, ids, clk), hub) - agents := app.NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := app.NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := app.NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), - store.NewEnvironmentRepo(db), es, store.NewRunStore(db, ids, clk), - agentruntime.NewFake(), sandbox.NewLocalProvider(), ids, clk) - return NewServerAdapter(agents, envs, sessions, es, hub) -} - -// pollEvents polls GET /v1/sessions/{id}/events until the event count -// stabilises (two consecutive reads return the same count) or the deadline -// expires. It returns (count, true) when stable and (lastSeen, false) when -// the deadline expires without stabilising. -func pollEvents(h http.Handler, sessID string, deadline time.Duration) (int, bool) { - end := time.Now().Add(deadline) - prev := -1 - for time.Now().Before(end) { - rec := do(h, "GET", "/v1/sessions/"+sessID+"/events", "") - var m map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { - time.Sleep(25 * time.Millisecond) - continue - } - data, ok := m["data"].([]any) - if !ok { - time.Sleep(25 * time.Millisecond) - continue - } - if len(data) == prev && prev >= 0 { - return prev, true - } - prev = len(data) - time.Sleep(25 * time.Millisecond) - } - return prev, false -} - -func TestRestart_ResourcesAndEventsSurvive(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "test.db") - - // ---- phase 1: populate on the first DB handle ---- - db, err := store.Open(dbPath) - if err != nil { - t.Fatal(err) - } - h := handlerOn(t, db) - - ag := createID(t, h, "POST", "/v1/agents", `{"name":"a","model":"claude-opus-4-8"}`) - env := createID(t, h, "POST", "/v1/environments", `{"name":"e","config":{"type":"cloud"}}`) - sess := createID(t, h, "POST", "/v1/sessions", `{"agent":"`+ag+`","environment_id":"`+env+`"}`) - - // Send a user.message; the fake runtime will append additional events - // (agent.message + session.status_idle). Poll until the count stabilises - // so the before/after comparison is deterministic. - do(h, "POST", "/v1/sessions/"+sess+"/events", `{"events":[{"type":"user.message","content":[{"type":"text","text":"hello"}]}]}`) - - nBefore, stable := pollEvents(h, sess, 3*time.Second) - if !stable { - t.Fatalf("event count did not stabilise within deadline (last seen %d)", nBefore) - } - if nBefore <= 0 { - t.Fatal("expected at least one event before restart, got 0") - } - - // Close the DB, simulating a process restart. - db.Close() - - // ---- phase 2: reopen and verify durability ---- - db2, err := store.Open(dbPath) - if err != nil { - t.Fatal(err) - } - defer db2.Close() - h2 := handlerOn(t, db2) - - after := do(h2, "GET", "/v1/sessions/"+sess+"/events", "") - var a map[string]any - if err := json.Unmarshal(after.Body.Bytes(), &a); err != nil { - t.Fatalf("unmarshal after-restart events: %v", err) - } - afterData, ok := a["data"].([]any) - if !ok { - t.Fatalf("after-restart events: unexpected response: %s", after.Body) - } - nAfter := len(afterData) - if nAfter != nBefore { - t.Fatalf("events lost across restart: before %d after %d", nBefore, nAfter) - } - - // agent + session still retrievable - if rec := do(h2, "GET", "/v1/agents/"+ag, ""); rec.Code != 200 { - t.Fatalf("agent lost after restart: %d %s", rec.Code, rec.Body) - } - if rec := do(h2, "GET", "/v1/sessions/"+sess, ""); rec.Code != 200 { - t.Fatalf("session lost after restart: %d %s", rec.Code, rec.Body) - } -} diff --git a/internal/httpapi/sdk_golden_test.go b/internal/httpapi/sdk_golden_test.go index c5010c4..6626f1d 100644 --- a/internal/httpapi/sdk_golden_test.go +++ b/internal/httpapi/sdk_golden_test.go @@ -1,4 +1,4 @@ -package httpapi_test +package httpapi import ( "bytes" diff --git a/internal/httpapi/sdk_helpers_test.go b/internal/httpapi/sdk_helpers_test.go index 41e22eb..c0e2c66 100644 --- a/internal/httpapi/sdk_helpers_test.go +++ b/internal/httpapi/sdk_helpers_test.go @@ -1,4 +1,4 @@ -package httpapi_test +package httpapi import ( "bytes" diff --git a/internal/httpapi/sdk_session_list_test.go b/internal/httpapi/sdk_session_list_test.go index 5fddef7..6283891 100644 --- a/internal/httpapi/sdk_session_list_test.go +++ b/internal/httpapi/sdk_session_list_test.go @@ -1,4 +1,4 @@ -package httpapi_test +package httpapi import ( "context" diff --git a/internal/httpapi/sdk_test.go b/internal/httpapi/sdk_test.go index ebb3177..2b84bbe 100644 --- a/internal/httpapi/sdk_test.go +++ b/internal/httpapi/sdk_test.go @@ -1,4 +1,4 @@ -package httpapi_test +package httpapi import ( "context" @@ -8,13 +8,6 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" - - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" - "github.com/yanpgwang/managed-agent-go/internal/app" - "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/httpapi" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" ) // These tests drive the server through the official Anthropic Go SDK as a @@ -29,28 +22,10 @@ import ( func sdkClientAndServer(t *testing.T) (anthropic.Client, *httptest.Server) { t.Helper() - db, err := store.OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - ids := domain.NewRandomIDGen() - clk := realClock{} - hub := app.NewHub(64) - es := app.NewEventService(store.NewEventStore(db, ids, clk), hub) - agents := app.NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := app.NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := app.NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), - store.NewEnvironmentRepo(db), es, store.NewRunStore(db, ids, clk), - agentruntime.NewFake(), sandbox.NewLocalProvider(), ids, clk) - srv := httpapi.NewServer( - httpapi.Deps{Agents: agents, Envs: envs, Sessions: sessions, Events: es, Hub: hub}, - httpapi.Config{ - RequireBeta: true, RequireAuth: true, RequireVersion: true, RequireContentType: true, - }, - ) - ts := httptest.NewServer(srv.Handler()) + handler := newTestHandler(t, Config{ + RequireBeta: true, RequireAuth: true, RequireVersion: true, RequireContentType: true, + }, false) + ts := httptest.NewServer(handler) t.Cleanup(ts.Close) client := anthropic.NewClient( @@ -60,10 +35,6 @@ func sdkClientAndServer(t *testing.T) (anthropic.Client, *httptest.Server) { return client, ts } -type realClock struct{} - -func (realClock) Now() time.Time { return time.Now().UTC() } - func TestSDK_AgentLifecycle(t *testing.T) { client, _ := sdkClientAndServer(t) ctx := context.Background() diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index c5f12aa..74ecf5d 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -56,9 +56,6 @@ type Deps struct { Sessions SessionService Events EventService Stream EventSubscriber - // Hub is retained only as a source-compatible bridge for the SQLite test - // fixture and legacy server. New wiring should set Stream explicitly. - Hub *app.Hub } type Server struct { @@ -68,9 +65,6 @@ type Server struct { } func NewServer(deps Deps, cfg Config) *Server { - if deps.Stream == nil { - deps.Stream = deps.Hub - } s := &Server{deps: deps, cfg: cfg, mux: http.NewServeMux()} s.routes() return s @@ -107,12 +101,3 @@ func (s *Server) Handler() http.Handler { return requestIDMiddleware(bodyLimitMiddleware(authMiddleware(s.cfg, versionMiddleware(s.cfg, contentTypeMiddleware(s.cfg, betaMiddleware(s.cfg, s.mux)))))) } - -// NewServerAdapter builds a Handler from already-constructed services. -// Intended for tests that need to reopen the store to simulate a restart. -// Uses a lenient Config (no beta header or auth token required). -func NewServerAdapter(agents *app.AgentService, envs *app.EnvironmentService, - sessions *app.SessionService, events *app.EventService, hub *app.Hub) http.Handler { - return NewServer(Deps{Agents: agents, Envs: envs, Sessions: sessions, Events: events, Hub: hub}, - Config{}).Handler() -} diff --git a/internal/httpapi/stream_optin_test.go b/internal/httpapi/stream_optin_test.go index c5c9347..5dfbfae 100644 --- a/internal/httpapi/stream_optin_test.go +++ b/internal/httpapi/stream_optin_test.go @@ -66,7 +66,7 @@ func TestStreamEvents_EventDeltasValidation(t *testing.T) { // followed by the persisted agent.message, and that a non-opted client sees // only the persisted agent.message. func TestStreamEvents_PreviewRenderedAsSSE(t *testing.T) { - h := NewTestHandlerAgentCore(t) + h := NewTestHandlerWithPreviews(t) ag := createID(t, h, "POST", "/v1/agents", `{"name":"a","model":"claude-opus-4-8"}`) env := createID(t, h, "POST", "/v1/environments", `{"name":"e","config":{"type":"cloud"}}`) sess := createID(t, h, "POST", "/v1/sessions", `{"agent":"`+ag+`","environment_id":"`+env+`"}`) diff --git a/internal/httpapi/testhelpers_test.go b/internal/httpapi/testhelpers_test.go index a09f201..67b54e6 100644 --- a/internal/httpapi/testhelpers_test.go +++ b/internal/httpapi/testhelpers_test.go @@ -1,59 +1,654 @@ package httpapi import ( + "context" "net/http" + "sort" + "strings" + "sync" "testing" "time" - "github.com/yanpgwang/managed-agent-go/internal/agentruntime" "github.com/yanpgwang/managed-agent-go/internal/app" "github.com/yanpgwang/managed-agent-go/internal/domain" - "github.com/yanpgwang/managed-agent-go/internal/model" - "github.com/yanpgwang/managed-agent-go/internal/sandbox" - "github.com/yanpgwang/managed-agent-go/internal/store" ) +// The HTTP suite uses test-only fakes to exercise wire behavior. Durable +// behavior belongs to the PostgreSQL/Temporal integration suite; keeping these +// fakes in _test.go prevents them from becoming a second runtime backend. func NewTestHandler(t *testing.T) http.Handler { t.Helper() - db, err := store.OpenMemory() + return newTestHandler(t, Config{}, false) +} + +func NewTestHandlerWithPreviews(t *testing.T) http.Handler { + t.Helper() + return newTestHandler(t, Config{}, true) +} + +func newTestHandler(t *testing.T, cfg Config, previews bool) http.Handler { + t.Helper() + ids := domain.NewSeqIDGen() + clock := domain.FixedClock{T: time.Unix(1000, 0).UTC()} + agentsRepo := newTestAgentRepository() + environmentsRepo := newTestEnvironmentRepository() + agents := app.NewAgentService(agentsRepo, ids, clock) + environments := app.NewEnvironmentService(environmentsRepo, ids, clock) + hub := app.NewHub(256) + sessions := newTestSessionService( + agentsRepo, + environmentsRepo, + ids, + clock, + hub, + previews, + ) + return NewServer(Deps{ + Agents: agents, Envs: environments, Sessions: sessions, + Events: sessions, Stream: hub, + }, cfg).Handler() +} + +type testAgentRepository struct { + mu sync.Mutex + versions map[string][]domain.Agent +} + +func newTestAgentRepository() *testAgentRepository { + return &testAgentRepository{versions: make(map[string][]domain.Agent)} +} + +func (r *testAgentRepository) PutVersion(_ context.Context, agent domain.Agent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.versions[agent.ID] = append(r.versions[agent.ID], agent) + return nil +} + +func (r *testAgentRepository) UpdateVersion( + _ context.Context, + id string, + update func(domain.Agent) (domain.Agent, bool, error), +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + current := versions[len(versions)-1] + next, changed, err := update(current) if err != nil { - t.Fatal(err) + return domain.Agent{}, err + } + if !changed { + return current, nil + } + next.Version = current.Version + 1 + r.versions[id] = append(versions, next) + return next, nil +} + +func (r *testAgentRepository) Archive( + _ context.Context, + id string, + at time.Time, +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + if versions[len(versions)-1].ArchivedAt == nil { + for index := range versions { + versions[index].ArchivedAt = &at + versions[index].UpdatedAt = at + } + r.versions[id] = versions + } + return versions[len(versions)-1], nil +} + +func (r *testAgentRepository) Latest(_ context.Context, id string) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return domain.Agent{}, domain.NotFound("agent not found") + } + return versions[len(versions)-1], nil +} + +func (r *testAgentRepository) GetVersion( + _ context.Context, + id string, + version int, +) (domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, agent := range r.versions[id] { + if agent.Version == version { + return agent, nil + } + } + return domain.Agent{}, domain.NotFound("agent version not found") +} + +func (r *testAgentRepository) Versions( + _ context.Context, + id string, +) ([]domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + versions := r.versions[id] + if len(versions) == 0 { + return nil, domain.NotFound("agent not found") + } + return append([]domain.Agent(nil), versions...), nil +} + +func (r *testAgentRepository) List(_ context.Context) ([]domain.Agent, error) { + r.mu.Lock() + defer r.mu.Unlock() + agents := make([]domain.Agent, 0, len(r.versions)) + for _, versions := range r.versions { + agents = append(agents, versions[len(versions)-1]) + } + sort.Slice(agents, func(i, j int) bool { + return agents[i].ID < agents[j].ID + }) + return agents, nil +} + +type testEnvironmentRepository struct { + mu sync.Mutex + values map[string]domain.Environment + references map[string]int +} + +func newTestEnvironmentRepository() *testEnvironmentRepository { + return &testEnvironmentRepository{ + values: make(map[string]domain.Environment), references: make(map[string]int), + } +} + +func (r *testEnvironmentRepository) Put( + _ context.Context, + environment domain.Environment, +) error { + r.mu.Lock() + defer r.mu.Unlock() + r.values[environment.ID] = environment + return nil +} + +func (r *testEnvironmentRepository) Get( + _ context.Context, + id string, +) (domain.Environment, error) { + r.mu.Lock() + defer r.mu.Unlock() + environment, ok := r.values[id] + if !ok { + return domain.Environment{}, domain.NotFound("environment not found") + } + return environment, nil +} + +func (r *testEnvironmentRepository) List(_ context.Context) ([]domain.Environment, error) { + r.mu.Lock() + defer r.mu.Unlock() + environments := make([]domain.Environment, 0, len(r.values)) + for _, environment := range r.values { + environments = append(environments, environment) + } + sort.Slice(environments, func(i, j int) bool { + return environments[i].ID < environments[j].ID + }) + return environments, nil +} + +func (r *testEnvironmentRepository) DeleteIfUnreferenced( + _ context.Context, + id string, +) error { + r.mu.Lock() + defer r.mu.Unlock() + if _, ok := r.values[id]; !ok { + return domain.NotFound("environment not found") + } + if r.references[id] > 0 { + return domain.Conflict("environment is referenced by a session") + } + delete(r.values, id) + return nil +} + +func (r *testEnvironmentRepository) addReference(id string) { + r.mu.Lock() + defer r.mu.Unlock() + r.references[id]++ +} + +func (r *testEnvironmentRepository) removeReference(id string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.references[id] > 0 { + r.references[id]-- + } +} + +type testSessionService struct { + mu sync.Mutex + agents *testAgentRepository + environments *testEnvironmentRepository + ids domain.IDGenerator + clock domain.Clock + hub *app.Hub + previews bool + sessions map[string]domain.Session + events map[string][]domain.Event + sequences map[string]int64 +} + +func newTestSessionService( + agents *testAgentRepository, + environments *testEnvironmentRepository, + ids domain.IDGenerator, + clock domain.Clock, + hub *app.Hub, + previews bool, +) *testSessionService { + return &testSessionService{ + agents: agents, environments: environments, ids: ids, clock: clock, + hub: hub, previews: previews, sessions: make(map[string]domain.Session), + events: make(map[string][]domain.Event), sequences: make(map[string]int64), + } +} + +func (s *testSessionService) Create( + ctx context.Context, + input app.CreateSessionInput, +) (domain.Session, error) { + if err := app.ValidateMetadata(input.Metadata); err != nil { + return domain.Session{}, err + } + var ( + agent domain.Agent + err error + ) + if input.AgentVersion != nil { + agent, err = s.agents.GetVersion(ctx, input.AgentID, *input.AgentVersion) + } else { + agent, err = s.agents.Latest(ctx, input.AgentID) } - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1000, 0).UTC()} - hub := app.NewHub(64) - es := app.NewEventService(store.NewEventStore(db, ids, clk), hub) - agents := app.NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := app.NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := app.NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), - store.NewEnvironmentRepo(db), es, store.NewRunStore(db, ids, clk), - agentruntime.NewFake(), sandbox.NewLocalProvider(), ids, clk) - srv := NewServer(Deps{Agents: agents, Envs: envs, Sessions: sessions, Events: es, Hub: hub}, - Config{RequireBeta: false, RequireAuth: false}) - return srv.Handler() -} - -// NewTestHandlerAgentCore mirrors NewTestHandler but wires the real AgentCore -// over model.Fake, so runs stream agent.message previews to opted-in stream -// clients (model.NewFake returns a Fake, whose CreateMessageStream feeds deltas). -func NewTestHandlerAgentCore(t *testing.T) http.Handler { - t.Helper() - db, err := store.OpenMemory() if err != nil { - t.Fatal(err) + return domain.Session{}, domain.Validation("agent not found") + } + if agent.ArchivedAt != nil { + return domain.Session{}, domain.Validation("agent is archived") + } + environment, err := s.environments.Get(ctx, input.EnvironmentID) + if err != nil { + return domain.Session{}, domain.Validation("environment not found") + } + if environment.ArchivedAt != nil { + return domain.Session{}, domain.Validation("environment is archived") + } + if environment.ConfigType == "self_hosted" { + return domain.Session{}, domain.Unsupported( + "sessions against self_hosted environments are not supported in v0.1", + ) + } + snapshot := agent + if input.Overrides != nil { + snapshot = snapshot.WithOverrides(*input.Overrides) + } + metadata := input.Metadata + if metadata == nil { + metadata = map[string]any{} + } + now := s.clock.Now().UTC() + session := domain.Session{ + ID: s.ids.NewID(domain.PrefixSession), AgentID: agent.ID, + AgentVersion: agent.Version, AgentSnapshot: snapshot, + EnvironmentID: environment.ID, Status: domain.StatusIdle, + Title: input.Title, Metadata: metadata, CreatedAt: now, UpdatedAt: now, + } + s.mu.Lock() + s.sessions[session.ID] = session + s.mu.Unlock() + s.environments.addReference(environment.ID) + if len(input.InitialEvents) > 0 { + if _, err := s.SendEvent(ctx, session.ID, input.InitialEvents); err != nil { + return domain.Session{}, err + } + } + return session, nil +} + +func (s *testSessionService) Get( + _ context.Context, + id string, +) (domain.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[id] + if !ok { + return domain.Session{}, domain.NotFound("session not found") + } + return session, nil +} + +func (s *testSessionService) List( + _ context.Context, + query app.ListPage, +) (app.SessionListPage, error) { + s.mu.Lock() + defer s.mu.Unlock() + sessions := make([]domain.Session, 0, len(s.sessions)) + statuses := make(map[domain.Status]bool, len(query.Statuses)) + for _, status := range query.Statuses { + statuses[status] = true + } + for _, session := range s.sessions { + if !query.IncludeArchived && session.ArchivedAt != nil { + continue + } + if query.AgentID != "" && session.AgentID != query.AgentID { + continue + } + if query.AgentVersion != nil && session.AgentVersion != *query.AgentVersion { + continue + } + if len(statuses) > 0 && !statuses[session.Status] { + continue + } + if query.DeploymentID != nil || query.MemoryStoreID != nil { + continue + } + if query.CreatedAtGt != nil && !session.CreatedAt.After(*query.CreatedAtGt) { + continue + } + if query.CreatedAtGte != nil && session.CreatedAt.Before(*query.CreatedAtGte) { + continue + } + if query.CreatedAtLt != nil && !session.CreatedAt.Before(*query.CreatedAtLt) { + continue + } + if query.CreatedAtLte != nil && session.CreatedAt.After(*query.CreatedAtLte) { + continue + } + sessions = append(sessions, session) + } + sort.Slice(sessions, func(i, j int) bool { + less := sessions[i].CreatedAt.Before(sessions[j].CreatedAt) || + (sessions[i].CreatedAt.Equal(sessions[j].CreatedAt) && + sessions[i].ID < sessions[j].ID) + if query.Desc { + return !less + } + return less + }) + + start, end := 0, len(sessions) + if query.Boundary != nil { + index := len(sessions) + for candidate := range sessions { + if sessions[candidate].ID == query.Boundary.ID && + sessions[candidate].CreatedAt.Equal(query.Boundary.CreatedAt) { + index = candidate + break + } + } + if index < len(sessions) { + if query.Boundary.Backward { + end = index + start = max(0, end-query.Limit) + } else { + start = index + 1 + } + } + } + if end > start+query.Limit { + end = start + query.Limit + } + page := append([]domain.Session(nil), sessions[start:end]...) + return app.SessionListPage{ + Sessions: page, + HasPrev: start > 0, + HasNext: end < len(sessions), + }, nil +} + +func (s *testSessionService) SendEvent( + _ context.Context, + sessionID string, + drafts []domain.EventDraft, +) ([]domain.Event, error) { + s.mu.Lock() + session, ok := s.sessions[sessionID] + if !ok { + s.mu.Unlock() + return nil, domain.NotFound("session not found") + } + if session.ArchivedAt != nil || session.Status == domain.StatusTerminated { + s.mu.Unlock() + return nil, domain.Conflict("session does not accept events") + } + committed := make([]domain.Event, 0, len(drafts)) + var generated []domain.EventDraft + for _, draft := range drafts { + event := s.appendEventLocked(sessionID, draft) + committed = append(committed, event) + switch draft.Type { + case domain.EvUserMessage: + text := eventText(draft.Payload) + if strings.Contains(text, "tool:") { + generated = append(generated, domain.EventDraft{ + Type: domain.EvAgentCustomToolUse, + Payload: map[string]any{ + "name": "get_metrics", "input": map[string]any{}, + }, + }) + } else { + generated = append(generated, + domain.EventDraft{ + Type: domain.EvAgentMessage, + Payload: map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "echo: " + text}, + }, + }, + }, + idleDraft(), + ) + } + case domain.EvUserCustomToolResult: + generated = append(generated, + domain.EventDraft{ + Type: domain.EvAgentMessage, + Payload: map[string]any{"content": draft.Payload["content"]}, + }, + idleDraft(), + ) + } + } + generatedEvents := make([]domain.Event, 0, len(generated)) + for _, draft := range generated { + if s.previews && draft.Type == domain.EvAgentMessage { + eventID := s.ids.NewID(domain.PrefixEvent) + draft.ID = eventID + s.hub.PublishPreview(sessionID, domain.PreviewFrame{ + Kind: domain.PreviewEventStart, EventID: eventID, + EventType: domain.EvAgentMessage, + }) + s.hub.PublishPreview(sessionID, domain.PreviewFrame{ + Kind: domain.PreviewEventDelta, EventID: eventID, + EventType: domain.EvAgentMessage, Text: eventText(draft.Payload), + }) + } + generatedEvents = append(generatedEvents, s.appendEventLocked(sessionID, draft)) + } + s.mu.Unlock() + + for _, event := range append(append([]domain.Event(nil), committed...), generatedEvents...) { + s.hub.Publish(sessionID, event) + } + return committed, nil +} + +func (s *testSessionService) UpdateTitle( + _ context.Context, + id string, + title string, +) (domain.Session, error) { + s.mu.Lock() + session, ok := s.sessions[id] + if !ok { + s.mu.Unlock() + return domain.Session{}, domain.NotFound("session not found") + } + if session.Title == title { + s.mu.Unlock() + return session, nil + } + session.Title = title + session.UpdatedAt = s.clock.Now().UTC() + s.sessions[id] = session + event := s.appendEventLocked(id, domain.EventDraft{ + Type: domain.EvSessionUpdated, Payload: map[string]any{"title": title}, + }) + s.mu.Unlock() + s.hub.Publish(id, event) + return session, nil +} + +func (s *testSessionService) Archive( + _ context.Context, + id string, +) (domain.Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[id] + if !ok { + return domain.Session{}, domain.NotFound("session not found") + } + if session.ArchivedAt == nil { + now := s.clock.Now().UTC() + session.ArchivedAt = &now + session.UpdatedAt = now + s.sessions[id] = session + } + return session, nil +} + +func (s *testSessionService) Delete(_ context.Context, id string) error { + s.mu.Lock() + session, ok := s.sessions[id] + if !ok { + s.mu.Unlock() + return domain.NotFound("session not found") + } + terminal := s.appendEventLocked(id, domain.EventDraft{Type: domain.EvSessionDeleted}) + delete(s.sessions, id) + delete(s.events, id) + delete(s.sequences, id) + s.mu.Unlock() + s.environments.removeReference(session.EnvironmentID) + s.hub.CloseSession(id, terminal) + return nil +} + +func (s *testSessionService) Query( + _ context.Context, + sessionID string, + query app.EventQuery, +) ([]domain.Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.sessions[sessionID]; !ok { + return nil, domain.NotFound("session not found") + } + types := make(map[string]bool, len(query.Types)) + for _, eventType := range query.Types { + types[eventType] = true + } + events := make([]domain.Event, 0, len(s.events[sessionID])) + for _, event := range s.events[sessionID] { + if query.AfterSeq > 0 && event.Sequence <= query.AfterSeq { + continue + } + if query.BeforeSeq > 0 && event.Sequence >= query.BeforeSeq { + continue + } + if len(types) > 0 && !types[event.Type] { + continue + } + if query.CreatedAtGt != nil && !event.CreatedAt.After(*query.CreatedAtGt) { + continue + } + if query.CreatedAtGte != nil && event.CreatedAt.Before(*query.CreatedAtGte) { + continue + } + if query.CreatedAtLt != nil && !event.CreatedAt.Before(*query.CreatedAtLt) { + continue + } + if query.CreatedAtLte != nil && event.CreatedAt.After(*query.CreatedAtLte) { + continue + } + events = append(events, event) + } + sort.Slice(events, func(i, j int) bool { + if query.Desc { + return events[i].Sequence > events[j].Sequence + } + return events[i].Sequence < events[j].Sequence + }) + if query.Limit > 0 && len(events) > query.Limit { + events = events[:query.Limit] + } + return events, nil +} + +func (s *testSessionService) appendEventLocked( + sessionID string, + draft domain.EventDraft, +) domain.Event { + s.sequences[sessionID]++ + now := s.clock.Now().UTC() + eventID := draft.ID + if eventID == "" { + eventID = s.ids.NewID(domain.PrefixEvent) + } + event := domain.Event{ + ID: eventID, SessionID: sessionID, Sequence: s.sequences[sessionID], + Type: draft.Type, Payload: draft.Payload, CreatedAt: now, + } + if domain.ProcessedOnReceipt(draft.Type) { + event.ProcessedAt = &now + } + s.events[sessionID] = append(s.events[sessionID], event) + return event +} + +func eventText(payload map[string]any) string { + content, _ := payload["content"].([]any) + var text strings.Builder + for _, item := range content { + block, _ := item.(map[string]any) + value, _ := block["text"].(string) + text.WriteString(value) + } + return text.String() +} + +func idleDraft() domain.EventDraft { + return domain.EventDraft{ + Type: domain.EvSessionStatusIdle, + Payload: map[string]any{ + "stop_reason": map[string]any{"type": "end_turn"}, + }, } - t.Cleanup(func() { db.Close() }) - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: time.Unix(1000, 0).UTC()} - hub := app.NewHub(256) - es := app.NewEventService(store.NewEventStore(db, ids, clk), hub) - agents := app.NewAgentService(store.NewAgentRepo(db), ids, clk) - envs := app.NewEnvironmentService(store.NewEnvironmentRepo(db), ids, clk) - sessions := app.NewSessionService(store.NewSessionRepo(db), store.NewAgentRepo(db), - store.NewEnvironmentRepo(db), es, store.NewRunStore(db, ids, clk), - agentruntime.NewAgentCore(model.NewFake(), ids), sandbox.NewLocalProvider(), ids, clk) - srv := NewServer(Deps{Agents: agents, Envs: envs, Sessions: sessions, Events: es, Hub: hub}, - Config{RequireBeta: false, RequireAuth: false}) - return srv.Handler() } diff --git a/internal/pg/journal.go b/internal/pg/journal.go index bd7afa6..8a0e117 100644 --- a/internal/pg/journal.go +++ b/internal/pg/journal.go @@ -11,11 +11,10 @@ import ( "github.com/yanpgwang/managed-agent-go/internal/pg/pgstore" ) -// The tool-execution journal preserves the same prepared/started/completed/ -// ambiguous boundary as the SQLite execution store, but for the Temporal path. -// A turn is identified by (session_id, trigger_event_id); each RunTurn Activity -// execution is an attempt. A Temporal retry creates a new attempt rather than -// erasing the facts a prior attempt recorded. +// The tool-execution journal preserves a prepared/started/completed/ambiguous +// side-effect boundary. A turn is identified by (session_id, +// trigger_event_id); each RunTurn Activity execution is an attempt. A Temporal +// retry creates a new attempt rather than erasing facts from a prior attempt. // TurnAttempt is one durable execution attempt for a turn. It is internal // bookkeeping and never serialized on the public API. diff --git a/internal/pg/migrations/00001_platform_spine.sql b/internal/pg/migrations/00001_platform_spine.sql index 97092b9..596dea7 100644 --- a/internal/pg/migrations/00001_platform_spine.sql +++ b/internal/pg/migrations/00001_platform_spine.sql @@ -1,13 +1,10 @@ -- +goose Up -- +goose StatementBegin --- Platform-spine schema for the Temporal/PostgreSQL session path. --- --- This is a NEW, parallel path. SQLite (internal/store) remains the default --- compatibility store; nothing here migrates or replaces it. Only the tables the --- first end-to-end vertical slice needs are created: a session projection, the --- append-only public event ledger with a durable per-session receipt sequence, --- and the coalescible orchestration outbox that wakes the SessionWorkflow. +-- Platform-spine schema for the Temporal/PostgreSQL session path. The first +-- vertical slice consists of a session projection, the append-only public event +-- ledger with a durable per-session receipt sequence, and the coalescible +-- orchestration outbox that wakes the SessionWorkflow. -- -- Deliberately NOT here (out of milestone scope): agents, environments, runs, -- run attempts, tool steps, pending actions, multiagent threads, schedules, diff --git a/internal/pg/migrations/00002_tool_journal.sql b/internal/pg/migrations/00002_tool_journal.sql index f0a6045..f4d2c05 100644 --- a/internal/pg/migrations/00002_tool_journal.sql +++ b/internal/pg/migrations/00002_tool_journal.sql @@ -1,10 +1,10 @@ -- +goose Up -- +goose StatementBegin --- Durable tool-execution journal for the Temporal path, mirroring the SQLite --- prepared/started/completed/ambiguous boundary (internal/store/execution_store.go) --- so a built-in tool step run under a RunTurn Activity survives worker/activity --- retries without silently replaying an external side effect. +-- Durable tool-execution journal for the Temporal path. The +-- prepared/started/completed/ambiguous boundary lets a built-in tool step +-- survive worker/activity retries without silently replaying an external side +-- effect. -- -- The "logical run" here is one turn, identified by (session_id, -- trigger_event_id) — the public event that caused the turn. Each RunTurn diff --git a/internal/pg/pg.go b/internal/pg/pg.go index b82080d..462a795 100644 --- a/internal/pg/pg.go +++ b/internal/pg/pg.go @@ -1,6 +1,5 @@ -// Package pg provides the primary control-plane persistence: a pgx pool, +// Package pg provides the authoritative control-plane persistence: a pgx pool, // embedded goose migrations, and stores built on sqlc-generated queries. -// SQLite remains available only through the deprecated compatibility backend. package pg import ( diff --git a/internal/pg/pgstore/journal.sql.go b/internal/pg/pgstore/journal.sql.go index bfc676f..479d6a3 100644 --- a/internal/pg/pgstore/journal.sql.go +++ b/internal/pg/pgstore/journal.sql.go @@ -284,8 +284,7 @@ type NextAttemptNoParams struct { // Typed queries for the Temporal-path tool-execution journal (turn_attempts, // tool_steps). Transitions keep an explicit from-state in the WHERE clause and -// the Go layer checks rows-affected, mirroring the SQLite execution store's -// guard semantics. +// the Go layer checks rows-affected to enforce guarded state transitions. func (q *Queries) NextAttemptNo(ctx context.Context, arg NextAttemptNoParams) (int32, error) { row := q.db.QueryRow(ctx, nextAttemptNo, arg.SessionID, arg.TriggerEventID) var next_no int32 diff --git a/internal/pg/queries/journal.sql b/internal/pg/queries/journal.sql index 2f44584..02b9a38 100644 --- a/internal/pg/queries/journal.sql +++ b/internal/pg/queries/journal.sql @@ -1,7 +1,6 @@ -- Typed queries for the Temporal-path tool-execution journal (turn_attempts, -- tool_steps). Transitions keep an explicit from-state in the WHERE clause and --- the Go layer checks rows-affected, mirroring the SQLite execution store's --- guard semantics. +-- the Go layer checks rows-affected to enforce guarded state transitions. -- name: NextAttemptNo :one SELECT (COALESCE(MAX(attempt_no), 0) + 1)::int AS next_no diff --git a/internal/pg/resources.go b/internal/pg/resources.go index db66beb..d7ef2ef 100644 --- a/internal/pg/resources.go +++ b/internal/pg/resources.go @@ -197,9 +197,9 @@ func agentsFromRows(rows []pgstore.Agent) ([]domain.Agent, error) { return out, nil } -// EnvironmentRepository stores Environment resources and makes delete-if-unused -// one PostgreSQL statement, preserving the dependency race guarantee from the -// former SQLite repository. +// EnvironmentRepository stores Environment resources and makes +// delete-if-unused one PostgreSQL statement so a concurrent Session creation +// cannot commit an orphaned reference. type EnvironmentRepository struct { store *Store } diff --git a/internal/pg/store.go b/internal/pg/store.go index f693687..ae4ea47 100644 --- a/internal/pg/store.go +++ b/internal/pg/store.go @@ -485,10 +485,9 @@ func (s *Store) GetEvent(ctx context.Context, sessionID, id string) (domain.Even } // HistoryThrough reconstructs the causal conversation history for the turn -// triggered by triggerEventID, to be projected into the model. It mirrors the -// SQLite path's RunStore.ModelHistory: public receipt order is deliberately NOT -// what a turn replays, because a later user.message admitted (at a lower -// sequence) before an earlier turn finished must not appear as a peer of the +// triggered by triggerEventID, to be projected into the model. Public receipt +// order is deliberately NOT what a turn replays, because a later user.message +// admitted before an earlier turn finished must not appear as a peer of the // current trigger. // // The reconstruction walks prior *processed* model-driving triggers diff --git a/internal/sandbox/memory_binding_test.go b/internal/sandbox/memory_binding_test.go new file mode 100644 index 0000000..304780b --- /dev/null +++ b/internal/sandbox/memory_binding_test.go @@ -0,0 +1,50 @@ +package sandbox + +import ( + "context" + "sync" +) + +type memoryBindingStore struct { + mu sync.Mutex + bindings map[string]Binding +} + +func newMemoryBindingStore() *memoryBindingStore { + return &memoryBindingStore{bindings: make(map[string]Binding)} +} + +func (s *memoryBindingStore) GetSandboxBinding( + _ context.Context, + sessionID string, +) (Binding, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + binding, ok := s.bindings[sessionID] + return binding, ok, nil +} + +func (s *memoryBindingStore) PutSandboxBinding( + _ context.Context, + binding Binding, +) (Binding, error) { + s.mu.Lock() + defer s.mu.Unlock() + if current, ok := s.bindings[binding.SessionID]; ok { + return current, nil + } + s.bindings[binding.SessionID] = binding + return binding, nil +} + +func (s *memoryBindingStore) DeleteSandboxBinding( + _ context.Context, + binding Binding, +) error { + s.mu.Lock() + defer s.mu.Unlock() + if current, ok := s.bindings[binding.SessionID]; ok && current.Ref == binding.Ref { + delete(s.bindings, binding.SessionID) + } + return nil +} diff --git a/internal/sandbox/session.go b/internal/sandbox/session.go index e295cb0..2b997f9 100644 --- a/internal/sandbox/session.go +++ b/internal/sandbox/session.go @@ -51,14 +51,12 @@ type sessionMutex struct { users int } -// NewSessionManager wraps a provider with durable session ownership. The -// optional store keeps deprecated single-process callers source-compatible; a -// process-local store is used when none is supplied. Production Temporal -// workers always pass PostgreSQL. -func NewSessionManager(provider Provider, stores ...BindingStore) *SessionManager { - var bindings BindingStore = newMemoryBindingStore() - if len(stores) > 0 && stores[0] != nil { - bindings = stores[0] +// NewSessionManager wraps a provider with durable session ownership. +// BindingStore is required: sandbox identity must never fall back to process +// memory, even in a single-worker deployment. +func NewSessionManager(provider Provider, bindings BindingStore) *SessionManager { + if bindings == nil { + panic("sandbox: binding store is required") } return &SessionManager{ provider: provider, @@ -245,47 +243,3 @@ func specHash(spec Spec) string { sum := sha256.Sum256(body) return fmt.Sprintf("sha256:%x", sum[:]) } - -type memoryBindingStore struct { - mu sync.Mutex - bindings map[string]Binding -} - -func newMemoryBindingStore() *memoryBindingStore { - return &memoryBindingStore{bindings: make(map[string]Binding)} -} - -func (s *memoryBindingStore) GetSandboxBinding( - _ context.Context, - sessionID string, -) (Binding, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - binding, ok := s.bindings[sessionID] - return binding, ok, nil -} - -func (s *memoryBindingStore) PutSandboxBinding( - _ context.Context, - binding Binding, -) (Binding, error) { - s.mu.Lock() - defer s.mu.Unlock() - if current, ok := s.bindings[binding.SessionID]; ok { - return current, nil - } - s.bindings[binding.SessionID] = binding - return binding, nil -} - -func (s *memoryBindingStore) DeleteSandboxBinding( - _ context.Context, - binding Binding, -) error { - s.mu.Lock() - defer s.mu.Unlock() - if current, ok := s.bindings[binding.SessionID]; ok && current.Ref == binding.Ref { - delete(s.bindings, binding.SessionID) - } - return nil -} diff --git a/internal/sandbox/session_test.go b/internal/sandbox/session_test.go index de8562b..bea33da 100644 --- a/internal/sandbox/session_test.go +++ b/internal/sandbox/session_test.go @@ -106,7 +106,7 @@ func (p *destroyCountingProvider) Attach( func TestSessionManager_ReusesSandboxPerSession(t *testing.T) { cp := &countingProvider{inner: NewLocalProvider()} - m := NewSessionManager(cp) + m := NewSessionManager(cp, newMemoryBindingStore()) ctx := context.Background() first, err := m.Acquire(ctx, "sesn_a", Spec{}) @@ -128,8 +128,17 @@ func TestSessionManager_ReusesSandboxPerSession(t *testing.T) { } } +func TestNewSessionManager_RequiresBindingStore(t *testing.T) { + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("NewSessionManager accepted a nil BindingStore") + } + }() + NewSessionManager(NewLocalProvider(), nil) +} + func TestSessionManager_IsolatesSessions(t *testing.T) { - m := NewSessionManager(NewLocalProvider()) + m := NewSessionManager(NewLocalProvider(), newMemoryBindingStore()) ctx := context.Background() a, err := m.Acquire(ctx, "sesn_a", Spec{}) @@ -156,7 +165,7 @@ func TestSessionManager_IsolatesSessions(t *testing.T) { func TestSessionManager_ReleaseDestroysExactlyOnce(t *testing.T) { dp := &destroyCountingProvider{inner: NewLocalProvider()} - m := NewSessionManager(dp) + m := NewSessionManager(dp, newMemoryBindingStore()) ctx := context.Background() if _, err := m.Acquire(ctx, "sesn_a", Spec{}); err != nil { @@ -182,7 +191,7 @@ func TestSessionManager_ReleaseDestroysExactlyOnce(t *testing.T) { func TestSessionManager_ConcurrentAcquireProvisionsOnce(t *testing.T) { cp := &countingProvider{inner: NewLocalProvider()} - m := NewSessionManager(cp) + m := NewSessionManager(cp, newMemoryBindingStore()) ctx := context.Background() const goroutines = 32 @@ -215,7 +224,7 @@ func TestSessionManager_ConcurrentAcquireProvisionsOnce(t *testing.T) { func TestSessionManager_ProvisionFailureIsNotCached(t *testing.T) { cp := &countingProvider{inner: NewLocalProvider(), provisionErr: errors.New("boom")} - m := NewSessionManager(cp) + m := NewSessionManager(cp, newMemoryBindingStore()) ctx := context.Background() if _, err := m.Acquire(ctx, "sesn_a", Spec{}); err == nil { @@ -342,7 +351,7 @@ func TestSessionManager_ReleaseWaitsForInflightProvision(t *testing.T) { entered: make(chan struct{}), proceed: make(chan struct{}), } - m := NewSessionManager(p) + m := NewSessionManager(p, newMemoryBindingStore()) ctx := context.Background() acquired := make(chan struct{}) diff --git a/internal/store/agent_repo.go b/internal/store/agent_repo.go deleted file mode 100644 index ca57f1a..0000000 --- a/internal/store/agent_repo.go +++ /dev/null @@ -1,259 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "strings" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -type AgentRepo struct{ db *DB } - -func NewAgentRepo(db *DB) *AgentRepo { return &AgentRepo{db} } - -func (r *AgentRepo) PutVersion(ctx context.Context, a domain.Agent) error { - body, err := json.Marshal(a) - if err != nil { - return err - } - _, err = r.db.ExecContext(ctx, - `INSERT INTO agents (id, version, name, body, created_at, updated_at, archived_at) - VALUES (?,?,?,?,?,?,?)`, - a.ID, a.Version, a.Name, string(body), - a.CreatedAt.Format(rfc3339), a.UpdatedAt.Format(rfc3339), nullableTime(a.ArchivedAt)) - return err -} - -// UpdateVersion atomically reads the current resource state, applies mutate, -// and conditionally appends one configuration version. The conditional insert -// protects against both a concurrent version append and a concurrent archive, -// including callers using another database connection. -func (r *AgentRepo) UpdateVersion( - ctx context.Context, - id string, - mutate func(domain.Agent) (domain.Agent, bool, error), -) (domain.Agent, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return domain.Agent{}, err - } - defer tx.Rollback() - - current, err := latestAgent(ctx, tx, id) - if err == sql.ErrNoRows { - return domain.Agent{}, domain.NotFound("agent not found") - } - if err != nil { - return domain.Agent{}, err - } - - next, changed, err := mutate(current) - if err != nil { - return domain.Agent{}, err - } - if !changed { - if err := tx.Commit(); err != nil { - return domain.Agent{}, err - } - return current, nil - } - - // Identity, lifecycle state, and version allocation belong to the - // repository, not to a caller-provided mutation. - next.ID = current.ID - next.Version = current.Version + 1 - next.CreatedAt = current.CreatedAt - next.ArchivedAt = nil - body, err := json.Marshal(next) - if err != nil { - return domain.Agent{}, err - } - - result, err := tx.ExecContext(ctx, ` -INSERT OR IGNORE INTO agents - (id, version, name, body, created_at, updated_at, archived_at) -SELECT ?, ?, ?, ?, ?, ?, NULL -WHERE EXISTS ( - SELECT 1 - FROM agents AS current - WHERE current.id = ? - AND current.version = ? - AND current.archived_at IS NULL - AND current.version = ( - SELECT MAX(candidate.version) - FROM agents AS candidate - WHERE candidate.id = current.id - ) -)`, - next.ID, next.Version, next.Name, string(body), - next.CreatedAt.Format(rfc3339), next.UpdatedAt.Format(rfc3339), - id, current.Version) - if err != nil { - if isAgentWriteRace(err) { - return domain.Agent{}, domain.Conflict("agent version changed during update") - } - return domain.Agent{}, err - } - affected, err := result.RowsAffected() - if err != nil { - return domain.Agent{}, err - } - if affected != 1 { - return domain.Agent{}, domain.Conflict("agent version changed during update") - } - if err := tx.Commit(); err != nil { - if isAgentWriteRace(err) { - return domain.Agent{}, domain.Conflict("agent version changed during update") - } - return domain.Agent{}, err - } - return next, nil -} - -// Archive records lifecycle state without creating a configuration version. -// Archival applies to the agent resource rather than one version, so the -// lifecycle timestamp is projected onto every stored version. This also -// prevents a caller from selecting a historical version to bypass archival. -func (r *AgentRepo) Archive(ctx context.Context, id string, archivedAt time.Time) (domain.Agent, error) { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return domain.Agent{}, err - } - defer tx.Rollback() - - at := archivedAt.UTC() - if _, err := tx.ExecContext(ctx, ` -UPDATE agents -SET archived_at = COALESCE( - ( - SELECT existing.archived_at - FROM agents AS existing - WHERE existing.id = ? - AND existing.archived_at IS NOT NULL - ORDER BY existing.version DESC - LIMIT 1 - ), - ? -) -WHERE id = ?`, id, at.Format(rfc3339), id); err != nil { - return domain.Agent{}, err - } - latest, err := latestAgent(ctx, tx, id) - if err == sql.ErrNoRows { - return domain.Agent{}, domain.NotFound("agent not found") - } - if err != nil { - return domain.Agent{}, err - } - if err := tx.Commit(); err != nil { - return domain.Agent{}, err - } - return latest, nil -} - -func (r *AgentRepo) Latest(ctx context.Context, id string) (domain.Agent, error) { - a, err := latestAgent(ctx, r.db, id) - if err == sql.ErrNoRows { - return domain.Agent{}, domain.NotFound("agent not found") - } - return a, err -} - -// GetVersion returns a specific agent version. Configuration fields are -// immutable once written, so this yields a stable snapshot for session pinning; -// resource-level lifecycle metadata such as ArchivedAt may be projected later. -func (r *AgentRepo) GetVersion(ctx context.Context, id string, version int) (domain.Agent, error) { - var body string - var archivedAt sql.NullString - err := r.db.QueryRowContext(ctx, - `SELECT body, archived_at FROM agents WHERE id=? AND version=?`, id, version). - Scan(&body, &archivedAt) - if err == sql.ErrNoRows { - return domain.Agent{}, domain.NotFound("agent version not found") - } - if err != nil { - return domain.Agent{}, err - } - return decodeAgent(body, archivedAt) -} - -func (r *AgentRepo) Versions(ctx context.Context, id string) ([]domain.Agent, error) { - rows, err := r.db.QueryContext(ctx, - `SELECT body, archived_at FROM agents WHERE id=? ORDER BY version ASC`, id) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAgents(rows) -} - -func (r *AgentRepo) List(ctx context.Context) ([]domain.Agent, error) { - rows, err := r.db.QueryContext(ctx, - `SELECT body, archived_at FROM agents a WHERE version=(SELECT MAX(version) FROM agents b WHERE b.id=a.id) - ORDER BY id ASC`) - if err != nil { - return nil, err - } - defer rows.Close() - return scanAgents(rows) -} - -func scanAgents(rows *sql.Rows) ([]domain.Agent, error) { - var out []domain.Agent - for rows.Next() { - var body string - var archivedAt sql.NullString - if err := rows.Scan(&body, &archivedAt); err != nil { - return nil, err - } - a, err := decodeAgent(body, archivedAt) - if err != nil { - return nil, err - } - out = append(out, a) - } - return out, rows.Err() -} - -type agentQueryRower interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func latestAgent(ctx context.Context, q agentQueryRower, id string) (domain.Agent, error) { - var body string - var archivedAt sql.NullString - err := q.QueryRowContext(ctx, - `SELECT body, archived_at FROM agents WHERE id=? ORDER BY version DESC LIMIT 1`, id). - Scan(&body, &archivedAt) - if err != nil { - return domain.Agent{}, err - } - return decodeAgent(body, archivedAt) -} - -func decodeAgent(body string, archivedAt sql.NullString) (domain.Agent, error) { - var a domain.Agent - if err := json.Unmarshal([]byte(body), &a); err != nil { - return domain.Agent{}, err - } - if !archivedAt.Valid { - a.ArchivedAt = nil - return a, nil - } - at, err := parseRFC3339(archivedAt.String) - if err != nil { - return domain.Agent{}, err - } - a.ArchivedAt = &at - return a, nil -} - -func isAgentWriteRace(err error) bool { - message := strings.ToLower(err.Error()) - return strings.Contains(message, "locked") || - strings.Contains(message, "busy") || - strings.Contains(message, "unique constraint") -} diff --git a/internal/store/environment_repo.go b/internal/store/environment_repo.go deleted file mode 100644 index 6572efd..0000000 --- a/internal/store/environment_repo.go +++ /dev/null @@ -1,103 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -type EnvironmentRepo struct{ db *DB } - -func NewEnvironmentRepo(db *DB) *EnvironmentRepo { return &EnvironmentRepo{db} } - -func (r *EnvironmentRepo) Put(ctx context.Context, e domain.Environment) error { - body, err := json.Marshal(e) - if err != nil { - return err - } - _, err = r.db.ExecContext(ctx, - `INSERT INTO environments (id, name, config_type, body, created_at, updated_at, archived_at) - VALUES (?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET name=excluded.name, config_type=excluded.config_type, - body=excluded.body, updated_at=excluded.updated_at, archived_at=excluded.archived_at`, - e.ID, e.Name, e.ConfigType, string(body), - e.CreatedAt.Format(rfc3339), e.UpdatedAt.Format(rfc3339), nullableTime(e.ArchivedAt)) - return err -} - -func (r *EnvironmentRepo) Get(ctx context.Context, id string) (domain.Environment, error) { - var body string - err := r.db.QueryRowContext(ctx, `SELECT body FROM environments WHERE id=?`, id).Scan(&body) - if err == sql.ErrNoRows { - return domain.Environment{}, domain.NotFound("environment not found") - } - if err != nil { - return domain.Environment{}, err - } - var e domain.Environment - return e, json.Unmarshal([]byte(body), &e) -} - -func (r *EnvironmentRepo) List(ctx context.Context) ([]domain.Environment, error) { - rows, err := r.db.QueryContext(ctx, `SELECT body FROM environments ORDER BY id ASC`) - if err != nil { - return nil, err - } - defer rows.Close() - var out []domain.Environment - for rows.Next() { - var body string - if err := rows.Scan(&body); err != nil { - return nil, err - } - var e domain.Environment - if err := json.Unmarshal([]byte(body), &e); err != nil { - return nil, err - } - out = append(out, e) - } - return out, rows.Err() -} - -// DeleteIfUnreferenced removes an environment only if no session references it. -// The conditional DELETE and the missing-vs-referenced classification share one -// transaction, so a concurrent conditional session insert cannot leave an -// orphaned environment reference. -func (r *EnvironmentRepo) DeleteIfUnreferenced(ctx context.Context, id string) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - result, err := tx.ExecContext(ctx, ` -DELETE FROM environments -WHERE id = ? - AND NOT EXISTS ( - SELECT 1 - FROM sessions - WHERE environment_id = ? - )`, id, id) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected == 1 { - return tx.Commit() - } - - var exists bool - if err := tx.QueryRowContext(ctx, - `SELECT EXISTS(SELECT 1 FROM environments WHERE id=?)`, id).Scan(&exists); err != nil { - return err - } - if !exists { - return domain.NotFound("environment not found") - } - return domain.Conflict("environment is referenced by a session") -} diff --git a/internal/store/event_store.go b/internal/store/event_store.go deleted file mode 100644 index b466e0d..0000000 --- a/internal/store/event_store.go +++ /dev/null @@ -1,218 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -type EventStore struct { - db *DB - ids domain.IDGenerator - clock domain.Clock -} - -// eventProcessedKeySQL normalizes the processed_at column to a fixed-width -// nanosecond representation for correct ordering and boundary comparisons. -var eventProcessedKeySQL = nanoKeySQL("processed_at") - -const eventProcessedKeyFormat = "2006-01-02T15:04:05.000000000Z" - -func NewEventStore(db *DB, ids domain.IDGenerator, clock domain.Clock) *EventStore { - return &EventStore{db: db, ids: ids, clock: clock} -} - -func (s *EventStore) Append(ctx context.Context, sessionID string, drafts []domain.EventDraft) ([]domain.Event, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer tx.Rollback() - - out, err := appendEventsTx(ctx, tx, s.ids, s.clock, sessionID, drafts) - if err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, err - } - return out, nil -} - -func appendEventsTx( - ctx context.Context, - tx *sql.Tx, - ids domain.IDGenerator, - clock domain.Clock, - sessionID string, - drafts []domain.EventDraft, -) ([]domain.Event, error) { - var maxSeq int64 - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(MAX(seq),0) FROM events WHERE session_id=?`, sessionID).Scan(&maxSeq); err != nil { - return nil, err - } - - out := make([]domain.Event, 0, len(drafts)) - for _, d := range drafts { - maxSeq++ - // Honor a pre-assigned committed id (set by a server-side emitter that - // needed the id before this transaction); otherwise mint a fresh one. - id := d.ID - if id == "" { - id = ids.NewID(domain.PrefixEvent) - } - payload, err := json.Marshal(d.Payload) - if err != nil { - return nil, err - } - now := clock.Now().UTC() - var processedAt *string - var processedTime *time.Time - if domain.ProcessedOnReceipt(d.Type) { - str := timeVal(now) - processedAt = &str - t := now - processedTime = &t - } - if _, err := tx.ExecContext(ctx, - `INSERT INTO events (id, session_id, seq, type, payload, created_at, processed_at) VALUES (?,?,?,?,?,?,?)`, - id, sessionID, maxSeq, d.Type, string(payload), timeVal(now), processedAt); err != nil { - return nil, err - } - out = append(out, domain.Event{ - ID: id, SessionID: sessionID, Sequence: maxSeq, Type: d.Type, - Payload: d.Payload, CreatedAt: now, ProcessedAt: processedTime, - }) - } - return out, nil -} - -// EventQuery expresses the public List Events filters. AfterSeq/BeforeSeq are -// internal cursors (never exposed on the wire); the HTTP layer encodes them into -// an opaque page token. Types filters by event type. The public created_at[*] -// query names compare against processed_at, matching the official SDK contract. -type EventQuery struct { - AfterSeq int64 // exclusive lower bound (ascending pagination) - BeforeSeq int64 // exclusive upper bound (descending pagination) - Limit int - Desc bool - Types []string - CreatedAtGt *time.Time - CreatedAtGte *time.Time - CreatedAtLt *time.Time - CreatedAtLte *time.Time -} - -// History returns events after a sequence cursor in ascending order. It backs -// internal reconciliation, which needs total, stably ordered history. -func (s *EventStore) History(ctx context.Context, sessionID string, afterSeq int64, limit int) ([]domain.Event, error) { - return s.Query(ctx, sessionID, EventQuery{AfterSeq: afterSeq, Limit: limit}) -} - -// HistoryTail returns the newest `limit` events for a session in ascending -// (chronological) sequence order. Where History returns the OLDEST events after -// a cursor, HistoryTail bounds an over-limit session to its most RECENT window -// so model projection carries recent context rather than the start of the -// conversation. The newest events are selected by sequence descending, then -// reversed back to chronological order. Pairing across the window boundary is -// not repaired here — ProjectMessages already drops a dangling tool_use or an -// orphan tool_result, so a window that splits a pair still projects to a legal -// Messages-API request. -func (s *EventStore) HistoryTail(ctx context.Context, sessionID string, limit int) ([]domain.Event, error) { - desc, err := s.Query(ctx, sessionID, EventQuery{Limit: limit, Desc: true}) - if err != nil { - return nil, err - } - for i, j := 0, len(desc)-1; i < j; i, j = i+1, j-1 { - desc[i], desc[j] = desc[j], desc[i] - } - return desc, nil -} - -// Query lists events for a session applying the public List Events filters. -// Ordering remains the stable internal sequence until processed_at keyset -// ordering (including null semantics) is implemented. -func (s *EventStore) Query(ctx context.Context, sessionID string, q EventQuery) ([]domain.Event, error) { - limit := q.Limit - if limit <= 0 { - limit = 1000 - } - - where := []string{"session_id=?"} - args := []any{sessionID} - if q.AfterSeq > 0 { - where = append(where, "seq>?") - args = append(args, q.AfterSeq) - } - if q.BeforeSeq > 0 { - where = append(where, "seq 0 { - placeholders := make([]string, len(q.Types)) - for i, t := range q.Types { - placeholders[i] = "?" - args = append(args, t) - } - where = append(where, "type IN ("+strings.Join(placeholders, ",")+")") - } - for _, b := range []struct { - t *time.Time - op string - }{ - {q.CreatedAtGt, ">"}, {q.CreatedAtGte, ">="}, - {q.CreatedAtLt, "<"}, {q.CreatedAtLte, "<="}, - } { - if b.t != nil { - where = append(where, eventProcessedKeySQL+" "+b.op+" ?") - args = append(args, eventProcessedKey(*b.t)) - } - } - - order := "ASC" - if q.Desc { - order = "DESC" - } - args = append(args, limit) - - query := `SELECT id, seq, type, payload, created_at, processed_at FROM events WHERE ` + - strings.Join(where, " AND ") + ` ORDER BY seq ` + order + ` LIMIT ?` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - var out []domain.Event - for rows.Next() { - var ev domain.Event - var payload, createdAt string - var processedAt sql.NullString - if err := rows.Scan(&ev.ID, &ev.Sequence, &ev.Type, &payload, &createdAt, &processedAt); err != nil { - return nil, err - } - ev.SessionID = sessionID - if err := json.Unmarshal([]byte(payload), &ev.Payload); err != nil { - return nil, fmt.Errorf("store: decode payload for event %s: %w", ev.ID, err) - } - if tm, err := parseRFC3339(createdAt); err == nil { - ev.CreatedAt = tm - } - if processedAt.Valid { - if tm, err := parseRFC3339(processedAt.String); err == nil { - ev.ProcessedAt = &tm - } - } - out = append(out, ev) - } - return out, rows.Err() -} - -func eventProcessedKey(value time.Time) string { - return value.UTC().Format(eventProcessedKeyFormat) -} diff --git a/internal/store/event_store_test.go b/internal/store/event_store_test.go deleted file mode 100644 index 897f780..0000000 --- a/internal/store/event_store_test.go +++ /dev/null @@ -1,267 +0,0 @@ -package store - -import ( - "context" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -func newEventStore(t *testing.T) *EventStore { - t.Helper() - db, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - return NewEventStore(db, domain.NewSeqIDGen(), domain.FixedClock{T: time.Unix(1000, 0).UTC()}) -} - -func TestAppend_StrictlyIncreasingSeq(t *testing.T) { - s := newEventStore(t) - ctx := context.Background() - out, err := s.Append(ctx, "sesn_1", []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"x": 1}}, - {Type: domain.EvAgentMessage, Payload: map[string]any{"y": 2}}, - }) - if err != nil { - t.Fatal(err) - } - if out[0].Sequence != 1 || out[1].Sequence != 2 { - t.Fatalf("seq: %d,%d", out[0].Sequence, out[1].Sequence) - } - more, _ := s.Append(ctx, "sesn_1", []domain.EventDraft{{Type: domain.EvSessionStatusIdle}}) - if more[0].Sequence != 3 { - t.Fatalf("expected seq 3, got %d", more[0].Sequence) - } - // separate session restarts at 1 - other, _ := s.Append(ctx, "sesn_2", []domain.EventDraft{{Type: domain.EvUserMessage}}) - if other[0].Sequence != 1 { - t.Fatalf("expected seq 1 for new session, got %d", other[0].Sequence) - } -} - -func TestAppend_ProcessedOnReceipt(t *testing.T) { - s := newEventStore(t) - out, _ := s.Append(context.Background(), "sesn_1", []domain.EventDraft{ - {Type: domain.EvUserCustomToolResult}, - {Type: domain.EvUserMessage}, - {Type: domain.EvAgentMessage}, - {Type: domain.EvSessionStatusIdle}, - }) - if out[0].ProcessedAt == nil { - t.Fatal("custom_tool_result should be processed on receipt") - } - if out[1].ProcessedAt != nil { - t.Fatal("user.message should be queued (processed_at nil)") - } - for i, event := range out[2:] { - if event.ProcessedAt == nil { - t.Fatalf("server event %d (%s) should be processed on persist", i+2, event.Type) - } - } -} - -// TestHistoryTail_ReturnsNewestWindowInOrder proves that when a session has more -// events than the projection window, HistoryTail returns the NEWEST events (not -// the oldest) and keeps them in ascending chronological order. This is the -// regression guard for the historyProjectionLimit bug, where afterSeq=0 + LIMIT -// projected the OLDEST window. A small limit stands in for the production -// 10000 ceiling so the test stays fast and needs no bulk inserts. -func TestHistoryTail_ReturnsNewestWindowInOrder(t *testing.T) { - s := newEventStore(t) - ctx := context.Background() - const sessionID = "sesn_tail" - - // Append 10 user messages numbered 0..9 in order. - drafts := make([]domain.EventDraft, 10) - for i := range drafts { - drafts[i] = domain.EventDraft{Type: domain.EvUserMessage, Payload: map[string]any{"n": i}} - } - if _, err := s.Append(ctx, sessionID, drafts); err != nil { - t.Fatalf("Append: %v", err) - } - - // Ask for the newest 3. - tail, err := s.HistoryTail(ctx, sessionID, 3) - if err != nil { - t.Fatalf("HistoryTail: %v", err) - } - if len(tail) != 3 { - t.Fatalf("HistoryTail len = %d; want 3", len(tail)) - } - // Must be the newest three (n=7,8,9) in ascending sequence order, NOT the - // oldest three (n=0,1,2) that the old afterSeq=0 projection returned. - wantN := []float64{7, 8, 9} - for i, ev := range tail { - if ev.Payload["n"] != wantN[i] { - t.Fatalf("tail[%d].n = %v; want %v (newest window, chronological order): full=%+v", - i, ev.Payload["n"], wantN[i], tail) - } - if i > 0 && tail[i-1].Sequence >= ev.Sequence { - t.Fatalf("tail not ascending by seq at %d: %d then %d", i, tail[i-1].Sequence, ev.Sequence) - } - } - - // A limit larger than the total returns everything, still chronological. - full, err := s.HistoryTail(ctx, sessionID, 100) - if err != nil { - t.Fatalf("HistoryTail(100): %v", err) - } - if len(full) != 10 || full[0].Payload["n"] != float64(0) || full[9].Payload["n"] != float64(9) { - t.Fatalf("HistoryTail(100) = %d events, first=%v last=%v; want 10, 0..9", - len(full), full[0].Payload["n"], full[len(full)-1].Payload["n"]) - } -} - -func TestList_RoundTripAndAfterSeq(t *testing.T) { - s := newEventStore(t) - ctx := context.Background() - const sessionID = "sesn_list" - - // Append 3 events: two plain types, one ProcessedOnReceipt. - _, err := s.Append(ctx, sessionID, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"text": "hi", "n": 42}}, - {Type: domain.EvAgentMessage, Payload: map[string]any{"text": "hello"}}, - {Type: domain.EvUserCustomToolResult, Payload: map[string]any{"result": "ok"}}, - }) - if err != nil { - t.Fatalf("Append: %v", err) - } - - // List all events (afterSeq=0, limit=0). - all, err := s.History(ctx, sessionID, 0, 0) - if err != nil { - t.Fatalf("List all: %v", err) - } - if len(all) != 3 { - t.Fatalf("expected 3 events, got %d", len(all)) - } - - // Assert seq ordering. - for i, ev := range all { - if ev.Sequence != int64(i+1) { - t.Errorf("event[%d]: expected seq %d, got %d", i, i+1, ev.Sequence) - } - } - - // Assert payload round-trip: JSON numbers come back as float64. - ev0 := all[0] - if ev0.Payload["text"] != "hi" { - t.Errorf("payload text: got %v", ev0.Payload["text"]) - } - if ev0.Payload["n"].(float64) != 42 { - t.Errorf("payload n: got %v", ev0.Payload["n"]) - } - - // ProcessedAt: user.message -> nil; server events and selected client - // acknowledgements -> non-nil. - if all[0].ProcessedAt != nil { - t.Error("user.message should have nil ProcessedAt after List") - } - if all[1].ProcessedAt == nil { - t.Error("agent.message should have non-nil ProcessedAt after List") - } - if all[2].ProcessedAt == nil { - t.Error("user.custom_tool_result should have non-nil ProcessedAt after List") - } - - // List with afterSeq=1: should return only seq 2 and 3. - tail, err := s.History(ctx, sessionID, 1, 0) - if err != nil { - t.Fatalf("List afterSeq=1: %v", err) - } - if len(tail) != 2 { - t.Fatalf("afterSeq=1: expected 2 events, got %d", len(tail)) - } - if tail[0].Sequence != 2 || tail[1].Sequence != 3 { - t.Errorf("afterSeq=1 seqs: %d, %d", tail[0].Sequence, tail[1].Sequence) - } -} - -func TestQuery_CreatedAtNamedFilterUsesProcessedAt(t *testing.T) { - s := newEventStore(t) - ctx := context.Background() - _, err := s.Append(ctx, "sesn_filter", []domain.EventDraft{ - {Type: domain.EvUserMessage}, - {Type: domain.EvAgentMessage}, - }) - if err != nil { - t.Fatal(err) - } - bound := time.Unix(999, 0).UTC() - got, err := s.Query(ctx, "sesn_filter", EventQuery{CreatedAtGt: &bound, Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || got[0].Type != domain.EvAgentMessage { - t.Fatalf("filter should exclude unprocessed user event and include processed server event: %+v", got) - } -} - -func TestQuery_ProcessedAtFiltersExactAndFractionalWithinSecond(t *testing.T) { - db, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - ctx := context.Background() - ids := domain.NewSeqIDGen() - exact := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) - fractional := exact.Add(100 * time.Millisecond) - for _, at := range []time.Time{exact, fractional} { - store := NewEventStore(db, ids, domain.FixedClock{T: at}) - if _, err := store.Append(ctx, "sesn_filter", []domain.EventDraft{ - {Type: domain.EvAgentMessage}, - }); err != nil { - t.Fatal(err) - } - } - store := NewEventStore(db, ids, domain.FixedClock{T: fractional}) - - tests := []struct { - name string - query EventQuery - want []time.Time - }{ - { - name: "gt exact", - query: EventQuery{CreatedAtGt: &exact}, - want: []time.Time{fractional}, - }, - { - name: "gte exact", - query: EventQuery{CreatedAtGte: &exact}, - want: []time.Time{exact, fractional}, - }, - { - name: "lt fractional", - query: EventQuery{CreatedAtLt: &fractional}, - want: []time.Time{exact}, - }, - { - name: "lte fractional", - query: EventQuery{CreatedAtLte: &fractional}, - want: []time.Time{exact, fractional}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.query.Limit = 10 - got, err := store.Query(ctx, "sesn_filter", tt.query) - if err != nil { - t.Fatal(err) - } - if len(got) != len(tt.want) { - t.Fatalf("got %d events, want %d: %+v", len(got), len(tt.want), got) - } - for i, want := range tt.want { - if got[i].ProcessedAt == nil || !got[i].ProcessedAt.Equal(want) { - t.Fatalf("event %d processed_at = %v, want %s", i, got[i].ProcessedAt, want) - } - } - }) - } -} diff --git a/internal/store/execution_store.go b/internal/store/execution_store.go deleted file mode 100644 index ab4b55a..0000000 --- a/internal/store/execution_store.go +++ /dev/null @@ -1,523 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -// BeginAttempt creates the next immutable attempt number for a running logical -// run. At most one attempt may be active for a run at a time. -func (s *RunStore) BeginAttempt(ctx context.Context, runID string) (domain.RunAttempt, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return domain.RunAttempt{}, err - } - defer tx.Rollback() - - run, err := getRunTx(ctx, tx, runID) - if err == sql.ErrNoRows { - return domain.RunAttempt{}, domain.NotFound("run not found") - } - if err != nil { - return domain.RunAttempt{}, err - } - if run.State != domain.RunRunning { - return domain.RunAttempt{}, domain.Conflict("attempt requires a running run") - } - - var active bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS( - SELECT 1 FROM run_attempts WHERE run_id=? AND state=? -)`, runID, string(domain.RunAttemptActive)).Scan(&active); err != nil { - return domain.RunAttempt{}, err - } - if active { - return domain.RunAttempt{}, domain.Conflict("run already has an active attempt") - } - var completed bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS( - SELECT 1 FROM run_attempts WHERE run_id=? AND state=? -)`, runID, string(domain.RunAttemptCompleted)).Scan(&completed); err != nil { - return domain.RunAttempt{}, err - } - if completed { - return domain.RunAttempt{}, domain.Conflict("run already has a completed attempt") - } - var executed bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS( - SELECT 1 - FROM tool_steps AS step - JOIN run_attempts AS attempt ON attempt.id=step.attempt_id - WHERE attempt.run_id=? AND step.state IN (?, ?) -)`, runID, string(domain.ToolStepCompleted), string(domain.ToolStepAmbiguous)).Scan(&executed); err != nil { - return domain.RunAttempt{}, err - } - if executed { - return domain.RunAttempt{}, domain.Conflict("run has prior tool execution that requires recovery") - } - - var attemptNo int - if err := tx.QueryRowContext(ctx, ` -SELECT COALESCE(MAX(attempt_no), 0) + 1 -FROM run_attempts -WHERE run_id=?`, runID).Scan(&attemptNo); err != nil { - return domain.RunAttempt{}, err - } - now := s.clock.Now().UTC() - attempt := domain.RunAttempt{ - ID: s.ids.NewID(domain.PrefixRunAttempt), - RunID: runID, - AttemptNo: attemptNo, - State: domain.RunAttemptActive, - CreatedAt: now, - UpdatedAt: now, - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO run_attempts - (id, run_id, attempt_no, state, error, created_at, updated_at, finished_at) -VALUES (?, ?, ?, ?, NULL, ?, ?, NULL)`, - attempt.ID, attempt.RunID, attempt.AttemptNo, string(attempt.State), - timeVal(attempt.CreatedAt), timeVal(attempt.UpdatedAt)); err != nil { - return domain.RunAttempt{}, err - } - if err := tx.Commit(); err != nil { - return domain.RunAttempt{}, err - } - return attempt, nil -} - -// FinishAttempt closes an active attempt without erasing its tool-step facts. -// A successful attempt requires every prepared step to have a durable result. -// No terminal attempt may retain a started step: recovery must first classify -// such a step completed or ambiguous. -func (s *RunStore) FinishAttempt( - ctx context.Context, - attemptID string, - state domain.RunAttemptState, - attemptError *string, -) (domain.RunAttempt, error) { - switch state { - case domain.RunAttemptCompleted: - if attemptError != nil { - return domain.RunAttempt{}, domain.Validation("completed attempt cannot carry an error") - } - case domain.RunAttemptFailed: - if attemptError == nil || *attemptError == "" { - return domain.RunAttempt{}, domain.Validation("failed attempt requires an error") - } - case domain.RunAttemptInterrupted: - // An interrupt is not necessarily an error. - default: - return domain.RunAttempt{}, domain.Validation("invalid terminal attempt state") - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return domain.RunAttempt{}, err - } - defer tx.Rollback() - - attempt, err := getRunAttemptTx(ctx, tx, attemptID) - if err == sql.ErrNoRows { - return domain.RunAttempt{}, domain.NotFound("run attempt not found") - } - if err != nil { - return domain.RunAttempt{}, err - } - if attempt.State != domain.RunAttemptActive { - return domain.RunAttempt{}, domain.Conflict("run attempt is not active") - } - - var started int - if err := tx.QueryRowContext(ctx, ` -SELECT COUNT(*) FROM tool_steps WHERE attempt_id=? AND state=?`, - attemptID, string(domain.ToolStepStarted)).Scan(&started); err != nil { - return domain.RunAttempt{}, err - } - if started > 0 { - return domain.RunAttempt{}, domain.Conflict("run attempt has unclassified started tool steps") - } - if state == domain.RunAttemptCompleted { - var incomplete int - if err := tx.QueryRowContext(ctx, ` -SELECT COUNT(*) FROM tool_steps WHERE attempt_id=? AND state<>?`, - attemptID, string(domain.ToolStepCompleted)).Scan(&incomplete); err != nil { - return domain.RunAttempt{}, err - } - if incomplete > 0 { - return domain.RunAttempt{}, domain.Conflict("completed attempt has non-completed tool steps") - } - } - - now := s.clock.Now().UTC() - result, err := tx.ExecContext(ctx, ` -UPDATE run_attempts -SET state=?, error=?, updated_at=?, finished_at=? -WHERE id=? AND state=?`, - string(state), nullableString(attemptError), timeVal(now), timeVal(now), - attemptID, string(domain.RunAttemptActive)) - if err != nil { - return domain.RunAttempt{}, err - } - affected, err := result.RowsAffected() - if err != nil { - return domain.RunAttempt{}, err - } - if affected != 1 { - return domain.RunAttempt{}, domain.Conflict("run attempt is not active") - } - if err := tx.Commit(); err != nil { - return domain.RunAttempt{}, err - } - attempt.State = state - attempt.Error = attemptError - attempt.UpdatedAt = now - attempt.FinishedAt = &now - return attempt, nil -} - -// PrepareToolStep persists the model's tool request before an executor is -// invoked. ordinal is stable within the attempt and ToolUseEventID is allocated -// from the public event ID space for later event correlation. -func (s *RunStore) PrepareToolStep( - ctx context.Context, - attemptID string, - ordinal int, - toolUseEventID string, - toolName string, - input map[string]any, -) (domain.ToolStep, error) { - if ordinal < 0 { - return domain.ToolStep{}, domain.Validation("tool step ordinal must be non-negative") - } - if toolUseEventID == "" { - return domain.ToolStep{}, domain.Validation("tool step event id is required") - } - if toolName == "" { - return domain.ToolStep{}, domain.Validation("tool step name is required") - } - if input == nil { - return domain.ToolStep{}, domain.Validation("tool step input is required") - } - inputJSON, err := json.Marshal(input) - if err != nil { - return domain.ToolStep{}, fmt.Errorf("store: encode tool step input: %w", err) - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return domain.ToolStep{}, err - } - defer tx.Rollback() - - attempt, err := getRunAttemptTx(ctx, tx, attemptID) - if err == sql.ErrNoRows { - return domain.ToolStep{}, domain.NotFound("run attempt not found") - } - if err != nil { - return domain.ToolStep{}, err - } - if attempt.State != domain.RunAttemptActive { - return domain.ToolStep{}, domain.Conflict("tool step requires an active attempt") - } - var exists bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS( - SELECT 1 FROM tool_steps - WHERE (attempt_id=? AND ordinal=?) OR tool_use_event_id=? -)`, attemptID, ordinal, toolUseEventID).Scan(&exists); err != nil { - return domain.ToolStep{}, err - } - if exists { - return domain.ToolStep{}, domain.Conflict("tool step ordinal or event id already exists") - } - - now := s.clock.Now().UTC() - step := domain.ToolStep{ - ID: s.ids.NewID(domain.PrefixToolStep), - AttemptID: attemptID, - Ordinal: ordinal, - ToolUseEventID: toolUseEventID, - ToolName: toolName, - Input: input, - State: domain.ToolStepPrepared, - CreatedAt: now, - UpdatedAt: now, - } - if _, err := tx.ExecContext(ctx, ` -INSERT INTO tool_steps - (id, attempt_id, ordinal, tool_use_event_id, tool_name, input, state, result, - created_at, updated_at, started_at, finished_at) -VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL)`, - step.ID, step.AttemptID, step.Ordinal, step.ToolUseEventID, step.ToolName, - string(inputJSON), string(step.State), timeVal(step.CreatedAt), timeVal(step.UpdatedAt)); err != nil { - return domain.ToolStep{}, err - } - if err := tx.Commit(); err != nil { - return domain.ToolStep{}, err - } - return step, nil -} - -func (s *RunStore) StartToolStep(ctx context.Context, stepID string) (domain.ToolStep, error) { - return s.transitionToolStep(ctx, stepID, domain.ToolStepPrepared, domain.ToolStepStarted, nil) -} - -func (s *RunStore) CompleteToolStep( - ctx context.Context, - stepID string, - result domain.ToolStepResult, -) (domain.ToolStep, error) { - return s.transitionToolStep(ctx, stepID, domain.ToolStepStarted, domain.ToolStepCompleted, &result) -} - -func (s *RunStore) MarkToolStepAmbiguous(ctx context.Context, stepID string) (domain.ToolStep, error) { - return s.transitionToolStep(ctx, stepID, domain.ToolStepStarted, domain.ToolStepAmbiguous, nil) -} - -func (s *RunStore) transitionToolStep( - ctx context.Context, - stepID string, - from domain.ToolStepState, - to domain.ToolStepState, - stepResult *domain.ToolStepResult, -) (domain.ToolStep, error) { - var resultJSON any - if stepResult != nil { - encoded, err := json.Marshal(stepResult) - if err != nil { - return domain.ToolStep{}, fmt.Errorf("store: encode tool step result: %w", err) - } - resultJSON = string(encoded) - } - - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return domain.ToolStep{}, err - } - defer tx.Rollback() - - step, err := getToolStepTx(ctx, tx, stepID) - if err == sql.ErrNoRows { - return domain.ToolStep{}, domain.NotFound("tool step not found") - } - if err != nil { - return domain.ToolStep{}, err - } - if step.State != from { - return domain.ToolStep{}, domain.Conflict("invalid tool step transition") - } - - now := s.clock.Now().UTC() - startedAt := nullableTime(step.StartedAt) - finishedAt := nullableTime(step.FinishedAt) - if to == domain.ToolStepStarted { - startedAt = timeVal(now) - } - if to == domain.ToolStepCompleted || to == domain.ToolStepAmbiguous { - finishedAt = timeVal(now) - } - updateResult := any(nil) - if to == domain.ToolStepCompleted { - updateResult = resultJSON - } - result, err := tx.ExecContext(ctx, ` -UPDATE tool_steps -SET state=?, result=?, updated_at=?, started_at=?, finished_at=? -WHERE id=? AND state=?`, - string(to), updateResult, timeVal(now), startedAt, finishedAt, stepID, string(from)) - if err != nil { - return domain.ToolStep{}, err - } - affected, err := result.RowsAffected() - if err != nil { - return domain.ToolStep{}, err - } - if affected != 1 { - return domain.ToolStep{}, domain.Conflict("invalid tool step transition") - } - if err := tx.Commit(); err != nil { - return domain.ToolStep{}, err - } - step.State = to - step.Result = stepResult - step.UpdatedAt = now - if to == domain.ToolStepStarted { - step.StartedAt = &now - } - if to == domain.ToolStepCompleted || to == domain.ToolStepAmbiguous { - step.FinishedAt = &now - } - return step, nil -} - -func (s *RunStore) GetAttempt(ctx context.Context, attemptID string) (domain.RunAttempt, error) { - attempt, err := getRunAttemptTx(ctx, s.db, attemptID) - if err == sql.ErrNoRows { - return domain.RunAttempt{}, domain.NotFound("run attempt not found") - } - return attempt, err -} - -func (s *RunStore) ListAttempts(ctx context.Context, runID string) ([]domain.RunAttempt, error) { - rows, err := s.db.QueryContext(ctx, ` -SELECT id, run_id, attempt_no, state, error, created_at, updated_at, finished_at -FROM run_attempts -WHERE run_id=? -ORDER BY attempt_no`, runID) - if err != nil { - return nil, err - } - defer rows.Close() - - var attempts []domain.RunAttempt - for rows.Next() { - attempt, err := scanRunAttempt(rows) - if err != nil { - return nil, err - } - attempts = append(attempts, attempt) - } - return attempts, rows.Err() -} - -func (s *RunStore) GetToolStep(ctx context.Context, stepID string) (domain.ToolStep, error) { - step, err := getToolStepTx(ctx, s.db, stepID) - if err == sql.ErrNoRows { - return domain.ToolStep{}, domain.NotFound("tool step not found") - } - return step, err -} - -func (s *RunStore) ListToolSteps(ctx context.Context, attemptID string) ([]domain.ToolStep, error) { - rows, err := s.db.QueryContext(ctx, ` -SELECT id, attempt_id, ordinal, tool_use_event_id, tool_name, input, state, result, - created_at, updated_at, started_at, finished_at -FROM tool_steps -WHERE attempt_id=? -ORDER BY ordinal`, attemptID) - if err != nil { - return nil, err - } - defer rows.Close() - - var steps []domain.ToolStep - for rows.Next() { - step, err := scanToolStep(rows) - if err != nil { - return nil, err - } - steps = append(steps, step) - } - return steps, rows.Err() -} - -type rowScanner interface { - Scan(...any) error -} - -func getRunAttemptTx(ctx context.Context, q runQueryRower, attemptID string) (domain.RunAttempt, error) { - return scanRunAttempt(q.QueryRowContext(ctx, ` -SELECT id, run_id, attempt_no, state, error, created_at, updated_at, finished_at -FROM run_attempts -WHERE id=?`, attemptID)) -} - -func scanRunAttempt(row rowScanner) (domain.RunAttempt, error) { - var ( - attempt domain.RunAttempt - state, createdAt, updatedAt string - attemptError, finishedAt sql.NullString - ) - if err := row.Scan( - &attempt.ID, &attempt.RunID, &attempt.AttemptNo, &state, &attemptError, - &createdAt, &updatedAt, &finishedAt, - ); err != nil { - return domain.RunAttempt{}, err - } - attempt.State = domain.RunAttemptState(state) - if attemptError.Valid { - attempt.Error = &attemptError.String - } - var err error - attempt.CreatedAt, err = parseRFC3339(createdAt) - if err != nil { - return domain.RunAttempt{}, err - } - attempt.UpdatedAt, err = parseRFC3339(updatedAt) - if err != nil { - return domain.RunAttempt{}, err - } - attempt.FinishedAt, err = parseNullableRFC3339(finishedAt) - return attempt, err -} - -func getToolStepTx(ctx context.Context, q runQueryRower, stepID string) (domain.ToolStep, error) { - return scanToolStep(q.QueryRowContext(ctx, ` -SELECT id, attempt_id, ordinal, tool_use_event_id, tool_name, input, state, result, - created_at, updated_at, started_at, finished_at -FROM tool_steps -WHERE id=?`, stepID)) -} - -func scanToolStep(row rowScanner) (domain.ToolStep, error) { - var ( - step domain.ToolStep - inputJSON, state string - resultJSON sql.NullString - createdAt, updatedAt string - startedAt, finishedAt sql.NullString - ) - if err := row.Scan( - &step.ID, &step.AttemptID, &step.Ordinal, &step.ToolUseEventID, - &step.ToolName, &inputJSON, &state, &resultJSON, &createdAt, &updatedAt, - &startedAt, &finishedAt, - ); err != nil { - return domain.ToolStep{}, err - } - if err := json.Unmarshal([]byte(inputJSON), &step.Input); err != nil { - return domain.ToolStep{}, fmt.Errorf("store: decode tool step input: %w", err) - } - step.State = domain.ToolStepState(state) - if resultJSON.Valid { - var result domain.ToolStepResult - if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil { - return domain.ToolStep{}, fmt.Errorf("store: decode tool step result: %w", err) - } - step.Result = &result - } - var err error - step.CreatedAt, err = parseRFC3339(createdAt) - if err != nil { - return domain.ToolStep{}, err - } - step.UpdatedAt, err = parseRFC3339(updatedAt) - if err != nil { - return domain.ToolStep{}, err - } - step.StartedAt, err = parseNullableRFC3339(startedAt) - if err != nil { - return domain.ToolStep{}, err - } - step.FinishedAt, err = parseNullableRFC3339(finishedAt) - return step, err -} - -func parseNullableRFC3339(value sql.NullString) (*time.Time, error) { - if !value.Valid { - return nil, nil - } - parsed, err := parseRFC3339(value.String) - if err != nil { - return nil, err - } - return &parsed, nil -} diff --git a/internal/store/execution_store_test.go b/internal/store/execution_store_test.go deleted file mode 100644 index 0698ed8..0000000 --- a/internal/store/execution_store_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package store - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -func claimExecutionRun(t *testing.T, runs *RunStore, session domain.Session) domain.SessionRun { - t.Helper() - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{ - Type: domain.EvUserMessage, - }}); err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - return claim.Run -} - -func TestExecutionStore_AttemptAndToolStepLifecycle(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - run := claimExecutionRun(t, runs, session) - - attempt, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - if attempt.AttemptNo != 1 || attempt.State != domain.RunAttemptActive { - t.Fatalf("attempt = %#v", attempt) - } - if _, err := runs.BeginAttempt(ctx, run.ID); !isConflict(err) { - t.Fatalf("second active attempt err = %v, want conflict", err) - } - - input := map[string]any{"path": "notes.txt", "content": "hello"} - step, err := runs.PrepareToolStep(ctx, attempt.ID, 0, "sevt_tool_1", "write", input) - if err != nil { - t.Fatal(err) - } - if step.State != domain.ToolStepPrepared || step.ToolUseEventID != "sevt_tool_1" { - t.Fatalf("prepared step = %#v", step) - } - if _, err := runs.FinishAttempt(ctx, attempt.ID, domain.RunAttemptCompleted, nil); !isConflict(err) { - t.Fatalf("completed attempt with prepared step err = %v, want conflict", err) - } - - step, err = runs.StartToolStep(ctx, step.ID) - if err != nil { - t.Fatal(err) - } - if step.State != domain.ToolStepStarted || step.StartedAt == nil { - t.Fatalf("started step = %#v", step) - } - if _, err := runs.FinishAttempt(ctx, attempt.ID, domain.RunAttemptInterrupted, nil); !isConflict(err) { - t.Fatalf("attempt with started step err = %v, want conflict", err) - } - - wantResult := domain.ToolStepResult{ - Content: []any{map[string]any{"type": "text", "text": "wrote notes.txt"}}, - IsError: false, - } - step, err = runs.CompleteToolStep(ctx, step.ID, wantResult) - if err != nil { - t.Fatal(err) - } - if step.State != domain.ToolStepCompleted || step.Result == nil || - step.FinishedAt == nil || step.Result.IsError { - t.Fatalf("completed step = %#v", step) - } - - attempt, err = runs.FinishAttempt(ctx, attempt.ID, domain.RunAttemptCompleted, nil) - if err != nil { - t.Fatal(err) - } - if attempt.State != domain.RunAttemptCompleted || attempt.FinishedAt == nil { - t.Fatalf("finished attempt = %#v", attempt) - } - if _, err := runs.BeginAttempt(ctx, run.ID); !isConflict(err) { - t.Fatalf("retry after completed attempt err = %v, want conflict", err) - } - - stored, err := runs.GetToolStep(ctx, step.ID) - if err != nil { - t.Fatal(err) - } - if stored.Input["path"] != "notes.txt" || stored.Result == nil || - len(stored.Result.Content) != 1 { - t.Fatalf("stored step = %#v", stored) - } - attempts, err := runs.ListAttempts(ctx, run.ID) - if err != nil || len(attempts) != 1 || attempts[0].ID != attempt.ID { - t.Fatalf("attempts = %#v err=%v", attempts, err) - } - steps, err := runs.ListToolSteps(ctx, attempt.ID) - if err != nil || len(steps) != 1 || steps[0].ID != step.ID { - t.Fatalf("steps = %#v err=%v", steps, err) - } -} - -func TestExecutionStore_AmbiguousStepBlocksAnotherAttempt(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - run := claimExecutionRun(t, runs, session) - attempt, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - step, err := runs.PrepareToolStep( - ctx, attempt.ID, 0, "sevt_tool_1", "bash", map[string]any{"command": "touch marker"}, - ) - if err != nil { - t.Fatal(err) - } - if _, err := runs.CompleteToolStep(ctx, step.ID, domain.ToolStepResult{}); !isConflict(err) { - t.Fatalf("complete before start err = %v, want conflict", err) - } - if _, err := runs.StartToolStep(ctx, step.ID); err != nil { - t.Fatal(err) - } - step, err = runs.MarkToolStepAmbiguous(ctx, step.ID) - if err != nil { - t.Fatal(err) - } - if step.State != domain.ToolStepAmbiguous || step.Result != nil { - t.Fatalf("ambiguous step = %#v", step) - } - if _, err := runs.FinishAttempt(ctx, attempt.ID, domain.RunAttemptInterrupted, nil); err != nil { - t.Fatal(err) - } - if _, err := runs.BeginAttempt(ctx, run.ID); !isConflict(err) { - t.Fatalf("retry with ambiguous side effect err = %v, want conflict", err) - } -} - -func TestExecutionStore_CompletedStepInFailedAttemptBlocksAnotherAttempt(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - run := claimExecutionRun(t, runs, session) - attempt, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - step, err := runs.PrepareToolStep( - ctx, attempt.ID, 0, "sevt_tool_1", "write", map[string]any{"path": "marker"}, - ) - if err != nil { - t.Fatal(err) - } - if _, err := runs.StartToolStep(ctx, step.ID); err != nil { - t.Fatal(err) - } - if _, err := runs.CompleteToolStep(ctx, step.ID, domain.ToolStepResult{}); err != nil { - t.Fatal(err) - } - message := "model failed after the tool completed" - if _, err := runs.FinishAttempt(ctx, attempt.ID, domain.RunAttemptFailed, &message); err != nil { - t.Fatal(err) - } - if _, err := runs.BeginAttempt(ctx, run.ID); !isConflict(err) { - t.Fatalf("retry after completed side effect err = %v, want conflict", err) - } -} - -func TestExecutionStore_SafeFailedAttemptCanBeRetried(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - run := claimExecutionRun(t, runs, session) - first, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - if _, err := runs.PrepareToolStep( - ctx, first.ID, 0, "sevt_tool_1", "read", map[string]any{"path": "missing"}, - ); err != nil { - t.Fatal(err) - } - message := "model connection failed before tool execution" - if _, err := runs.FinishAttempt(ctx, first.ID, domain.RunAttemptFailed, &message); err != nil { - t.Fatal(err) - } - second, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - if second.AttemptNo != 2 { - t.Fatalf("second attempt number = %d, want 2", second.AttemptNo) - } -} - -func TestExecutionStore_JournalSurvivesReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "execution.db") - db, err := Open(path) - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - now := time.Unix(1, 0).UTC() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: now} - if err := NewAgentRepo(db).PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "agent", Model: domain.Model{ID: "model"}, - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := NewEnvironmentRepo(db).Put(ctx, domain.Environment{ - ID: "env_1", Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - session := domain.Session{ - ID: "sesn_1", AgentID: "agent_1", AgentVersion: 1, EnvironmentID: "env_1", - Status: domain.StatusIdle, CreatedAt: now, UpdatedAt: now, - } - runs := NewRunStore(db, ids, clk) - run := claimExecutionRun(t, runs, session) - attempt, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - step, err := runs.PrepareToolStep( - ctx, attempt.ID, 0, "sevt_tool_1", "write", map[string]any{"path": "state.txt"}, - ) - if err != nil { - t.Fatal(err) - } - if _, err := runs.StartToolStep(ctx, step.ID); err != nil { - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - - reopened, err := Open(path) - if err != nil { - t.Fatal(err) - } - defer reopened.Close() - reopenedRuns := NewRunStore(reopened, domain.NewSeqIDGen(), clk) - storedAttempt, err := reopenedRuns.GetAttempt(ctx, attempt.ID) - if err != nil { - t.Fatal(err) - } - storedStep, err := reopenedRuns.GetToolStep(ctx, step.ID) - if err != nil { - t.Fatal(err) - } - if storedAttempt.State != domain.RunAttemptActive || - storedStep.State != domain.ToolStepStarted || - storedStep.ToolUseEventID != "sevt_tool_1" { - t.Fatalf("reopened attempt=%#v step=%#v", storedAttempt, storedStep) - } -} - -func TestExecutionStore_SessionDeleteCascadesJournal(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - run := claimExecutionRun(t, runs, session) - attempt, err := runs.BeginAttempt(ctx, run.ID) - if err != nil { - t.Fatal(err) - } - if _, err := runs.PrepareToolStep( - ctx, attempt.ID, 0, "sevt_tool_1", "read", map[string]any{"path": "file"}, - ); err != nil { - t.Fatal(err) - } - if err := NewSessionRepo(db).Delete(ctx, session.ID); err != nil { - t.Fatal(err) - } - var attempts, steps int - if err := db.QueryRow(`SELECT COUNT(*) FROM run_attempts`).Scan(&attempts); err != nil { - t.Fatal(err) - } - if err := db.QueryRow(`SELECT COUNT(*) FROM tool_steps`).Scan(&steps); err != nil { - t.Fatal(err) - } - if attempts != 0 || steps != 0 { - t.Fatalf("journal rows after session delete: attempts=%d steps=%d", attempts, steps) - } -} diff --git a/internal/store/pending_store.go b/internal/store/pending_store.go deleted file mode 100644 index bf340cf..0000000 --- a/internal/store/pending_store.go +++ /dev/null @@ -1,195 +0,0 @@ -package store - -import ( - "context" - "database/sql" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -// pendingActionRow is the internal projection of an unresolved pending action -// used by the claim gate and admission validation. It is never serialized onto -// the public wire. -type pendingActionRow struct { - id string - actionEventID string - kind domain.PendingActionKind - resolvingEventID *string -} - -// unresolvedPendingActions returns every pending action for the session that has -// not yet been resolved (resolved_at IS NULL), in stable creation order. A -// non-empty result gates ordinary queued runs: only a run whose trigger matches -// one of these (by resolving_event_id) may be claimed. -func unresolvedPendingActions(ctx context.Context, tx *sql.Tx, sessionID string) ([]pendingActionRow, error) { - rows, err := tx.QueryContext(ctx, ` -SELECT id, action_event_id, kind, resolving_event_id -FROM pending_actions -WHERE session_id=? AND resolved_at IS NULL -ORDER BY created_at, id`, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []pendingActionRow - for rows.Next() { - var ( - r pendingActionRow - kind string - resolving sql.NullString - ) - if err := rows.Scan(&r.id, &r.actionEventID, &kind, &resolving); err != nil { - return nil, err - } - r.kind = domain.PendingActionKind(kind) - if resolving.Valid { - r.resolvingEventID = &resolving.String - } - out = append(out, r) - } - return out, rows.Err() -} - -// insertPendingActionTx persists one durable pending action for a parked action -// event. allowed maps the ids of the action events THIS Complete call just -// committed to their committed type and payload; only such an id may park. This -// rejects an old action event from an earlier run in the same session, a phantom -// id, or any output that is not in the current drafts — merely session-local -// existence is not trusted. The expected response kind is derived from the -// committed event's own type AND payload (an agent.tool_use parks only when its -// evaluated_permission is "ask") — never a caller string. Duplicate action ids -// within one park are rejected by the caller before reaching this function, so -// the (session_id, action_event_id) ON CONFLICT clause only guards a repeated -// park of the same event across separate Complete calls, keeping it a single -// gate rather than erroring. -func insertPendingActionTx( - ctx context.Context, - tx *sql.Tx, - ids domain.IDGenerator, - clock domain.Clock, - sessionID string, - actionEventID string, - allowed map[string]domain.Event, -) error { - event, ok := allowed[actionEventID] - if !ok { - return domain.Validation("pending action must reference an action event committed by this run") - } - kind, ok := domain.PendingActionKindForEvent(event.Type, event.Payload) - if !ok { - return domain.Validation("event type cannot park a pending action") - } - now := timeVal(clock.Now().UTC()) - _, err := tx.ExecContext(ctx, ` -INSERT INTO pending_actions (id, session_id, action_event_id, kind, resolving_event_id, created_at, resolved_at) -VALUES (?, ?, ?, ?, NULL, ?, NULL) -ON CONFLICT (session_id, action_event_id) DO NOTHING`, - ids.NewID(domain.PrefixPendingAction), sessionID, actionEventID, string(kind), now) - return err -} - -// claimPendingActionTx validates that a resolution event may resolve a pending -// action and records the resolving event id. It enforces, atomically: -// - the referenced action event has an OPEN pending action in this session -// (unknown / wrong-session references find no row); -// - the pending action's kind matches the resolution kind (wrong-kind); -// - the pending action has not already been resolved or claimed by an earlier -// resolution (already-resolved / duplicate). -// -// On success it sets resolving_event_id to the committed resolution event id, -// moving the pending action from OPEN to CLAIMED. resolved_at stays NULL until -// the resume run closes, so the gate remains active for ordinary queued runs -// while the matching resume run is the one allowed to proceed. -func claimPendingActionTx( - ctx context.Context, - tx *sql.Tx, - sessionID string, - actionEventID string, - kind domain.PendingActionKind, - resolvingEventID string, -) error { - var ( - storedKind string - resolvedAt sql.NullString - resolving sql.NullString - ) - err := tx.QueryRowContext(ctx, ` -SELECT kind, resolved_at, resolving_event_id -FROM pending_actions -WHERE session_id=? AND action_event_id=?`, sessionID, actionEventID).Scan(&storedKind, &resolvedAt, &resolving) - if err == sql.ErrNoRows { - return domain.Validation("resolution references unknown pending action") - } - if err != nil { - return err - } - if domain.PendingActionKind(storedKind) != kind { - return domain.Validation("resolution kind does not match the pending action") - } - if resolvedAt.Valid { - return domain.Conflict("pending action is already resolved") - } - if resolving.Valid { - return domain.Conflict("pending action already has a pending resolution") - } - result, err := tx.ExecContext(ctx, ` -UPDATE pending_actions -SET resolving_event_id=? -WHERE session_id=? AND action_event_id=? AND resolving_event_id IS NULL AND resolved_at IS NULL`, - resolvingEventID, sessionID, actionEventID) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected != 1 { - // Lost a race with a concurrent resolution for the same action; treat as a - // duplicate rather than silently succeeding. - return domain.Conflict("pending action already has a pending resolution") - } - return nil -} - -// resolvePendingActionsForTriggers marks resolved every pending action whose -// resolving_event_id is one of this run's trigger events. Called in the same -// transaction that closes the resume run, so the ordinary queued work a park -// blocked becomes claimable only after the resume run has closed. Returns the -// number of pending actions resolved. -func resolvePendingActionsForTriggers( - ctx context.Context, - tx *sql.Tx, - clock domain.Clock, - sessionID string, - triggerEventIDs []string, -) (int, error) { - resolved := 0 - now := timeVal(clock.Now().UTC()) - for _, triggerID := range triggerEventIDs { - result, err := tx.ExecContext(ctx, ` -UPDATE pending_actions -SET resolved_at=? -WHERE session_id=? AND resolving_event_id=? AND resolved_at IS NULL`, - now, sessionID, triggerID) - if err != nil { - return resolved, err - } - affected, err := result.RowsAffected() - if err != nil { - return resolved, err - } - resolved += int(affected) - } - return resolved, nil -} - -// hasUnresolvedPendingTx reports whether the session still has any pending -// action that has not been resolved. While true, ordinary queued runs are gated. -func hasUnresolvedPendingTx(ctx context.Context, tx *sql.Tx, sessionID string) (bool, error) { - var exists bool - err := tx.QueryRowContext(ctx, ` -SELECT EXISTS(SELECT 1 FROM pending_actions WHERE session_id=? AND resolved_at IS NULL)`, - sessionID).Scan(&exists) - return exists, err -} diff --git a/internal/store/pending_store_test.go b/internal/store/pending_store_test.go deleted file mode 100644 index 7c97fc1..0000000 --- a/internal/store/pending_store_test.go +++ /dev/null @@ -1,824 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "errors" - "path/filepath" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -// parkRun claims the session's next run and completes it as a parked -// custom-tool run: it commits an agent.custom_tool_use action event plus the -// terminal status_idle{requires_action}, and persists a durable pending action -// for that action event in the same completion transaction. Returns the -// committed action event id (the custom_tool_use_id a resolution must reference). -func parkRun(t *testing.T, runs *RunStore, sessionID string) string { - t.Helper() - ctx := context.Background() - claim, ok, err := runs.ClaimNext(ctx, sessionID) - if err != nil || !ok { - t.Fatalf("park: claim ok=%v err=%v", ok, err) - } - actionID := "sevt_action_" + claim.Run.ID - completion, err := runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: actionID, Type: domain.EvAgentCustomToolUse, Payload: map[string]any{"name": "get_metrics", "input": map[string]any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{actionID}) - if err != nil { - t.Fatalf("park: complete: %v", err) - } - if completion.Session.Status != domain.StatusIdle { - t.Fatalf("park: session status = %s, want idle", completion.Session.Status) - } - return actionID -} - -// TestPending_ParkPersistsPendingActionInCompletionTx proves the pending action -// and the run completion are one transaction: after Complete returns, the -// session is idle, the run is completed, the action event exists, and a durable -// unresolved pending action of the derived kind references it. -func TestPending_ParkPersistsPendingActionInCompletionTx(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - actionID := parkRun(t, runs, session.ID) - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback() - pending, err := unresolvedPendingActions(ctx, tx, session.ID) - if err != nil { - t.Fatal(err) - } - if len(pending) != 1 { - t.Fatalf("unresolved pending = %d, want 1", len(pending)) - } - if pending[0].actionEventID != actionID { - t.Fatalf("pending action event id = %s, want %s", pending[0].actionEventID, actionID) - } - if pending[0].kind != domain.PendingCustomToolResult { - t.Fatalf("pending kind = %s, want custom_tool_result", pending[0].kind) - } - if pending[0].resolvingEventID != nil { - t.Fatalf("pending resolving id = %v, want nil (open)", *pending[0].resolvingEventID) - } -} - -// TestPending_QueuedRunBeforeParkStaysGated proves an ordinary run queued BEFORE -// a park remains unclaimable while the pending action is unresolved, even though -// it was admitted first. -func TestPending_QueuedRunBeforeParkStaysGated(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - // Two ordinary user messages admitted up front: run 1 will park, run 2 is the - // earlier-queued ordinary work that must be gated. - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "one"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "two"}}}}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 2 { - t.Fatalf("runs = %d, want 2", len(admission.Runs)) - } - // Run 1 parks. - parkRun(t, runs, session.ID) - - // Run 2 was queued before the park; it must not be claimable while the pending - // action is unresolved. - if claim, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("gated queued run was claimed: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } -} - -// TestPending_MatchingResolutionBypassesEarlierQueued proves a matching -// resolution trigger admitted LATER bypasses the earlier ordinary queued run: -// the resume run is claimed even though a lower-admission-seq ordinary run is -// still queued and gated. -func TestPending_MatchingResolutionBypassesEarlierQueued(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "one"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "two"}}}}, - }) - if err != nil { - t.Fatal(err) - } - earlierQueuedRunID := admission.Runs[1].ID - actionID := parkRun(t, runs, session.ID) - - // Admit the matching resolution. This creates a new (highest admission_seq) - // run whose trigger resolves the pending action. - resume, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}) - if err != nil { - t.Fatalf("admit resolution: %v", err) - } - if len(resume.Runs) != 1 { - t.Fatalf("resume runs = %d, want 1", len(resume.Runs)) - } - resumeRunID := resume.Runs[0].ID - - // The claim must be the resume run, NOT the earlier ordinary queued run. - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim resume: ok=%v err=%v", ok, err) - } - if claim.Run.ID != resumeRunID { - t.Fatalf("claimed run = %s, want resume run %s (earlier queued %s must stay gated)", - claim.Run.ID, resumeRunID, earlierQueuedRunID) - } - if claim.Run.AdmissionSeq <= admission.Runs[1].AdmissionSeq { - t.Fatalf("resume admission_seq %d should exceed earlier queued %d", - claim.Run.AdmissionSeq, admission.Runs[1].AdmissionSeq) - } -} - -// TestPending_ResumeCompletionReleasesEarlierQueued proves the gate clears only -// after the resume run closes successfully: the previously blocked ordinary run -// becomes claimable, and the session reopens to running for it. -func TestPending_ResumeCompletionReleasesEarlierQueued(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "one"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "two"}}}}, - }) - if err != nil { - t.Fatal(err) - } - earlierQueuedRunID := admission.Runs[1].ID - actionID := parkRun(t, runs, session.ID) - resume, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}) - if err != nil { - t.Fatal(err) - } - resumeClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || resumeClaim.Run.ID != resume.Runs[0].ID { - t.Fatalf("resume claim = %#v ok=%v err=%v", resumeClaim.Run, ok, err) - } - // Before the resume closes, the earlier queued ordinary run is still gated - // (also blocked by the one-running rule). - if _, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("earlier queued claimable before resume closed: ok=%v err=%v", ok, err) - } - - // Resume closes successfully; the pending action resolves in this same - // transaction, so the session must reopen to running for the still-queued run. - done, err := runs.Complete(ctx, resumeClaim.Run.ID, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": []any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "end_turn"}}}, - }, domain.StatusIdle, nil, nil) - if err != nil { - t.Fatal(err) - } - if done.Session.Status != domain.StatusRunning { - t.Fatalf("session after resume = %s, want running (gate cleared, queued work remains)", done.Session.Status) - } - // The previously blocked ordinary run is now claimable. - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || claim.Run.ID != earlierQueuedRunID { - t.Fatalf("post-resume claim = %#v ok=%v err=%v, want earlier queued %s", claim.Run, ok, err, earlierQueuedRunID) - } -} - -// TestPending_AdmissionRejectsBadResolutions proves unknown, wrong-kind, -// duplicate, and already-resolved references fail atomically at admission -// without creating any runnable work. -func TestPending_AdmissionRejectsBadResolutions(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - actionID := parkRun(t, runs, session.ID) - - // Unknown reference: no pending action exists for this id. - if _, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": "sevt_nope", "content": []any{}}, - }}); !isValidation(err) { - t.Fatalf("unknown reference err = %v, want validation", err) - } - - // Wrong kind: a tool_confirmation cannot resolve a custom_tool_result park. - if _, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserToolConfirmation, - Payload: map[string]any{"tool_use_id": actionID, "result": "allow"}, - }}); !isValidation(err) { - t.Fatalf("wrong-kind reference err = %v, want validation", err) - } - - // First valid resolution claims the pending action. - if _, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}); err != nil { - t.Fatalf("first resolution: %v", err) - } - // Duplicate resolution for the same still-open action is a conflict. - if _, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}); !isConflict(err) { - t.Fatalf("duplicate resolution err = %v, want conflict", err) - } - - // A rejected resolution must not have created a runnable run: only the one - // valid resume run exists beyond the original message run. - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback() - var total int - if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM session_runs WHERE session_id=?`, session.ID).Scan(&total); err != nil { - t.Fatal(err) - } - // original message run (parked) + exactly one valid resume run = 2. - if total != 2 { - t.Fatalf("total runs = %d, want 2 (rejected resolutions created no runs)", total) - } -} - -// TestPending_WrongSessionReferenceRejected proves a resolution referencing a -// pending action that belongs to a different session is rejected atomically. -func TestPending_WrongSessionReferenceRejected(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - actionID := parkRun(t, runs, session.ID) - - // A second session with its own message run, never parked. - other := session - other.ID = "sesn_other" - if err := NewSessionRepo(db).CreateIfDependenciesActive(ctx, other); err != nil { - t.Fatal(err) - } - if _, err := runs.Admit(ctx, other.ID, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - // Referencing session A's parked action from session B must be rejected: the - // pending action does not exist in session B. - if _, err := runs.Admit(ctx, other.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}); !isValidation(err) { - t.Fatalf("cross-session reference err = %v, want validation", err) - } -} - -// TestPending_GateSurvivesReopen proves the durable pending gate survives a -// file-backed database close/reopen: after reopening, the earlier queued run is -// still gated, and a matching resolution admitted post-reopen resumes. -func TestPending_GateSurvivesReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "pending.db") - db, err := Open(path) - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - now := time.Unix(1, 0).UTC() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: now} - if err := NewAgentRepo(db).PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "agent", Model: domain.Model{ID: "model"}, CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := NewEnvironmentRepo(db).Put(ctx, domain.Environment{ - ID: "env_1", Name: "environment", ConfigType: "cloud", CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - session := domain.Session{ - ID: "sesn_1", AgentID: "agent_1", AgentVersion: 1, - EnvironmentID: "env_1", Status: domain.StatusIdle, CreatedAt: now, UpdatedAt: now, - } - runs := NewRunStore(db, ids, clk) - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "one"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "two"}}}}, - }) - if err != nil { - t.Fatal(err) - } - earlierQueuedRunID := admission.Runs[1].ID - actionID := parkRun(t, runs, session.ID) - if err := db.Close(); err != nil { - t.Fatal(err) - } - - reopened, err := Open(path) - if err != nil { - t.Fatal(err) - } - defer reopened.Close() - reopenedRuns := NewRunStore(reopened, ids, clk) - - // Gate survived: the earlier queued run is still not claimable. - if claim, ok, err := reopenedRuns.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("gated run claimable after reopen: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } - - // A matching resolution admitted after reopen resumes. - resume, err := reopenedRuns.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}) - if err != nil { - t.Fatalf("admit resolution after reopen: %v", err) - } - claim, ok, err := reopenedRuns.ClaimNext(ctx, session.ID) - if err != nil || !ok || claim.Run.ID != resume.Runs[0].ID { - t.Fatalf("resume claim after reopen = %#v ok=%v err=%v", claim.Run, ok, err) - } - if claim.Run.ID == earlierQueuedRunID { - t.Fatal("earlier queued run was claimed instead of the resume run after reopen") - } -} - -// TestPending_DeleteRemovesPendingState proves session deletion removes durable -// pending-action rows with the session. -func TestPending_DeleteRemovesPendingState(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - parkRun(t, runs, session.ID) - - if err := NewSessionRepo(db).Delete(ctx, session.ID); err != nil { - t.Fatal(err) - } - var count int - if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pending_actions WHERE session_id=?`, session.ID).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("pending_actions after delete = %d, want 0", count) - } -} - -// TestPending_MultiActionParkGatesAllButNoAggregateResume documents the explicit -// multi-action limitation: a run that parks with multiple action events persists -// and gates ALL of them, but this milestone does not implement an aggregated -// multi-action resume protocol. Resolving one action leaves the others -// unresolved, so the gate stays closed until every action is individually -// resolved. -func TestPending_MultiActionParkGatesAllButNoAggregateResume(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - // Park with TWO custom-tool action events in one completion. - a1 := "sevt_a1_" + claim.Run.ID - a2 := "sevt_a2_" + claim.Run.ID - if _, err := runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: a1, Type: domain.EvAgentCustomToolUse, Payload: map[string]any{"name": "t1", "input": map[string]any{}}}, - {ID: a2, Type: domain.EvAgentCustomToolUse, Payload: map[string]any{"name": "t2", "input": map[string]any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{a1, a2}); err != nil { - t.Fatal(err) - } - // Both are gated. - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - pending, err := unresolvedPendingActions(ctx, tx, session.ID) - tx.Rollback() - if err != nil { - t.Fatal(err) - } - if len(pending) != 2 { - t.Fatalf("unresolved pending = %d, want 2 (both action events gated)", len(pending)) - } - - // Resolve only the first action. The second is still unresolved, so the gate - // remains closed for ordinary work — the resume for a1 is the only claimable - // run, and after it closes a2 still gates. - resume, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": a1, "content": []any{}}, - }}) - if err != nil { - t.Fatalf("admit a1 resolution: %v", err) - } - rc, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || rc.Run.ID != resume.Runs[0].ID { - t.Fatalf("a1 resume claim = %#v ok=%v err=%v", rc.Run, ok, err) - } - done, err := runs.Complete(ctx, rc.Run.ID, []domain.EventDraft{ - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "end_turn"}}}, - }, domain.StatusIdle, nil, nil) - if err != nil { - t.Fatal(err) - } - // a2 still gates: the session must stay idle, not reopen to running, and no - // ordinary run is claimable. - if done.Session.Status != domain.StatusIdle { - t.Fatalf("session after partial resume = %s, want idle (a2 still gates)", done.Session.Status) - } - if claim, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("claim while a2 unresolved: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } -} - -// TestPending_OrdinaryWorkAdmittedDuringParkStaysIdleAndGated proves the -// state-machine fix: with no ordinary work queued behind a park, admitting a new -// ordinary user.message while the pending action is still OPEN creates its queued -// run but leaves the session idle, emits NO session.status_running, and the run -// is not claimable. Admitting the matching resolution afterward reopens the -// session to running and its resume run is claimed before the ordinary queued -// run; a normal release then lets the ordinary run proceed. -func TestPending_OrdinaryWorkAdmittedDuringParkStaysIdleAndGated(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - // A single trigger: run 1 parks. Nothing else is queued behind it. - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - actionID := parkRun(t, runs, session.ID) - - // Admit an ORDINARY user.message (not the matching resolution) while the park - // is still open. It must be durably queued but leave the session idle. - admission, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "meanwhile"}}}, - }}) - if err != nil { - t.Fatalf("admit ordinary message during park: %v", err) - } - if len(admission.Runs) != 1 { - t.Fatalf("ordinary admission runs = %d, want 1 (durably queued)", len(admission.Runs)) - } - ordinaryRunID := admission.Runs[0].ID - // The session projection must remain idle... - if admission.Session.Status != domain.StatusIdle { - t.Fatalf("session after gated admission = %s, want idle", admission.Session.Status) - } - // ...and NO session.status_running may have been emitted by this admission. - for _, ev := range admission.Events { - if ev.Type == domain.EvSessionStatusRunning { - t.Fatalf("gated admission emitted %s, want none", domain.EvSessionStatusRunning) - } - } - // The stored session must also be idle. - stored, err := getSessionTx(ctx, db, session.ID) - if err != nil { - t.Fatal(err) - } - if stored.Status != domain.StatusIdle { - t.Fatalf("stored session = %s, want idle (no status_running committed)", stored.Status) - } - - // The gated ordinary run must not be claimable while the park is open. - if claim, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("gated ordinary run was claimed: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } - - // Admit the matching resolution: this reopens the session to running and its - // resume run must be claimed before the earlier ordinary queued run. - resume, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": actionID, "content": []any{}}, - }}) - if err != nil { - t.Fatalf("admit resolution: %v", err) - } - if resume.Session.Status != domain.StatusRunning { - t.Fatalf("session after resolution admission = %s, want running", resume.Session.Status) - } - sawRunning := false - for _, ev := range resume.Events { - if ev.Type == domain.EvSessionStatusRunning { - sawRunning = true - } - } - if !sawRunning { - t.Fatalf("resolution admission did not emit %s", domain.EvSessionStatusRunning) - } - - resumeClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || resumeClaim.Run.ID != resume.Runs[0].ID { - t.Fatalf("resume claim = %#v ok=%v err=%v, want resume run %s (ordinary %s must stay gated)", - resumeClaim.Run, ok, err, resume.Runs[0].ID, ordinaryRunID) - } - - // Close the resume run normally; the gate clears, so the ordinary queued run - // becomes claimable and the session reopens to running for it. - done, err := runs.Complete(ctx, resumeClaim.Run.ID, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": []any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "end_turn"}}}, - }, domain.StatusIdle, nil, nil) - if err != nil { - t.Fatal(err) - } - if done.Session.Status != domain.StatusRunning { - t.Fatalf("session after resume = %s, want running (gate cleared, ordinary run queued)", done.Session.Status) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || claim.Run.ID != ordinaryRunID { - t.Fatalf("post-resume claim = %#v ok=%v err=%v, want ordinary run %s", claim.Run, ok, err, ordinaryRunID) - } -} - -// TestPending_InterruptWhileParkedStaysGated proves the honest gate semantics: a -// user.interrupt admitted while an unresolved pending action gates the session is -// enqueued like any ordinary run but is NOT claimable — it is not the matching -// resolution, so the pending-action claim gate blocks it. It stays queued while the -// park is open; only the matching resolution bypasses the gate. This documents that -// interrupting a parked session is unsupported/unproven in this milestone: a -// mid-park interrupt does not resolve the blocking action, and the session's -// requires_action projection must not be replaced by an idle/end_turn. -func TestPending_InterruptWhileParkedStaysGated(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - // Run 1 parks, leaving an unresolved pending action that gates the session. - parkRun(t, runs, session.ID) - - // Admit a user.interrupt while the park is open. It is durably queued but does - // not resolve the pending action. - admission, err := runs.Admit(ctx, session.ID, []domain.EventDraft{ - {Type: domain.EvUserInterrupt, Payload: map[string]any{}}, - }) - if err != nil { - t.Fatalf("admit interrupt during park: %v", err) - } - if len(admission.Runs) != 1 { - t.Fatalf("interrupt admission runs = %d, want 1 (durably queued)", len(admission.Runs)) - } - // The gated admission must leave the session idle and emit no status_running. - if admission.Session.Status != domain.StatusIdle { - t.Fatalf("session after gated interrupt admission = %s, want idle", admission.Session.Status) - } - for _, ev := range admission.Events { - if ev.Type == domain.EvSessionStatusRunning { - t.Fatalf("gated interrupt admission emitted %s, want none", domain.EvSessionStatusRunning) - } - } - - // The interrupt run is NOT claimable while the pending action is unresolved: the - // gate blocks it exactly like any other non-resolution run. - if claim, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("gated interrupt run was claimed: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } - - // The pending action remains unresolved, confirming the interrupt did not - // disturb the gate or replace the requires_action projection. - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback() - pending, err := unresolvedPendingActions(ctx, tx, session.ID) - if err != nil { - t.Fatal(err) - } - if len(pending) != 1 { - t.Fatalf("unresolved pending = %d, want 1 (interrupt did not resolve the park)", len(pending)) - } -} - -func isValidation(err error) bool { - var de *domain.DomainError - return errors.As(err, &de) && de.Kind == domain.KindValidation -} - -func isConflict(err error) bool { - var de *domain.DomainError - return errors.As(err, &de) && de.Kind == domain.KindConflict -} - -// TestPending_CompleteRejectsOldSameSessionActionAtomically proves Complete only -// accepts a pending action id that names an action event committed by THIS -// Complete call. An action event id from an EARLIER run in the same session is -// rejected, and the whole completion transaction rolls back: the current run -// stays running, its drafts are not committed, its trigger stays unprocessed, -// and no new pending action is created. -func TestPending_CompleteRejectsOldSameSessionActionAtomically(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - // Two ordinary triggers: run 1 parks (producing an old action event id), run 2 - // is the run whose Complete we will feed the STALE id. - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "one"}}}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{map[string]any{"type": "text", "text": "two"}}}}, - }); err != nil { - t.Fatal(err) - } - oldActionID := parkRun(t, runs, session.ID) - // Resolve the park so ordinary queued work becomes claimable again. - resume, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserCustomToolResult, - Payload: map[string]any{"custom_tool_use_id": oldActionID, "content": []any{}}, - }}) - if err != nil { - t.Fatal(err) - } - resumeClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || resumeClaim.Run.ID != resume.Runs[0].ID { - t.Fatalf("resume claim = %#v ok=%v err=%v", resumeClaim.Run, ok, err) - } - if _, err := runs.Complete(ctx, resumeClaim.Run.ID, []domain.EventDraft{ - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "end_turn"}}}, - }, domain.StatusIdle, nil, nil); err != nil { - t.Fatal(err) - } - - // Claim the earlier ordinary run and try to Complete it while parking on the - // STALE action event id from run 1. It must be rejected. - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim ordinary run: ok=%v err=%v", ok, err) - } - runningRunID := claim.Run.ID - trigger := claim.Triggers[0].ID - newActionID := "sevt_new_" + claim.Run.ID - _, err = runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: newActionID, Type: domain.EvAgentCustomToolUse, Payload: map[string]any{"name": "t", "input": map[string]any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{oldActionID}) - if !isValidation(err) { - t.Fatalf("stale action id err = %v, want validation", err) - } - - // Atomic rollback: the run is still running, its trigger unprocessed, its new - // draft action event never committed, and no pending action for the stale or - // new id was created by this failed call. - run, err := runs.Get(ctx, runningRunID) - if err != nil { - t.Fatal(err) - } - if run.State != domain.RunRunning { - t.Fatalf("run state = %s, want running (Complete rolled back)", run.State) - } - var processed sql.NullString - if err := db.QueryRowContext(ctx, - `SELECT processed_at FROM events WHERE session_id=? AND id=?`, session.ID, trigger).Scan(&processed); err != nil { - t.Fatal(err) - } - if processed.Valid { - t.Fatalf("trigger processed_at = %q, want NULL (rolled back)", processed.String) - } - var newDraftCount int - if err := db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM events WHERE session_id=? AND id=?`, session.ID, newActionID).Scan(&newDraftCount); err != nil { - t.Fatal(err) - } - if newDraftCount != 0 { - t.Fatalf("new draft event count = %d, want 0 (rolled back)", newDraftCount) - } - // The only pending action ever created was the original park's, now resolved. - var unresolved int - if err := db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM pending_actions WHERE session_id=? AND resolved_at IS NULL`, session.ID).Scan(&unresolved); err != nil { - t.Fatal(err) - } - if unresolved != 0 { - t.Fatalf("unresolved pending actions = %d, want 0 (failed Complete created none)", unresolved) - } -} - -// TestPending_CompleteRejectsNonAskToolUseAtomically proves a committed -// agent.tool_use whose evaluated_permission is NOT "ask" cannot park: it is -// rejected and the completion transaction rolls back, leaving the run running. -func TestPending_CompleteRejectsNonAskToolUseAtomically(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - actionID := "sevt_allow_" + claim.Run.ID - _, err = runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: actionID, Type: domain.EvAgentToolUse, Payload: map[string]any{"name": "bash", "input": map[string]any{}, "evaluated_permission": "always_allow"}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{actionID}) - if !isValidation(err) { - t.Fatalf("non-ask tool_use park err = %v, want validation", err) - } - run, err := runs.Get(ctx, claim.Run.ID) - if err != nil { - t.Fatal(err) - } - if run.State != domain.RunRunning { - t.Fatalf("run state = %s, want running (Complete rolled back)", run.State) - } - var pending int - if err := db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM pending_actions WHERE session_id=?`, session.ID).Scan(&pending); err != nil { - t.Fatal(err) - } - if pending != 0 { - t.Fatalf("pending actions = %d, want 0", pending) - } -} - -// TestPending_CompleteAcceptsAskToolUse proves a committed agent.tool_use with -// evaluated_permission "ask" parks: a durable PendingToolConfirmation is created -// in the completion transaction and the session goes idle. -func TestPending_CompleteAcceptsAskToolUse(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - actionID := "sevt_ask_" + claim.Run.ID - completion, err := runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: actionID, Type: domain.EvAgentToolUse, Payload: map[string]any{"name": "bash", "input": map[string]any{}, "evaluated_permission": "ask"}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{actionID}) - if err != nil { - t.Fatalf("ask tool_use park: %v", err) - } - if completion.Session.Status != domain.StatusIdle { - t.Fatalf("session status = %s, want idle", completion.Session.Status) - } - tx, err := db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback() - pending, err := unresolvedPendingActions(ctx, tx, session.ID) - if err != nil { - t.Fatal(err) - } - if len(pending) != 1 { - t.Fatalf("unresolved pending = %d, want 1", len(pending)) - } - if pending[0].actionEventID != actionID || pending[0].kind != domain.PendingToolConfirmation { - t.Fatalf("pending = %+v, want tool_confirmation for %s", pending[0], actionID) - } -} - -// TestPending_CompleteRejectsDuplicateActionIDs proves duplicate pending action -// ids in one park are rejected explicitly rather than silently collapsing to a -// single gate, and the completion transaction rolls back. -func TestPending_CompleteRejectsDuplicateActionIDs(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - if _, err := runs.CreateSession(ctx, session, []domain.EventDraft{{Type: domain.EvUserMessage}}); err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - actionID := "sevt_dup_" + claim.Run.ID - _, err = runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {ID: actionID, Type: domain.EvAgentCustomToolUse, Payload: map[string]any{"name": "t", "input": map[string]any{}}}, - {Type: domain.EvSessionStatusIdle, Payload: map[string]any{"stop_reason": map[string]any{"type": "requires_action"}}}, - }, domain.StatusIdle, nil, []string{actionID, actionID}) - if !isValidation(err) { - t.Fatalf("duplicate action id err = %v, want validation", err) - } - run, err := runs.Get(ctx, claim.Run.ID) - if err != nil { - t.Fatal(err) - } - if run.State != domain.RunRunning { - t.Fatalf("run state = %s, want running (Complete rolled back)", run.State) - } - var pending int - if err := db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM pending_actions WHERE session_id=?`, session.ID).Scan(&pending); err != nil { - t.Fatal(err) - } - if pending != 0 { - t.Fatalf("pending actions = %d, want 0 (duplicate park rolled back)", pending) - } -} diff --git a/internal/store/repos_test.go b/internal/store/repos_test.go deleted file mode 100644 index 817fd4a..0000000 --- a/internal/store/repos_test.go +++ /dev/null @@ -1,474 +0,0 @@ -package store - -import ( - "context" - "fmt" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -func TestAgentRepo_VersionsAndLatest(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - r := NewAgentRepo(db) - ctx := context.Background() - now := time.Unix(1, 0).UTC() - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_1", Version: 1, Name: "a", CreatedAt: now, UpdatedAt: now})) - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_1", Version: 2, Name: "b", CreatedAt: now, UpdatedAt: now})) - got, err := r.Latest(ctx, "agent_1") - if err != nil || got.Version != 2 || got.Name != "b" { - t.Fatalf("latest wrong: %+v err=%v", got, err) - } - vs, _ := r.Versions(ctx, "agent_1") - if len(vs) != 2 || vs[0].Version != 1 || vs[1].Version != 2 { - t.Fatalf("versions wrong: %+v", vs) - } - if _, err := r.Latest(ctx, "nope"); err == nil { - t.Fatal("expected NotFound") - } -} - -func TestSessionRepo_CreateIfDependenciesActive(t *testing.T) { - db, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - - ctx := context.Background() - now := time.Unix(1, 0).UTC() - agents := NewAgentRepo(db) - environments := NewEnvironmentRepo(db) - sessions := NewSessionRepo(db) - must(t, agents.PutVersion(ctx, domain.Agent{ - ID: "agent_active", Version: 1, Name: "agent", - Model: domain.Model{ID: "model"}, CreatedAt: now, UpdatedAt: now, - })) - must(t, environments.Put(ctx, domain.Environment{ - ID: "env_active", Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - })) - - base := domain.Session{ - ID: "sesn_active", AgentID: "agent_active", AgentVersion: 1, - EnvironmentID: "env_active", Status: domain.StatusIdle, - CreatedAt: now, UpdatedAt: now, - } - must(t, sessions.CreateIfDependenciesActive(ctx, base)) - - missingVersion := base - missingVersion.ID = "sesn_missing_version" - missingVersion.AgentVersion = 2 - if err := sessions.CreateIfDependenciesActive(ctx, missingVersion); err == nil { - t.Fatal("created session for a missing exact agent version") - } - - archivedAt := now.Add(time.Second) - if _, err := agents.Archive(ctx, "agent_active", archivedAt); err != nil { - t.Fatal(err) - } - archivedAgent := base - archivedAgent.ID = "sesn_archived_agent" - if err := sessions.CreateIfDependenciesActive(ctx, archivedAgent); err == nil { - t.Fatal("created session for an archived agent") - } - - environment, err := environments.Get(ctx, "env_active") - if err != nil { - t.Fatal(err) - } - environment.ArchivedAt = &archivedAt - environment.UpdatedAt = archivedAt - must(t, environments.Put(ctx, environment)) - archivedEnvironment := base - archivedEnvironment.ID = "sesn_archived_environment" - if err := sessions.CreateIfDependenciesActive(ctx, archivedEnvironment); err == nil { - t.Fatal("created session for an archived environment") - } -} - -func TestAgentRepo_ListLatestOfEach(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - r := NewAgentRepo(db) - ctx := context.Background() - now := time.Unix(1, 0).UTC() - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_a", Version: 1, Name: "a-v1", CreatedAt: now, UpdatedAt: now})) - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_a", Version: 2, Name: "a-v2", CreatedAt: now, UpdatedAt: now})) - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_b", Version: 1, Name: "b-v1", CreatedAt: now, UpdatedAt: now})) - must(t, r.PutVersion(ctx, domain.Agent{ID: "agent_b", Version: 2, Name: "b-v2", CreatedAt: now, UpdatedAt: now})) - got, err := r.List(ctx) - if err != nil { - t.Fatalf("List error: %v", err) - } - if len(got) != 2 { - t.Fatalf("expected 2 agents, got %d: %+v", len(got), got) - } - for _, a := range got { - if a.Version != 2 { - t.Errorf("agent %q: expected version 2, got %d", a.ID, a.Version) - } - } -} - -func TestAgentRepo_ArchiveIsResourceStateNotConfigurationVersion(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - r := NewAgentRepo(db) - ctx := context.Background() - createdAt := time.Unix(1, 0).UTC() - updatedAt := time.Unix(2, 0).UTC() - archivedAt := time.Unix(3, 0).UTC() - must(t, r.PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "v1", CreatedAt: createdAt, UpdatedAt: createdAt, - })) - must(t, r.PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 2, Name: "v2", CreatedAt: createdAt, UpdatedAt: updatedAt, - })) - - archived, err := r.Archive(ctx, "agent_1", archivedAt) - if err != nil { - t.Fatalf("Archive error: %v", err) - } - if archived.Version != 2 { - t.Fatalf("archive changed version: got %d want 2", archived.Version) - } - if archived.ArchivedAt == nil || !archived.ArchivedAt.Equal(archivedAt) { - t.Fatalf("archive timestamp = %v, want %v", archived.ArchivedAt, archivedAt) - } - if !archived.UpdatedAt.Equal(updatedAt) { - t.Fatalf("archive changed configuration updated_at: got %v want %v", archived.UpdatedAt, updatedAt) - } - - versions, err := r.Versions(ctx, "agent_1") - if err != nil { - t.Fatalf("Versions error: %v", err) - } - if len(versions) != 2 { - t.Fatalf("archive appended a version: got %d versions", len(versions)) - } - for _, version := range versions { - if version.ArchivedAt == nil || !version.ArchivedAt.Equal(archivedAt) { - t.Fatalf("version %d does not reflect resource archive: %#v", version.Version, version.ArchivedAt) - } - } - - again, err := r.Archive(ctx, "agent_1", time.Unix(4, 0).UTC()) - if err != nil { - t.Fatalf("second Archive error: %v", err) - } - if again.ArchivedAt == nil || !again.ArchivedAt.Equal(archivedAt) { - t.Fatalf("idempotent archive changed timestamp: %v", again.ArchivedAt) - } - if _, err := r.Archive(ctx, "missing", archivedAt); err == nil { - t.Fatal("expected missing agent archive to fail") - } -} - -func TestAgentRepo_ConcurrentExpectedVersionUsesConditionalInsert(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - // Exercise the SQL guard across two real SQLite connections rather than - // relying on the production pool's single-connection serialization. - db.SetMaxOpenConns(2) - repo := NewAgentRepo(db) - ctx := context.Background() - now := time.Unix(1, 0).UTC() - must(t, repo.PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "v1", Model: domain.Model{ID: "m"}, - CreatedAt: now, UpdatedAt: now, - })) - - ready := make(chan struct{}, 2) - release := make(chan struct{}) - errs := make(chan error, 2) - var wg sync.WaitGroup - for _, name := range []string{"first", "second"} { - name := name - wg.Add(1) - go func() { - defer wg.Done() - _, err := repo.UpdateVersion(ctx, "agent_1", func(current domain.Agent) (domain.Agent, bool, error) { - ready <- struct{}{} - <-release - expected := 1 - next, changed, err := current.Apply(domain.AgentPatch{ - Name: &name, ExpectedVersion: &expected, - }) - next.UpdatedAt = now.Add(time.Second) - return next, changed, err - }) - errs <- err - }() - } - <-ready - <-ready - close(release) - wg.Wait() - close(errs) - - successes, conflicts := 0, 0 - for err := range errs { - if err == nil { - successes++ - continue - } - de, ok := err.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("concurrent update error = %v, want conflict", err) - } - conflicts++ - } - if successes != 1 || conflicts != 1 { - t.Fatalf("concurrent results: successes=%d conflicts=%d, want 1/1", successes, conflicts) - } - versions, err := repo.Versions(ctx, "agent_1") - if err != nil { - t.Fatalf("versions: %v", err) - } - if len(versions) != 2 { - t.Fatalf("conditional insert created %d versions, want 2 total", len(versions)) - } -} - -func TestAgentRepo_ArchiveWinsAgainstInFlightUpdate(t *testing.T) { - db, err := Open(filepath.Join(t.TempDir(), "agents.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() - db.SetMaxOpenConns(2) - repo := NewAgentRepo(db) - ctx := context.Background() - now := time.Unix(1, 0).UTC() - must(t, repo.PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "v1", Model: domain.Model{ID: "m"}, - CreatedAt: now, UpdatedAt: now, - })) - - updateRead := make(chan struct{}) - releaseUpdate := make(chan struct{}) - updateDone := make(chan error, 1) - go func() { - _, err := repo.UpdateVersion(ctx, "agent_1", func(current domain.Agent) (domain.Agent, bool, error) { - close(updateRead) - <-releaseUpdate - name := "v2" - next, changed, err := current.Apply(domain.AgentPatch{Name: &name}) - next.UpdatedAt = now.Add(time.Second) - return next, changed, err - }) - updateDone <- err - }() - <-updateRead - - // Archive commits on another WAL connection after the updater has read v1 - // but before it attempts the conditional insert. - archived, err := repo.Archive(ctx, "agent_1", now.Add(2*time.Second)) - if err != nil { - close(releaseUpdate) - <-updateDone - t.Fatalf("archive: %v", err) - } - close(releaseUpdate) - updateErr := <-updateDone - de, ok := updateErr.(*domain.DomainError) - if !ok || de.Kind != domain.KindConflict { - t.Fatalf("in-flight update error = %v, want conflict", updateErr) - } - if archived.ArchivedAt == nil || archived.Version != 1 { - t.Fatalf("archive result = %#v", archived) - } - - versions, err := repo.Versions(ctx, "agent_1") - if err != nil { - t.Fatalf("versions: %v", err) - } - if len(versions) != 1 || versions[0].ArchivedAt == nil { - t.Fatalf("in-flight update resurrected archived agent: %#v", versions) - } -} - -func TestSessionRepo_ListOrder(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - r := NewSessionRepo(db) - ctx := context.Background() - for i, id := range []string{"ses_1", "ses_2", "ses_3"} { - ts := time.Unix(int64(i+1), 0).UTC() - must(t, r.Put(ctx, domain.Session{ID: id, Status: domain.StatusIdle, CreatedAt: ts, UpdatedAt: ts})) - } - desc, err := r.List(ctx, SessionListQuery{Limit: 10, Desc: true}) - if err != nil { - t.Fatalf("List desc error: %v", err) - } - asc, err := r.List(ctx, SessionListQuery{Limit: 10}) - if err != nil { - t.Fatalf("List asc error: %v", err) - } - if len(desc.Sessions) != 3 || len(asc.Sessions) != 3 { - t.Fatalf("expected 3 sessions each, got desc=%d asc=%d", len(desc.Sessions), len(asc.Sessions)) - } - if desc.Sessions[0].ID == asc.Sessions[0].ID { - t.Fatalf("expected different first elements but both are %q", desc.Sessions[0].ID) - } - if desc.Sessions[0].ID != "ses_3" { - t.Errorf("desc first expected ses_3, got %q", desc.Sessions[0].ID) - } - if asc.Sessions[0].ID != "ses_1" { - t.Errorf("asc first expected ses_1, got %q", asc.Sessions[0].ID) - } -} - -func TestSessionRepo_ListOrdersAndFiltersFractionalTimestampsChronologically(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - repo := NewSessionRepo(db) - ctx := context.Background() - exact := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) - fractional := exact.Add(100 * time.Millisecond) - must(t, repo.Put(ctx, domain.Session{ - ID: "ses_exact", Status: domain.StatusIdle, CreatedAt: exact, UpdatedAt: exact, - })) - must(t, repo.Put(ctx, domain.Session{ - ID: "ses_fractional", Status: domain.StatusIdle, CreatedAt: fractional, UpdatedAt: fractional, - })) - - ordered, err := repo.List(ctx, SessionListQuery{Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(ordered.Sessions) != 2 { - t.Fatalf("ascending order returned %d sessions, want 2", len(ordered.Sessions)) - } - if got := []string{ordered.Sessions[0].ID, ordered.Sessions[1].ID}; got[0] != "ses_exact" || got[1] != "ses_fractional" { - t.Fatalf("ascending order = %v, want [ses_exact ses_fractional]", got) - } - - filtered, err := repo.List(ctx, SessionListQuery{CreatedAtGt: &exact, Limit: 10}) - if err != nil { - t.Fatal(err) - } - if len(filtered.Sessions) != 1 || filtered.Sessions[0].ID != "ses_fractional" { - t.Fatalf("created_at gt exact = %+v, want ses_fractional", filtered.Sessions) - } -} - -func TestEnvironmentRepo_PutGetUpsert(t *testing.T) { - db, _ := OpenMemory() - defer db.Close() - r := NewEnvironmentRepo(db) - ctx := context.Background() - now := time.Unix(1, 0).UTC() - env := domain.Environment{ID: "env_1", Name: "original", ConfigType: "cloud", CreatedAt: now, UpdatedAt: now} - must(t, r.Put(ctx, env)) - got, err := r.Get(ctx, "env_1") - if err != nil { - t.Fatalf("Get error: %v", err) - } - if got.Name != "original" || got.ConfigType != "cloud" { - t.Fatalf("unexpected values: name=%q config_type=%q", got.Name, got.ConfigType) - } - env.Name = "updated" - must(t, r.Put(ctx, env)) - got2, err := r.Get(ctx, "env_1") - if err != nil { - t.Fatalf("Get after upsert error: %v", err) - } - if got2.Name != "updated" { - t.Errorf("upsert did not update name, got %q", got2.Name) - } - all, err := r.List(ctx) - if err != nil { - t.Fatalf("List error: %v", err) - } - if len(all) != 1 { - t.Errorf("expected 1 environment after upsert, got %d", len(all)) - } -} - -func TestEnvironmentRepo_ConcurrentDeleteAndSessionCreateNeverOrphans(t *testing.T) { - db, err := Open(filepath.Join(t.TempDir(), "dependency-race.db")) - if err != nil { - t.Fatal(err) - } - defer db.Close() - db.SetMaxOpenConns(2) - - ctx := context.Background() - now := time.Unix(1, 0).UTC() - agents := NewAgentRepo(db) - environments := NewEnvironmentRepo(db) - sessions := NewSessionRepo(db) - must(t, agents.PutVersion(ctx, domain.Agent{ - ID: "agent_race", Version: 1, Name: "agent", Model: domain.Model{ID: "model"}, - CreatedAt: now, UpdatedAt: now, - })) - - const iterations = 100 - for iteration := 0; iteration < iterations; iteration++ { - environmentID := fmt.Sprintf("env_race_%d", iteration) - sessionID := fmt.Sprintf("sesn_race_%d", iteration) - must(t, environments.Put(ctx, domain.Environment{ - ID: environmentID, Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - })) - session := domain.Session{ - ID: sessionID, AgentID: "agent_race", AgentVersion: 1, - EnvironmentID: environmentID, Status: domain.StatusIdle, - CreatedAt: now, UpdatedAt: now, - } - - start := make(chan struct{}) - createDone := make(chan error, 1) - deleteDone := make(chan error, 1) - go func() { - <-start - createDone <- sessions.CreateIfDependenciesActive(ctx, session) - }() - go func() { - <-start - deleteDone <- environments.DeleteIfUnreferenced(ctx, environmentID) - }() - close(start) - createErr := <-createDone - deleteErr := <-deleteDone - - _, sessionErr := sessions.Get(ctx, sessionID) - _, environmentErr := environments.Get(ctx, environmentID) - sessionExists := sessionErr == nil - environmentExists := environmentErr == nil - if sessionExists && !environmentExists { - t.Fatalf("iteration %d left an orphan session", iteration) - } - if (createErr == nil) == (deleteErr == nil) { - t.Fatalf( - "iteration %d outcomes create=%v delete=%v; exactly one must succeed", - iteration, createErr, deleteErr, - ) - } - if createErr == nil && (!sessionExists || !environmentExists) { - t.Fatalf( - "iteration %d successful create has session=%v environment=%v", - iteration, sessionExists, environmentExists, - ) - } - if deleteErr == nil && (sessionExists || environmentExists) { - t.Fatalf( - "iteration %d successful delete has session=%v environment=%v", - iteration, sessionExists, environmentExists, - ) - } - } -} - -func must(t *testing.T, err error) { - t.Helper() - if err != nil { - t.Fatal(err) - } -} diff --git a/internal/store/run_store.go b/internal/store/run_store.go deleted file mode 100644 index f1a0d94..0000000 --- a/internal/store/run_store.go +++ /dev/null @@ -1,880 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -// RunStore owns the transactional boundary between sessions, public events, -// internal work items, and the durable execution-attempt/tool-step journal. It -// remains a single-node queue; distributed leases are outside this layer. -type RunStore struct { - db *DB - ids domain.IDGenerator - clock domain.Clock -} - -func NewRunStore(db *DB, ids domain.IDGenerator, clock domain.Clock) *RunStore { - return &RunStore{db: db, ids: ids, clock: clock} -} - -type Admission struct { - Session domain.Session - Events []domain.Event - // Runs holds one durable queued run per processable trigger event, in the - // same stable order as the admitted events. Multiple trigger IDs are never - // grouped into a single run. - Runs []domain.SessionRun -} - -type RunClaim struct { - Run domain.SessionRun - Triggers []domain.Event - Events []domain.Event - AgentSnapshot domain.Agent -} - -type RunCompletion struct { - Run domain.SessionRun - Session domain.Session - Events []domain.Event -} - -// CreateSession atomically creates a session and, when initial events are -// present, admits one durable queued run per processable trigger event. -func (s *RunStore) CreateSession( - ctx context.Context, - session domain.Session, - drafts []domain.EventDraft, -) (Admission, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Admission{}, err - } - defer tx.Rollback() - - if err := insertSessionIfDependenciesActive(ctx, tx, session); err != nil { - return Admission{}, err - } - admission, err := s.admitTx(ctx, tx, session, drafts) - if err != nil { - return Admission{}, err - } - if err := tx.Commit(); err != nil { - return Admission{}, err - } - return admission, nil -} - -// Admit atomically persists client events, enqueues one durable run per -// processable trigger event (in admission order), and reopens the session to -// running only when that new work is actually claimable — projecting to running -// and emitting session.status_running. Work admitted while an unresolved pending -// action still gates the session (and that admission is not the matching -// resolution) stays durably queued but leaves the session idle with no -// session.status_running. -func (s *RunStore) Admit( - ctx context.Context, - sessionID string, - drafts []domain.EventDraft, -) (Admission, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Admission{}, err - } - defer tx.Rollback() - - session, err := getSessionTx(ctx, tx, sessionID) - if err != nil { - return Admission{}, err - } - if session.ArchivedAt != nil { - return Admission{}, domain.Conflict("cannot send events to an archived session") - } - if session.Status == domain.StatusTerminated { - return Admission{}, domain.Conflict("cannot send events to a terminated session") - } - admission, err := s.admitTx(ctx, tx, session, drafts) - if err != nil { - return Admission{}, err - } - if err := tx.Commit(); err != nil { - return Admission{}, err - } - return admission, nil -} - -func (s *RunStore) admitTx( - ctx context.Context, - tx *sql.Tx, - session domain.Session, - drafts []domain.EventDraft, -) (Admission, error) { - events, err := appendEventsTx(ctx, tx, s.ids, s.clock, session.ID, drafts) - if err != nil { - return Admission{}, err - } - // A resolution event (user.custom_tool_result / user.tool_confirmation) must - // reference a currently OPEN pending action of the matching kind. Validate and - // claim each in the same transaction that commits it, so an unknown, - // already-resolved, duplicate, wrong-session, or wrong-kind reference fails - // atomically without ever creating runnable work. The referenced action event - // id comes from the event's own type-specific payload; the resolution kind is - // derived from the event type, never trusted from an arbitrary caller string. - // We also note whether this admission claimed any pending action: a matching - // resolution is what allows the session to reopen to running below, whereas - // ordinary work admitted while a park is still open stays idle and gated. - admittedResolution := false - for _, event := range events { - actionEventID, kind, ok := domain.ResolutionReference(event.Type, event.Payload) - if !ok { - continue - } - if err := claimPendingActionTx(ctx, tx, session.ID, actionEventID, kind, event.ID); err != nil { - return Admission{}, err - } - admittedResolution = true - } - triggerIDs := make([]string, 0, len(events)) - for _, event := range events { - if domain.IsClientSubmittable(event.Type) { - triggerIDs = append(triggerIDs, event.ID) - } - } - admission := Admission{Session: session, Events: events} - if len(triggerIDs) == 0 { - return admission, nil - } - - // Reopen the session to running only when the newly admitted work is actually - // claimable now. If the session is idle with an unresolved pending action and - // this admission did NOT claim a matching resolution, the new run is durably - // queued but gated (ClaimNext will not claim it), so the session must stay idle - // and emit no session.status_running — otherwise the projection would lie - // (running while really waiting for the action). A matching resolution, or work - // admitted with no open pending action, proceeds to running as before. - reopen := session.Status != domain.StatusRunning - if reopen && session.Status == domain.StatusIdle && !admittedResolution { - gated, err := hasUnresolvedPendingTx(ctx, tx, session.ID) - if err != nil { - return Admission{}, err - } - if gated { - reopen = false - } - } - - if reopen { - session.Status = domain.StatusRunning - session.UpdatedAt = s.clock.Now().UTC() - if err := updateSessionTx(ctx, tx, session); err != nil { - return Admission{}, err - } - statusEvents, err := appendEventsTx(ctx, tx, s.ids, s.clock, session.ID, []domain.EventDraft{{ - Type: domain.EvSessionStatusRunning, Payload: map[string]any{}, - }}) - if err != nil { - return Admission{}, err - } - admission.Events = append(admission.Events, statusEvents...) - admission.Session = session - } - - // One durable queued run per trigger, in admission order. Grouping multiple - // triggers into a single run would let a later trigger project history - // before the earlier trigger's agent output was committed. - for _, triggerID := range triggerIDs { - run, err := s.insertRunTx(ctx, tx, session.ID, triggerID) - if err != nil { - return Admission{}, err - } - admission.Runs = append(admission.Runs, run) - } - return admission, nil -} - -func (s *RunStore) insertRunTx( - ctx context.Context, - tx *sql.Tx, - sessionID string, - triggerID string, -) (domain.SessionRun, error) { - triggerIDs := []string{triggerID} - var sequence int64 - if err := tx.QueryRowContext(ctx, - `SELECT COALESCE(MAX(admission_seq), 0) + 1 FROM session_runs WHERE session_id=?`, - sessionID).Scan(&sequence); err != nil { - return domain.SessionRun{}, err - } - triggerJSON, err := json.Marshal(triggerIDs) - if err != nil { - return domain.SessionRun{}, err - } - now := s.clock.Now().UTC() - run := domain.SessionRun{ - ID: s.ids.NewID(domain.PrefixRun), - SessionID: sessionID, - AdmissionSeq: sequence, - TriggerEventIDs: triggerIDs, - State: domain.RunQueued, - CreatedAt: now, - UpdatedAt: now, - } - _, err = tx.ExecContext(ctx, ` -INSERT INTO session_runs - (id, session_id, admission_seq, trigger_event_ids, state, error, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, - run.ID, run.SessionID, run.AdmissionSeq, string(triggerJSON), string(run.State), - timeVal(run.CreatedAt), timeVal(run.UpdatedAt)) - return run, err -} - -// ClaimNext transitions the oldest queued run for a session to running. A -// partial unique index guarantees at most one running item per session. -func (s *RunStore) ClaimNext(ctx context.Context, sessionID string) (RunClaim, bool, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return RunClaim{}, false, err - } - defer tx.Rollback() - - // A terminated session is final: never claim its leftover queued work and - // never flip it back to running. Guard before selecting a run so a session - // that terminated with runs still queued stays terminated. - session, err := getSessionTx(ctx, tx, sessionID) - if err != nil { - // getSessionTx already maps a missing row to domain.NotFound; propagate - // the truthful error rather than silently reporting "nothing to claim". - return RunClaim{}, false, err - } - if session.Status == domain.StatusTerminated { - return RunClaim{}, false, nil - } - - // Claim gate: while the session has unresolved pending actions, ordinary - // queued runs must not be claimed even if they were admitted before the run - // parked. Only a run whose trigger is the matching resolution (its - // resolving_event_id, recorded at admission) may bypass those earlier queued - // runs. This keeps at most one running run and deterministic admission-order - // selection within whichever set is claimable. - pending, err := unresolvedPendingActions(ctx, tx, sessionID) - if err != nil { - return RunClaim{}, false, err - } - var run domain.SessionRun - if len(pending) > 0 { - resolving := make(map[string]struct{}, len(pending)) - for _, p := range pending { - if p.resolvingEventID != nil { - resolving[*p.resolvingEventID] = struct{}{} - } - } - run, err = selectNextResolutionRun(ctx, tx, sessionID, resolving) - } else { - run, err = selectNextQueuedRun(ctx, tx, sessionID) - } - if err == sql.ErrNoRows { - return RunClaim{}, false, nil - } - if err != nil { - return RunClaim{}, false, err - } - now := s.clock.Now().UTC() - result, err := tx.ExecContext(ctx, ` -UPDATE session_runs -SET state=?, updated_at=? -WHERE id=? AND state=? - AND NOT EXISTS ( - SELECT 1 FROM session_runs - WHERE session_id=? AND state=? - )`, - string(domain.RunRunning), timeVal(now), run.ID, string(domain.RunQueued), - sessionID, string(domain.RunRunning)) - if err != nil { - return RunClaim{}, false, err - } - affected, err := result.RowsAffected() - if err != nil { - return RunClaim{}, false, err - } - if affected != 1 { - return RunClaim{}, false, nil - } - run.State = domain.RunRunning - run.UpdatedAt = now - - triggers, err := loadEventsByIDTx(ctx, tx, sessionID, run.TriggerEventIDs) - if err != nil { - return RunClaim{}, false, err - } - var statusEvents []domain.Event - if session.Status != domain.StatusRunning { - session.Status = domain.StatusRunning - session.UpdatedAt = now - if err := updateSessionTx(ctx, tx, session); err != nil { - return RunClaim{}, false, err - } - statusEvents, err = appendEventsTx(ctx, tx, s.ids, s.clock, sessionID, []domain.EventDraft{{ - Type: domain.EvSessionStatusRunning, Payload: map[string]any{}, - }}) - if err != nil { - return RunClaim{}, false, err - } - } - if err := tx.Commit(); err != nil { - return RunClaim{}, false, err - } - return RunClaim{Run: run, Triggers: triggers, Events: statusEvents, AgentSnapshot: session.AgentSnapshot}, true, nil -} - -// Complete atomically appends buffered runtime output, marks trigger events -// processed, updates the session projection, and closes the durable run. When -// the run parked (pendingActionEventIDs is non-empty) it also persists one -// durable pending action per action event in the SAME transaction, deriving each -// expected response kind from the committed action event's type. When this run's -// triggers resolved earlier parked actions, those pending actions are marked -// resolved in this same transaction, so the ordinary queued work a park blocked -// becomes claimable only after the resume run has closed. -func (s *RunStore) Complete( - ctx context.Context, - runID string, - drafts []domain.EventDraft, - status domain.Status, - runError *string, - pendingActionEventIDs []string, -) (RunCompletion, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return RunCompletion{}, err - } - defer tx.Rollback() - - run, err := getRunTx(ctx, tx, runID) - if err != nil { - return RunCompletion{}, err - } - if run.State != domain.RunRunning { - return RunCompletion{}, domain.Conflict("run is not running") - } - session, err := getSessionTx(ctx, tx, run.SessionID) - if err != nil { - return RunCompletion{}, err - } - events, err := appendEventsTx(ctx, tx, s.ids, s.clock, run.SessionID, drafts) - if err != nil { - return RunCompletion{}, err - } - - processedAt := timeVal(s.clock.Now().UTC()) - for _, eventID := range run.TriggerEventIDs { - result, err := tx.ExecContext(ctx, ` -UPDATE events -SET processed_at=COALESCE(processed_at, ?) -WHERE session_id=? AND id=?`, processedAt, run.SessionID, eventID) - if err != nil { - return RunCompletion{}, err - } - affected, err := result.RowsAffected() - if err != nil { - return RunCompletion{}, err - } - if affected != 1 { - return RunCompletion{}, domain.NotFound("run trigger event not found") - } - } - - // Resolve any parked action this run's triggers answered. The resume run's - // trigger (a user.custom_tool_result / user.tool_confirmation admitted with a - // recorded resolving_event_id) clears the gate here, in the same transaction - // that closes the run, so ordinary queued work only continues after a - // successful resume commit. A failed resume leaves resolved_at set too (the - // park is answered honestly), but a terminated session never resurrects. - if _, err := resolvePendingActionsForTriggers(ctx, tx, s.clock, run.SessionID, run.TriggerEventIDs); err != nil { - return RunCompletion{}, err - } - - // Persist the run's parked actions as durable pending actions. Each id must be - // one of the action events THIS Complete call just committed above — validated - // against the committed drafts, never mere session-local existence, so an old - // action event from an earlier run, a phantom id, or any non-action output is - // rejected and rolls the whole transaction back. The allowed set is built from - // appendEventsTx's return BEFORE any later synthetic status_running append, so - // only genuine drafts of this run can park. The kind is derived from the - // committed event's type AND payload (see insertPendingActionTx). This is the - // same transaction as the action events, the status_idle{requires_action}, the - // session projection, and the run completion. - allowedActions := make(map[string]domain.Event, len(events)) - for _, event := range events { - allowedActions[event.ID] = event - } - // Duplicate ids in one park would silently collapse to a single gate via the - // ON CONFLICT no-op, hiding a caller mistake behind misleading success. Reject - // them explicitly so a park names each action event exactly once. - seenActions := make(map[string]struct{}, len(pendingActionEventIDs)) - for _, actionEventID := range pendingActionEventIDs { - if _, dup := seenActions[actionEventID]; dup { - return RunCompletion{}, domain.Validation("duplicate pending action event id") - } - seenActions[actionEventID] = struct{}{} - if err := insertPendingActionTx(ctx, tx, s.ids, s.clock, run.SessionID, actionEventID, allowedActions); err != nil { - return RunCompletion{}, err - } - } - - var hasQueued bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS( - SELECT 1 FROM session_runs - WHERE session_id=? AND state=? AND id<>? -)`, run.SessionID, string(domain.RunQueued), run.ID).Scan(&hasQueued); err != nil { - return RunCompletion{}, err - } - // Ordinary queued runs are gated while any pending action is unresolved: a run - // that just parked (or a resume that answered one park while another remains - // open) must NOT reopen the session to running for work that cannot yet be - // claimed. Only reopen when queued work exists AND nothing gates it. - gated, err := hasUnresolvedPendingTx(ctx, tx, run.SessionID) - if err != nil { - return RunCompletion{}, err - } - // A terminated session is final: even if later runs were queued before the - // failure, we do not resurrect it to running. Only a non-terminal completion - // with more claimable queued work reopens the session to running. - if hasQueued && !gated && status != domain.StatusRunning && status != domain.StatusTerminated { - status = domain.StatusRunning - statusEvents, err := appendEventsTx(ctx, tx, s.ids, s.clock, run.SessionID, []domain.EventDraft{{ - Type: domain.EvSessionStatusRunning, Payload: map[string]any{}, - }}) - if err != nil { - return RunCompletion{}, err - } - events = append(events, statusEvents...) - } - session.Status = status - session.UpdatedAt = s.clock.Now().UTC() - if err := updateSessionTx(ctx, tx, session); err != nil { - return RunCompletion{}, err - } - - run.UpdatedAt = s.clock.Now().UTC() - run.Error = runError - run.State = domain.RunCompleted - if runError != nil { - run.State = domain.RunFailed - } - // Persist the exact committed output event ids on the run in the same - // transaction that closes it. These are precisely the events this run - // appended above (agent output plus the run's terminal/status events), in - // commit order. Writing them here — not in a follow-up statement — keeps the - // invariant that there is never a completed run without its output - // association. ModelHistory later replays these ids to rebuild causal history. - outputIDs := make([]string, len(events)) - for i, event := range events { - outputIDs[i] = event.ID - } - run.OutputEventIDs = outputIDs - outputJSON, err := json.Marshal(outputIDs) - if err != nil { - return RunCompletion{}, err - } - _, err = tx.ExecContext(ctx, ` -UPDATE session_runs -SET state=?, error=?, output_event_ids=?, updated_at=? -WHERE id=? AND state=?`, - string(run.State), nullableString(run.Error), string(outputJSON), timeVal(run.UpdatedAt), - run.ID, string(domain.RunRunning)) - if err != nil { - return RunCompletion{}, err - } - if err := tx.Commit(); err != nil { - return RunCompletion{}, err - } - return RunCompletion{Run: run, Session: session, Events: events}, nil -} - -// ModelHistory reconstructs the causal conversation history for a claimed/current -// run, to be projected into the model. Public event ordering (History/List/the -// live stream) is authoritative receipt/commit order and is deliberately NOT -// what a turn should replay: a later queued trigger admitted before an earlier -// run finished must not appear in that earlier turn's projection. This method -// rebuilds history from run causality instead of raw sequence: -// -// - It walks the prior completed/failed runs for the same session in admission -// order and, for each, appends that run's trigger events followed by that -// run's persisted output events (the exact events it committed on -// completion). This interleaves user turn / agent reply in the causal order -// they actually resolved. -// - It then appends the current run's own trigger events. -// - Every later queued trigger (admission_seq greater than this run's, or any -// run not yet completed/failed) is excluded, because only prior terminal runs -// and this run's trigger are visited. -// -// The reconstructed history is finally bounded to the newest `limit` events (the -// historyProjectionLimit-equivalent), preserving chronological causal order. A -// window that cuts a tool_use/tool_result pair is left for ProjectMessages' -// existing dangling/orphan filtering to repair. -func (s *RunStore) ModelHistory( - ctx context.Context, - run domain.SessionRun, - limit int, -) ([]domain.Event, error) { - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return nil, err - } - defer tx.Rollback() - - rows, err := tx.QueryContext(ctx, ` -SELECT id -FROM session_runs -WHERE session_id=? AND admission_seq 0 && len(orderedIDs) > limit { - orderedIDs = orderedIDs[len(orderedIDs)-limit:] - } - return loadEventsByIDTx(ctx, tx, run.SessionID, orderedIDs) -} - -// UpdateTitle keeps the session row and session.updated event in one commit. -func (s *RunStore) UpdateTitle( - ctx context.Context, - sessionID string, - title string, -) (domain.Session, []domain.Event, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return domain.Session{}, nil, err - } - defer tx.Rollback() - - session, err := getSessionTx(ctx, tx, sessionID) - if err != nil { - return domain.Session{}, nil, err - } - if session.Title == title { - return session, nil, nil - } - session.Title = title - session.UpdatedAt = s.clock.Now().UTC() - if err := updateSessionTx(ctx, tx, session); err != nil { - return domain.Session{}, nil, err - } - events, err := appendEventsTx(ctx, tx, s.ids, s.clock, sessionID, []domain.EventDraft{{ - Type: domain.EvSessionUpdated, - Payload: map[string]any{ - "title": title, - }, - }}) - if err != nil { - return domain.Session{}, nil, err - } - if err := tx.Commit(); err != nil { - return domain.Session{}, nil, err - } - return session, events, nil -} - -// Recover resets work that was running when the single process stopped and -// returns every session with queued work. -func (s *RunStore) Recover(ctx context.Context) ([]string, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return nil, err - } - defer tx.Rollback() - - now := timeVal(s.clock.Now().UTC()) - if _, err := tx.ExecContext(ctx, ` -UPDATE session_runs -SET state=?, updated_at=? -WHERE state=?`, string(domain.RunQueued), now, string(domain.RunRunning)); err != nil { - return nil, err - } - rows, err := tx.QueryContext(ctx, ` -SELECT DISTINCT session_id -FROM session_runs -WHERE state=? -ORDER BY session_id`, string(domain.RunQueued)) - if err != nil { - return nil, err - } - var sessionIDs []string - for rows.Next() { - var sessionID string - if err := rows.Scan(&sessionID); err != nil { - rows.Close() - return nil, err - } - sessionIDs = append(sessionIDs, sessionID) - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, err - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, err - } - return sessionIDs, nil -} - -func (s *RunStore) Get(ctx context.Context, id string) (domain.SessionRun, error) { - run, err := getRunTx(ctx, s.db, id) - if err == sql.ErrNoRows { - return domain.SessionRun{}, domain.NotFound("run not found") - } - return run, err -} - -type runQueryRower interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func getRunTx(ctx context.Context, q runQueryRower, id string) (domain.SessionRun, error) { - var ( - run domain.SessionRun - triggerJSON string - outputJSON string - state, created, updated string - runError sql.NullString - ) - err := q.QueryRowContext(ctx, ` -SELECT id, session_id, admission_seq, trigger_event_ids, output_event_ids, state, error, created_at, updated_at -FROM session_runs -WHERE id=?`, id).Scan( - &run.ID, &run.SessionID, &run.AdmissionSeq, &triggerJSON, &outputJSON, &state, - &runError, &created, &updated) - if err != nil { - return domain.SessionRun{}, err - } - if err := json.Unmarshal([]byte(triggerJSON), &run.TriggerEventIDs); err != nil { - return domain.SessionRun{}, fmt.Errorf("store: decode run triggers: %w", err) - } - if err := json.Unmarshal([]byte(outputJSON), &run.OutputEventIDs); err != nil { - return domain.SessionRun{}, fmt.Errorf("store: decode run outputs: %w", err) - } - run.State = domain.RunState(state) - if runError.Valid { - run.Error = &runError.String - } - run.CreatedAt, err = parseRFC3339(created) - if err != nil { - return domain.SessionRun{}, err - } - run.UpdatedAt, err = parseRFC3339(updated) - return run, err -} - -func selectNextQueuedRun(ctx context.Context, tx *sql.Tx, sessionID string) (domain.SessionRun, error) { - var id string - err := tx.QueryRowContext(ctx, ` -SELECT id -FROM session_runs -WHERE session_id=? AND state=? - AND NOT EXISTS ( - SELECT 1 FROM session_runs - WHERE session_id=? AND state=? - ) -ORDER BY admission_seq -LIMIT 1`, - sessionID, string(domain.RunQueued), - sessionID, string(domain.RunRunning)).Scan(&id) - if err != nil { - return domain.SessionRun{}, err - } - return getRunTx(ctx, tx, id) -} - -// selectNextResolutionRun picks the oldest queued run (admission order) whose -// trigger is one of the given resolving event ids, honoring the same -// at-most-one-running guard as selectNextQueuedRun. It is the gate's bypass: while -// unresolved pending actions exist, only such a resume run is claimable; earlier -// ordinary queued runs are skipped until the park is resolved. Returns -// sql.ErrNoRows when no matching resume run is queued yet (the session stays -// idle, waiting for the client's response). -func selectNextResolutionRun( - ctx context.Context, - tx *sql.Tx, - sessionID string, - resolving map[string]struct{}, -) (domain.SessionRun, error) { - var running bool - if err := tx.QueryRowContext(ctx, ` -SELECT EXISTS(SELECT 1 FROM session_runs WHERE session_id=? AND state=?)`, - sessionID, string(domain.RunRunning)).Scan(&running); err != nil { - return domain.SessionRun{}, err - } - if running { - return domain.SessionRun{}, sql.ErrNoRows - } - rows, err := tx.QueryContext(ctx, ` -SELECT id, trigger_event_ids -FROM session_runs -WHERE session_id=? AND state=? -ORDER BY admission_seq`, sessionID, string(domain.RunQueued)) - if err != nil { - return domain.SessionRun{}, err - } - defer rows.Close() - for rows.Next() { - var id, triggerJSON string - if err := rows.Scan(&id, &triggerJSON); err != nil { - return domain.SessionRun{}, err - } - var triggerIDs []string - if err := json.Unmarshal([]byte(triggerJSON), &triggerIDs); err != nil { - return domain.SessionRun{}, err - } - for _, triggerID := range triggerIDs { - if _, ok := resolving[triggerID]; ok { - if err := rows.Err(); err != nil { - return domain.SessionRun{}, err - } - rows.Close() - return getRunTx(ctx, tx, id) - } - } - } - if err := rows.Err(); err != nil { - return domain.SessionRun{}, err - } - return domain.SessionRun{}, sql.ErrNoRows -} - -func loadEventsByIDTx( - ctx context.Context, - tx *sql.Tx, - sessionID string, - ids []string, -) ([]domain.Event, error) { - events := make([]domain.Event, 0, len(ids)) - for _, id := range ids { - var ( - event domain.Event - payload, createdAt string - processedAt sql.NullString - ) - err := tx.QueryRowContext(ctx, ` -SELECT id, seq, type, payload, created_at, processed_at -FROM events -WHERE session_id=? AND id=?`, sessionID, id).Scan( - &event.ID, &event.Sequence, &event.Type, &payload, &createdAt, &processedAt) - if err == sql.ErrNoRows { - return nil, domain.NotFound("run trigger event not found") - } - if err != nil { - return nil, err - } - event.SessionID = sessionID - if err := json.Unmarshal([]byte(payload), &event.Payload); err != nil { - return nil, err - } - event.CreatedAt, err = parseRFC3339(createdAt) - if err != nil { - return nil, err - } - if processedAt.Valid { - processed, err := parseRFC3339(processedAt.String) - if err != nil { - return nil, err - } - event.ProcessedAt = &processed - } - events = append(events, event) - } - return events, nil -} - -type sessionQueryRower interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func getSessionTx(ctx context.Context, q sessionQueryRower, id string) (domain.Session, error) { - var body string - err := q.QueryRowContext(ctx, `SELECT body FROM sessions WHERE id=?`, id).Scan(&body) - if err == sql.ErrNoRows { - return domain.Session{}, domain.NotFound("session not found") - } - if err != nil { - return domain.Session{}, err - } - var session domain.Session - if err := json.Unmarshal([]byte(body), &session); err != nil { - return domain.Session{}, err - } - return session, nil -} - -func updateSessionTx(ctx context.Context, tx *sql.Tx, session domain.Session) error { - body, err := json.Marshal(session) - if err != nil { - return err - } - result, err := tx.ExecContext(ctx, ` -UPDATE sessions -SET status=?, body=?, updated_at=?, archived_at=? -WHERE id=?`, - string(session.Status), string(body), timeVal(session.UpdatedAt), - nullableTime(session.ArchivedAt), session.ID) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected != 1 { - return domain.NotFound("session not found") - } - return nil -} - -func nullableString(value *string) any { - if value == nil { - return nil - } - return *value -} diff --git a/internal/store/run_store_test.go b/internal/store/run_store_test.go deleted file mode 100644 index 296aec7..0000000 --- a/internal/store/run_store_test.go +++ /dev/null @@ -1,457 +0,0 @@ -package store - -import ( - "context" - "path/filepath" - "testing" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -func newRunStoreFixture(t *testing.T) (*DB, *RunStore, domain.Session) { - t.Helper() - db, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { db.Close() }) - - ctx := context.Background() - now := time.Unix(1, 0).UTC() - ids := domain.NewSeqIDGen() - if err := NewAgentRepo(db).PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "agent", - Model: domain.Model{ID: "model"}, CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := NewEnvironmentRepo(db).Put(ctx, domain.Environment{ - ID: "env_1", Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - session := domain.Session{ - ID: "sesn_1", AgentID: "agent_1", AgentVersion: 1, - EnvironmentID: "env_1", Status: domain.StatusIdle, - CreatedAt: now, UpdatedAt: now, - } - return db, NewRunStore(db, ids, domain.FixedClock{T: now}), session -} - -func TestRunStore_CreateSessionAdmissionIsOneCommit(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{{ - Type: domain.EvUserMessage, - Payload: map[string]any{ - "content": []any{map[string]any{"type": "text", "text": "hello"}}, - }, - }}) - if err != nil { - t.Fatal(err) - } - if admission.Session.Status != domain.StatusRunning { - t.Fatalf("session status = %s, want running", admission.Session.Status) - } - if len(admission.Runs) != 1 || admission.Runs[0].State != domain.RunQueued { - t.Fatalf("runs = %#v", admission.Runs) - } - if len(admission.Events) != 2 || - admission.Events[0].Type != domain.EvUserMessage || - admission.Events[1].Type != domain.EvSessionStatusRunning { - t.Fatalf("admission events = %#v", admission.Events) - } - - storedSession, err := NewSessionRepo(db).Get(ctx, session.ID) - if err != nil { - t.Fatal(err) - } - storedRun, err := runs.Get(ctx, admission.Runs[0].ID) - if err != nil { - t.Fatal(err) - } - history, err := NewEventStore(db, domain.NewSeqIDGen(), - domain.FixedClock{T: session.CreatedAt}).History(ctx, session.ID, 0, 10) - if err != nil { - t.Fatal(err) - } - if storedSession.Status != domain.StatusRunning || - storedRun.State != domain.RunQueued || - len(history) != 2 { - t.Fatalf("stored state session=%s run=%s events=%d", - storedSession.Status, storedRun.State, len(history)) - } -} - -func TestRunStore_ClaimAndCompleteClosesRunAtomically(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{{ - Type: domain.EvUserMessage, - }}) - if err != nil { - t.Fatal(err) - } - claim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim: ok=%v err=%v", ok, err) - } - if len(claim.Triggers) != 1 || claim.Triggers[0].ID != admission.Events[0].ID { - t.Fatalf("claim triggers = %#v", claim.Triggers) - } - - completion, err := runs.Complete(ctx, claim.Run.ID, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": []any{}}}, - {Type: domain.EvSessionStatusIdle}, - }, domain.StatusIdle, nil, nil) - if err != nil { - t.Fatal(err) - } - if completion.Run.State != domain.RunCompleted || - completion.Session.Status != domain.StatusIdle { - t.Fatalf("completion = %#v", completion) - } - history, err := NewEventStore(db, domain.NewSeqIDGen(), - domain.FixedClock{T: session.CreatedAt}).History(ctx, session.ID, 0, 10) - if err != nil { - t.Fatal(err) - } - if len(history) != 4 { - t.Fatalf("history length = %d, want 4", len(history)) - } - if history[0].ProcessedAt == nil { - t.Fatal("trigger was not marked processed in completion commit") - } - if history[2].Type != domain.EvAgentMessage || - history[3].Type != domain.EvSessionStatusIdle { - t.Fatalf("completion event order = %#v", history) - } -} - -func TestRunStore_QueuesRunsPerSessionInAdmissionOrder(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - first, err := runs.CreateSession(ctx, session, []domain.EventDraft{{ - Type: domain.EvUserMessage, - }}) - if err != nil { - t.Fatal(err) - } - second, err := runs.Admit(ctx, session.ID, []domain.EventDraft{{ - Type: domain.EvUserMessage, - }}) - if err != nil { - t.Fatal(err) - } - if len(first.Runs) != 1 || len(second.Runs) != 1 { - t.Fatalf("run counts = %d, %d, want 1, 1", len(first.Runs), len(second.Runs)) - } - if first.Runs[0].AdmissionSeq != 1 || second.Runs[0].AdmissionSeq != 2 { - t.Fatalf("run sequences = %d, %d", first.Runs[0].AdmissionSeq, second.Runs[0].AdmissionSeq) - } - - firstClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || firstClaim.Run.ID != first.Runs[0].ID { - t.Fatalf("first claim = %#v ok=%v err=%v", firstClaim.Run, ok, err) - } - if _, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("second claim while first running: ok=%v err=%v", ok, err) - } - firstDone, err := runs.Complete(ctx, first.Runs[0].ID, - []domain.EventDraft{{Type: domain.EvSessionStatusIdle}}, - domain.StatusIdle, nil, nil) - if err != nil { - t.Fatal(err) - } - if firstDone.Session.Status != domain.StatusRunning { - t.Fatalf("queued successor should keep projection running, got %s", firstDone.Session.Status) - } - secondClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || secondClaim.Run.ID != second.Runs[0].ID { - t.Fatalf("second claim = %#v ok=%v err=%v", secondClaim.Run, ok, err) - } -} - -// TestRunStore_AdmitBatchCreatesOneRunPerTrigger proves a single atomic -// admission of multiple triggers yields one durable queued run per trigger, in -// admission order, each holding exactly one trigger id — never one grouped run. -func TestRunStore_AdmitBatchCreatesOneRunPerTrigger(t *testing.T) { - _, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "first"}, - }}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "second"}, - }}}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 2 { - t.Fatalf("runs = %d, want 2 (one per trigger)", len(admission.Runs)) - } - // The two user events are events[0] and events[1]; events[2] is the status - // event emitted by the same admission. - wantTriggers := []string{admission.Events[0].ID, admission.Events[1].ID} - for i, run := range admission.Runs { - if run.AdmissionSeq != int64(i+1) { - t.Fatalf("run %d admission_seq = %d, want %d", i, run.AdmissionSeq, i+1) - } - if len(run.TriggerEventIDs) != 1 || run.TriggerEventIDs[0] != wantTriggers[i] { - t.Fatalf("run %d triggers = %#v, want [%s]", i, run.TriggerEventIDs, wantTriggers[i]) - } - if run.State != domain.RunQueued { - t.Fatalf("run %d state = %s, want queued", i, run.State) - } - } -} - -// TestRunStore_CompletionBeforeNextClaimObservesOutput proves that after run N -// commits its agent output and marks its trigger processed, run N+1 becomes -// claimable and projects history that includes run N's committed output. -func TestRunStore_CompletionBeforeNextClaimObservesOutput(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "first"}, - }}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "second"}, - }}}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 2 { - t.Fatalf("runs = %d, want 2", len(admission.Runs)) - } - - firstClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("first claim: ok=%v err=%v", ok, err) - } - // Run N+1 is not claimable while run N is running. - if _, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("second claim before first completes: ok=%v err=%v", ok, err) - } - - // Run N commits agent output and marks its trigger processed. - if _, err := runs.Complete(ctx, firstClaim.Run.ID, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "answer-one"}, - }}}, - {Type: domain.EvSessionStatusIdle}, - }, domain.StatusRunning, nil, nil); err != nil { - t.Fatal(err) - } - - // The first trigger is now processed. - es := NewEventStore(db, domain.NewSeqIDGen(), domain.FixedClock{T: session.CreatedAt}) - history, err := es.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - if history[0].ProcessedAt == nil { - t.Fatal("first trigger not marked processed after run N completed") - } - - // Run N+1 is now claimable, and history projected for it includes the agent - // output committed by run N. - secondClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok || secondClaim.Run.ID != admission.Runs[1].ID { - t.Fatalf("second claim = %#v ok=%v err=%v", secondClaim.Run, ok, err) - } - history, err = es.History(ctx, session.ID, 0, 100) - if err != nil { - t.Fatal(err) - } - var sawAgentMessage bool - for _, event := range history { - if event.Type == domain.EvAgentMessage { - sawAgentMessage = true - } - } - if !sawAgentMessage { - t.Fatal("history for run N+1 does not include run N's committed agent output") - } -} - -// TestRunStore_TerminatedSessionNeverClaims proves a terminated session is -// final: ClaimNext must not claim leftover queued work nor reopen the session. -func TestRunStore_TerminatedSessionNeverClaims(t *testing.T) { - db, runs, session := newRunStoreFixture(t) - ctx := context.Background() - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "first"}, - }}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "second"}, - }}}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 2 { - t.Fatalf("runs = %d, want 2", len(admission.Runs)) - } - - firstClaim, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("first claim: ok=%v err=%v", ok, err) - } - // Run N terminates the session even though run N+1 is still queued. - msg := "boom" - if _, err := runs.Complete(ctx, firstClaim.Run.ID, []domain.EventDraft{ - {Type: domain.EvSessionError, Payload: map[string]any{"error": map[string]any{ - "type": "api_error", "message": msg, - }}}, - {Type: domain.EvSessionStatusTerminated}, - }, domain.StatusTerminated, &msg, nil); err != nil { - t.Fatal(err) - } - - // The leftover queued run must never be claimed, and the session must stay - // terminated. - if claim, ok, err := runs.ClaimNext(ctx, session.ID); err != nil || ok { - t.Fatalf("claim after termination: claim=%#v ok=%v err=%v", claim.Run, ok, err) - } - stored, err := NewSessionRepo(db).Get(ctx, session.ID) - if err != nil { - t.Fatal(err) - } - if stored.Status != domain.StatusTerminated { - t.Fatalf("session status = %s, want terminated", stored.Status) - } -} - -// TestRunStore_ModelHistorySurvivesReopenInCausalOrder proves the durable output -// association: after run A completes (persisting its committed output event ids -// in the same transaction that closes it), the process can close and reopen a -// file-backed database, and RunStore.ModelHistory for run B still reconstructs -// trigger(A), output(A), trigger(B) in causal order — reading the persisted -// column, not any in-memory state. -func TestRunStore_ModelHistorySurvivesReopenInCausalOrder(t *testing.T) { - path := filepath.Join(t.TempDir(), "causal.db") - db, err := Open(path) - if err != nil { - t.Fatal(err) - } - ctx := context.Background() - now := time.Unix(1, 0).UTC() - ids := domain.NewSeqIDGen() - clk := domain.FixedClock{T: now} - if err := NewAgentRepo(db).PutVersion(ctx, domain.Agent{ - ID: "agent_1", Version: 1, Name: "agent", - Model: domain.Model{ID: "model"}, CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - if err := NewEnvironmentRepo(db).Put(ctx, domain.Environment{ - ID: "env_1", Name: "environment", ConfigType: "cloud", - CreatedAt: now, UpdatedAt: now, - }); err != nil { - t.Fatal(err) - } - session := domain.Session{ - ID: "sesn_1", AgentID: "agent_1", AgentVersion: 1, - EnvironmentID: "env_1", Status: domain.StatusIdle, - CreatedAt: now, UpdatedAt: now, - } - runs := NewRunStore(db, ids, clk) - - // Admit A and B in one batch (two runs), claim and complete A with a distinct - // agent output. Then close the database. - admission, err := runs.CreateSession(ctx, session, []domain.EventDraft{ - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "A"}, - }}}, - {Type: domain.EvUserMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "B"}, - }}}, - }) - if err != nil { - t.Fatal(err) - } - if len(admission.Runs) != 2 { - t.Fatalf("runs = %d, want 2", len(admission.Runs)) - } - runBID := admission.Runs[1].ID - - claimA, ok, err := runs.ClaimNext(ctx, session.ID) - if err != nil || !ok { - t.Fatalf("claim A: ok=%v err=%v", ok, err) - } - if _, err := runs.Complete(ctx, claimA.Run.ID, []domain.EventDraft{ - {Type: domain.EvAgentMessage, Payload: map[string]any{"content": []any{ - map[string]any{"type": "text", "text": "reply-A"}, - }}}, - {Type: domain.EvSessionStatusIdle}, - }, domain.StatusRunning, nil, nil); err != nil { - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - - // Reopen the file-backed database in a fresh RunStore and reconstruct history - // for run B purely from persisted state. - reopened, err := Open(path) - if err != nil { - t.Fatal(err) - } - defer reopened.Close() - reopenedRuns := NewRunStore(reopened, domain.NewSeqIDGen(), clk) - - runB, err := reopenedRuns.Get(ctx, runBID) - if err != nil { - t.Fatal(err) - } - history, err := reopenedRuns.ModelHistory(ctx, runB, 10000) - if err != nil { - t.Fatal(err) - } - - // Expect exactly trigger(A), output(A) [agent.message reply-A, status_idle], - // trigger(B), in causal order. - msgs := domain.ProjectMessages(history) - if len(msgs) != 3 { - t.Fatalf("projected messages = %d, want 3: %#v", len(msgs), msgs) - } - if msgs[0].Role != domain.RoleUser || msgs[0].Content[0].Text != "A" { - t.Fatalf("messages[0] = %#v, want user 'A'", msgs[0]) - } - if msgs[1].Role != domain.RoleAssistant || msgs[1].Content[0].Text != "reply-A" { - t.Fatalf("messages[1] = %#v, want assistant 'reply-A'", msgs[1]) - } - if msgs[2].Role != domain.RoleUser || msgs[2].Content[0].Text != "B" { - t.Fatalf("messages[2] = %#v, want user 'B'", msgs[2]) - } - - // And prove the trigger(A), output(A), trigger(B) event ordering directly on - // the raw reconstructed events, independent of projection folding. - if len(history) < 4 { - t.Fatalf("reconstructed history has %d events, want >=4", len(history)) - } - if history[0].ID != claimA.Run.TriggerEventIDs[0] { - t.Fatalf("history[0] is not trigger(A): %#v", history[0]) - } - if history[len(history)-1].ID != runB.TriggerEventIDs[0] { - t.Fatalf("last history event is not trigger(B): %#v", history[len(history)-1]) - } - var sawAgentBetween bool - for _, e := range history[1 : len(history)-1] { - if e.Type == domain.EvAgentMessage { - sawAgentBetween = true - } - } - if !sawAgentBetween { - t.Fatal("output(A) agent.message did not survive reopen between trigger(A) and trigger(B)") - } -} diff --git a/internal/store/schema.sql b/internal/store/schema.sql deleted file mode 100644 index 7173d21..0000000 --- a/internal/store/schema.sql +++ /dev/null @@ -1,121 +0,0 @@ -CREATE TABLE IF NOT EXISTS agents ( - id TEXT NOT NULL, - version INTEGER NOT NULL, - name TEXT NOT NULL, - body TEXT NOT NULL, -- JSON of the full agent at this version - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - archived_at TEXT, - PRIMARY KEY (id, version) -); -CREATE TABLE IF NOT EXISTS environments ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - config_type TEXT NOT NULL, - body TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - archived_at TEXT -); -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - agent_version INTEGER NOT NULL, - environment_id TEXT NOT NULL, - status TEXT NOT NULL, - body TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - archived_at TEXT -); -CREATE TABLE IF NOT EXISTS events ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - seq INTEGER NOT NULL, - type TEXT NOT NULL, - payload TEXT NOT NULL, - created_at TEXT NOT NULL, - processed_at TEXT, - UNIQUE (session_id, seq) -); -CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, seq); -CREATE TABLE IF NOT EXISTS session_runs ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - admission_seq INTEGER NOT NULL, - trigger_event_ids TEXT NOT NULL, - -- Internal-only. The exact committed output event ids this run appended when - -- it closed, persisted in the same transaction that closes the run so there is - -- never a completed run without its output association. Empty until the run - -- completes. Never serialized onto the public wire. - output_event_ids TEXT NOT NULL DEFAULT '[]', - state TEXT NOT NULL, - error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (session_id, admission_seq) -); -CREATE INDEX IF NOT EXISTS idx_session_runs_session_state_seq - ON session_runs(session_id, state, admission_seq); -CREATE UNIQUE INDEX IF NOT EXISTS idx_session_runs_one_running - ON session_runs(session_id) WHERE state = 'running'; --- Internal-only execution attempts for one logical run. A later retry creates a --- new attempt instead of erasing what an earlier process may have done. The --- runtime writes these rows around every locally executed built-in tool. -CREATE TABLE IF NOT EXISTS run_attempts ( - id TEXT PRIMARY KEY, - run_id TEXT NOT NULL, - attempt_no INTEGER NOT NULL CHECK (attempt_no > 0), - state TEXT NOT NULL CHECK (state IN ('active', 'completed', 'failed', 'interrupted')), - error TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - finished_at TEXT, - UNIQUE (run_id, attempt_no), - FOREIGN KEY (run_id) REFERENCES session_runs(id) ON DELETE CASCADE -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_run_attempts_one_active - ON run_attempts(run_id) WHERE state = 'active'; --- A tool step records the model-requested operation before execution begins, --- then advances through an explicit side-effect boundary. A started step that --- has no durable result is never folded back into prepared: recovery must mark --- it ambiguous rather than silently executing it again. -CREATE TABLE IF NOT EXISTS tool_steps ( - id TEXT PRIMARY KEY, - attempt_id TEXT NOT NULL, - ordinal INTEGER NOT NULL CHECK (ordinal >= 0), - tool_use_event_id TEXT NOT NULL UNIQUE, - tool_name TEXT NOT NULL, - input TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('prepared', 'started', 'completed', 'ambiguous')), - result TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - started_at TEXT, - finished_at TEXT, - UNIQUE (attempt_id, ordinal), - FOREIGN KEY (attempt_id) REFERENCES run_attempts(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_tool_steps_attempt_ordinal - ON tool_steps(attempt_id, ordinal); --- Internal-only durable record of a run that parked awaiting a client response --- (a custom tool result or an always_ask tool confirmation). While a row is --- unresolved (resolved_at IS NULL) it gates the session's ordinary queued runs; --- only a matching resolution trigger may be claimed. Never serialized onto the --- public wire. action_event_id references the committed agent.custom_tool_use / --- agent.tool_use event; kind is derived from that event's type AND payload (an --- agent.tool_use parks only when its evaluated_permission is "ask"), not any --- caller string. The unique constraint makes a single park emit exactly one --- pending action per action event. -CREATE TABLE IF NOT EXISTS pending_actions ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - action_event_id TEXT NOT NULL, - kind TEXT NOT NULL, - resolving_event_id TEXT, - created_at TEXT NOT NULL, - resolved_at TEXT, - UNIQUE (session_id, action_event_id) -); -CREATE INDEX IF NOT EXISTS idx_pending_actions_unresolved - ON pending_actions(session_id) WHERE resolved_at IS NULL; diff --git a/internal/store/session_repo.go b/internal/store/session_repo.go deleted file mode 100644 index ed0bd94..0000000 --- a/internal/store/session_repo.go +++ /dev/null @@ -1,347 +0,0 @@ -package store - -import ( - "context" - "database/sql" - "encoding/json" - "strings" - "time" - - "github.com/yanpgwang/managed-agent-go/internal/domain" -) - -type SessionRepo struct{ db *DB } - -func NewSessionRepo(db *DB) *SessionRepo { return &SessionRepo{db} } - -// sessionCreatedKeySQL normalizes the created_at column to a fixed-width -// nanosecond representation for correct ordering and boundary comparisons. -var sessionCreatedKeySQL = nanoKeySQL("created_at") - -const sessionCreatedKeyFormat = "2006-01-02T15:04:05.000000000Z" - -type SessionListBoundary struct { - CreatedAt time.Time - ID string - // Backward asks for the page immediately before this boundary in the - // caller's requested order. False asks for the page immediately after it. - Backward bool -} - -type SessionListQuery struct { - AgentID string - AgentVersion *int - CreatedAtGt *time.Time - CreatedAtGte *time.Time - CreatedAtLt *time.Time - CreatedAtLte *time.Time - IncludeArchived bool - Statuses []domain.Status - // Sessions do not yet persist deployments or memory-store resources. - // These flags make an explicit filter match no rows instead of being - // silently ignored. - HasDeploymentFilter bool - HasMemoryStoreFilter bool - Boundary *SessionListBoundary - Limit int - Desc bool -} - -type SessionListResult struct { - Sessions []domain.Session - // HasBefore and HasAfter are relative to the requested display order. - HasBefore bool - HasAfter bool -} - -func (r *SessionRepo) Put(ctx context.Context, s domain.Session) error { - body, err := json.Marshal(s) - if err != nil { - return err - } - _, err = r.db.ExecContext(ctx, - `INSERT INTO sessions (id, agent_id, agent_version, environment_id, status, body, created_at, updated_at, archived_at) - VALUES (?,?,?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET status=excluded.status, body=excluded.body, - updated_at=excluded.updated_at, archived_at=excluded.archived_at`, - s.ID, s.AgentID, s.AgentVersion, s.EnvironmentID, string(s.Status), string(body), - timeVal(s.CreatedAt), timeVal(s.UpdatedAt), nullableTime(s.ArchivedAt)) - return err -} - -// CreateIfDependenciesActive inserts a new session only while its exact agent -// version and environment still exist and remain unarchived. Keeping both -// dependency checks in the INSERT makes session creation linearizable with -// concurrent agent/environment archival and environment deletion. -func (r *SessionRepo) CreateIfDependenciesActive(ctx context.Context, s domain.Session) error { - return insertSessionIfDependenciesActive(ctx, r.db, s) -} - -type sessionExecer interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) -} - -func insertSessionIfDependenciesActive( - ctx context.Context, - exec sessionExecer, - s domain.Session, -) error { - body, err := json.Marshal(s) - if err != nil { - return err - } - result, err := exec.ExecContext(ctx, ` -INSERT INTO sessions - (id, agent_id, agent_version, environment_id, status, body, created_at, updated_at, archived_at) -SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? -WHERE EXISTS ( - SELECT 1 - FROM agents - WHERE id = ? AND version = ? AND archived_at IS NULL -) -AND EXISTS ( - SELECT 1 - FROM environments - WHERE id = ? AND archived_at IS NULL -)`, - s.ID, s.AgentID, s.AgentVersion, s.EnvironmentID, string(s.Status), string(body), - timeVal(s.CreatedAt), timeVal(s.UpdatedAt), nullableTime(s.ArchivedAt), - s.AgentID, s.AgentVersion, s.EnvironmentID) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected != 1 { - return domain.Validation("agent or environment is missing or archived") - } - return nil -} - -func (r *SessionRepo) Get(ctx context.Context, id string) (domain.Session, error) { - var body string - err := r.db.QueryRowContext(ctx, `SELECT body FROM sessions WHERE id=?`, id).Scan(&body) - if err == sql.ErrNoRows { - return domain.Session{}, domain.NotFound("session not found") - } - if err != nil { - return domain.Session{}, err - } - var s domain.Session - return s, json.Unmarshal([]byte(body), &s) -} - -func (r *SessionRepo) List(ctx context.Context, query SessionListQuery) (SessionListResult, error) { - if query.Limit <= 0 { - query.Limit = 100 - } - - clauses, baseArgs := sessionListClauses(query) - pageClauses := append([]string(nil), clauses...) - pageArgs := append([]any(nil), baseArgs...) - - displayOrder := "ASC" - if query.Desc { - displayOrder = "DESC" - } - fetchOrder := displayOrder - if query.Boundary != nil { - relation := sessionRelationAfter - if query.Boundary.Backward { - relation = sessionRelationBefore - fetchOrder = oppositeOrder(displayOrder) - } - predicate, args := sessionKeyPredicate(relation, query.Desc, *query.Boundary) - pageClauses = append(pageClauses, predicate) - pageArgs = append(pageArgs, args...) - } - - statement := `SELECT body FROM sessions` - if len(pageClauses) > 0 { - statement += ` WHERE ` + strings.Join(pageClauses, ` AND `) - } - statement += ` ORDER BY ` + sessionCreatedKeySQL + ` ` + fetchOrder + `, id ` + fetchOrder + ` LIMIT ?` - pageArgs = append(pageArgs, query.Limit) - - rows, err := r.db.QueryContext(ctx, statement, pageArgs...) - if err != nil { - return SessionListResult{}, err - } - sessions, err := scanSessions(rows) - if closeErr := rows.Close(); err == nil { - err = closeErr - } - if err != nil { - return SessionListResult{}, err - } - if query.Boundary != nil && query.Boundary.Backward { - reverseSessions(sessions) - } - - result := SessionListResult{Sessions: sessions} - if len(sessions) == 0 { - return result, nil - } - result.HasBefore, err = r.sessionExistsRelative( - ctx, clauses, baseArgs, sessionRelationBefore, query.Desc, sessions[0], - ) - if err != nil { - return SessionListResult{}, err - } - result.HasAfter, err = r.sessionExistsRelative( - ctx, clauses, baseArgs, sessionRelationAfter, query.Desc, sessions[len(sessions)-1], - ) - if err != nil { - return SessionListResult{}, err - } - return result, nil -} - -type sessionRelation int - -const ( - sessionRelationBefore sessionRelation = iota - sessionRelationAfter -) - -func sessionListClauses(query SessionListQuery) ([]string, []any) { - clauses := make([]string, 0, 10) - args := make([]any, 0, 12) - if !query.IncludeArchived { - clauses = append(clauses, `archived_at IS NULL`) - } - if query.AgentID != "" { - clauses = append(clauses, `agent_id = ?`) - args = append(args, query.AgentID) - } - if query.AgentVersion != nil { - clauses = append(clauses, `agent_version = ?`) - args = append(args, *query.AgentVersion) - } - for _, bound := range []struct { - value *time.Time - op string - }{ - {query.CreatedAtGt, `>`}, - {query.CreatedAtGte, `>=`}, - {query.CreatedAtLt, `<`}, - {query.CreatedAtLte, `<=`}, - } { - if bound.value != nil { - clauses = append(clauses, sessionCreatedKeySQL+` `+bound.op+` ?`) - args = append(args, sessionCreatedKey(*bound.value)) - } - } - if len(query.Statuses) > 0 { - placeholders := make([]string, len(query.Statuses)) - for i, status := range query.Statuses { - placeholders[i] = `?` - args = append(args, string(status)) - } - clauses = append(clauses, `status IN (`+strings.Join(placeholders, `,`)+`)`) - } - if query.HasDeploymentFilter || query.HasMemoryStoreFilter { - clauses = append(clauses, `0 = 1`) - } - return clauses, args -} - -func sessionKeyPredicate( - relation sessionRelation, - desc bool, - boundary SessionListBoundary, -) (string, []any) { - cmp := `>` - if relation == sessionRelationBefore { - cmp = `<` - } - if desc { - if cmp == `>` { - cmp = `<` - } else { - cmp = `>` - } - } - return `(` + sessionCreatedKeySQL + ` ` + cmp + ` ? OR (` + - sessionCreatedKeySQL + ` = ? AND id ` + cmp + ` ?))`, - []any{sessionCreatedKey(boundary.CreatedAt), sessionCreatedKey(boundary.CreatedAt), boundary.ID} -} - -func sessionCreatedKey(value time.Time) string { - return value.UTC().Format(sessionCreatedKeyFormat) -} - -func (r *SessionRepo) sessionExistsRelative( - ctx context.Context, - clauses []string, - args []any, - relation sessionRelation, - desc bool, - session domain.Session, -) (bool, error) { - predicate, boundaryArgs := sessionKeyPredicate(relation, desc, SessionListBoundary{ - CreatedAt: session.CreatedAt, - ID: session.ID, - }) - allClauses := append(append([]string(nil), clauses...), predicate) - allArgs := append(append([]any(nil), args...), boundaryArgs...) - statement := `SELECT EXISTS(SELECT 1 FROM sessions WHERE ` + - strings.Join(allClauses, ` AND `) + ` LIMIT 1)` - var exists bool - if err := r.db.QueryRowContext(ctx, statement, allArgs...).Scan(&exists); err != nil { - return false, err - } - return exists, nil -} - -func oppositeOrder(order string) string { - if order == "ASC" { - return "DESC" - } - return "ASC" -} - -func reverseSessions(sessions []domain.Session) { - for left, right := 0, len(sessions)-1; left < right; left, right = left+1, right-1 { - sessions[left], sessions[right] = sessions[right], sessions[left] - } -} - -func (r *SessionRepo) Delete(ctx context.Context, id string) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `DELETE FROM session_runs WHERE session_id=?`, id); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM pending_actions WHERE session_id=?`, id); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE session_id=?`, id); err != nil { - return err - } - if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE id=?`, id); err != nil { - return err - } - return tx.Commit() -} - -func scanSessions(rows *sql.Rows) ([]domain.Session, error) { - var out []domain.Session - for rows.Next() { - var body string - if err := rows.Scan(&body); err != nil { - return nil, err - } - var s domain.Session - if err := json.Unmarshal([]byte(body), &s); err != nil { - return nil, err - } - out = append(out, s) - } - return out, rows.Err() -} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go deleted file mode 100644 index 4a0afb7..0000000 --- a/internal/store/sqlite.go +++ /dev/null @@ -1,92 +0,0 @@ -package store - -import ( - "database/sql" - _ "embed" - "fmt" - "sync/atomic" - "time" - - _ "modernc.org/sqlite" -) - -//go:embed schema.sql -var schema string - -// rfc3339 is the canonical time format used for all TEXT time columns. -const rfc3339 = time.RFC3339Nano - -// nanoKeySQL builds a SQL expression that normalizes a UTC RFC 3339 timestamp -// column to a fixed-width nanosecond representation. time.RFC3339Nano omits -// trailing fractional zeros, so comparing raw TEXT values would sort an -// exact-second value after a fractional value in the same second. Callers pass -// the column name (e.g. "created_at", "processed_at"). -func nanoKeySQL(column string) string { - return `(CASE - WHEN substr(` + column + `, 20, 1) = 'Z' - THEN substr(` + column + `, 1, 19) || '.000000000Z' - ELSE substr(` + column + `, 1, 20) || - substr(substr(` + column + `, 21, length(` + column + `) - 21) || '000000000', 1, 9) || 'Z' - END)` -} - -// DB wraps *sql.DB with the current schema applied. -type DB struct{ *sql.DB } - -// Open opens (or creates) a SQLite database at dsn, applies PRAGMAs, and creates -// the current schema. During the pre-release phase, old development schemas are -// intentionally not migrated; rebuild the local database when the schema -// changes. -func Open(dsn string) (*DB, error) { - sqlDB, err := sql.Open("sqlite", dsn) - if err != nil { - return nil, err - } - sqlDB.SetMaxOpenConns(1) - for _, p := range []string{ - `PRAGMA foreign_keys=ON`, - `PRAGMA journal_mode=WAL`, - `PRAGMA busy_timeout=5000`, - } { - if _, err := sqlDB.Exec(p); err != nil { - sqlDB.Close() - return nil, err - } - } - if _, err := sqlDB.Exec(schema); err != nil { - sqlDB.Close() - return nil, err - } - return &DB{sqlDB}, nil -} - -var memSeq atomic.Int64 - -// OpenMemory opens a unique named in-memory SQLite database suitable for tests. -// Each call gets an isolated database so tests do not share state. -func OpenMemory() (*DB, error) { - n := memSeq.Add(1) - return Open(fmt.Sprintf("file:memdb%d?mode=memory&cache=shared", n)) -} - -// parseRFC3339 parses an RFC3339Nano string stored in SQLite TEXT columns. -func parseRFC3339(s string) (time.Time, error) { - t, err := time.Parse(rfc3339, s) - if err != nil { - return time.Time{}, fmt.Errorf("store: parse time %q: %w", s, err) - } - return t, nil -} - -// timeVal formats t as an RFC3339Nano string for storage in TEXT columns. -func timeVal(t time.Time) string { - return t.UTC().Format(rfc3339) -} - -// nullableTime returns the RFC3339Nano string for t, or nil if t is nil. -func nullableTime(t *time.Time) any { - if t == nil { - return nil - } - return t.UTC().Format(rfc3339) -} diff --git a/internal/store/sqlite_test.go b/internal/store/sqlite_test.go deleted file mode 100644 index 98add3c..0000000 --- a/internal/store/sqlite_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package store - -import "testing" - -func TestOpenMemory_CreatesCurrentSchema(t *testing.T) { - db, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - defer db.Close() - var n int - // Core tables must exist. - if err := db.QueryRow(`SELECT count(*) FROM events`).Scan(&n); err != nil { - t.Fatalf("events table missing: %v", err) - } - if n != 0 { - t.Fatalf("expected empty, got %d", n) - } - if err := db.QueryRow(`SELECT count(*) FROM session_runs`).Scan(&n); err != nil { - t.Fatalf("session_runs table missing: %v", err) - } - if err := db.QueryRow(`SELECT count(*) FROM run_attempts`).Scan(&n); err != nil { - t.Fatalf("run_attempts table missing: %v", err) - } - if err := db.QueryRow(`SELECT count(*) FROM tool_steps`).Scan(&n); err != nil { - t.Fatalf("tool_steps table missing: %v", err) - } -} - -func TestOpenMemory_Isolated(t *testing.T) { - a, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - defer a.Close() - if _, err := a.Exec(`INSERT INTO environments (id,name,config_type,body,created_at,updated_at) VALUES ('env_x','n','cloud','{}','t','t')`); err != nil { - t.Fatal(err) - } - b, err := OpenMemory() - if err != nil { - t.Fatal(err) - } - defer b.Close() - var n int - if err := b.QueryRow(`SELECT count(*) FROM environments`).Scan(&n); err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("second OpenMemory must be isolated, saw %d rows", n) - } -} diff --git a/internal/temporal/activities.go b/internal/temporal/activities.go index dcafe36..2de1740 100644 --- a/internal/temporal/activities.go +++ b/internal/temporal/activities.go @@ -736,9 +736,9 @@ const durableWriteTimeout = 30 * time.Second // durableCtx returns a context detached from the caller's cancellation // (context.WithoutCancel preserves values like tracing metadata) with a fresh // bounded timeout. It is created per durable write, never once before a long -// runtime call, so the timeout cannot expire mid-run. This mirrors the SQLite -// app's runToolJournal, which records tool facts on a durable context so an -// interrupt reaching a tool executor still commits the result. +// runtime call, so the timeout cannot expire mid-run. This lets an interrupt +// reach a tool executor while still giving the result a bounded opportunity to +// commit. func durableCtx(ctx context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout(context.WithoutCancel(ctx), durableWriteTimeout) } diff --git a/internal/temporal/orchestrator.go b/internal/temporal/orchestrator.go index ad07426..e1ba38b 100644 --- a/internal/temporal/orchestrator.go +++ b/internal/temporal/orchestrator.go @@ -41,7 +41,8 @@ func (o *Orchestrator) Admit(ctx context.Context, sessionID string, drafts []dom log.Printf("orchestrator: fast-path signal failed session_id=%s (relay will deliver): %v", sessionID, sigErr) } } - // Echo only the caller-submitted events, matching the SQLite path's contract. + // Echo only the caller-submitted events; orchestration-generated events are + // observed through list and stream endpoints. if len(drafts) < len(adm.Events) { return adm.Events[:len(drafts)], nil } diff --git a/internal/temporal/workflow_tool_activity_test.go b/internal/temporal/workflow_tool_activity_test.go index 5ea8f65..a913dda 100644 --- a/internal/temporal/workflow_tool_activity_test.go +++ b/internal/temporal/workflow_tool_activity_test.go @@ -293,7 +293,7 @@ func TestWorkflowTurn_ToolResultWriteRetryDoesNotReexecute(t *testing.T) { ids := domain.NewRandomIDGen() source := storeSource{store: store} journal := &loseFirstCompletionAckJournal{JournalStore: source} - manager := sandbox.NewSessionManager(sandbox.NewLocalProvider()) + manager := sandbox.NewSessionManager(sandbox.NewLocalProvider(), store) lease := &forwardingCountingLease{inner: manager} t.Cleanup(func() { _ = manager.Release(context.Background(), sessionID)