From d9d6fc4a4b8e654d405dbb1d01bf3404e6435a1c Mon Sep 17 00:00:00 2001 From: jinye Date: Mon, 2 Nov 2026 20:46:43 +0800 Subject: [PATCH] refactor(managed-agent): split Agent and Session stores (#16090) * test(managed-agent): audit lock order across store collaborators Record the bilingual Store decomposition contract and entry ownership, transaction, lock and caller inventories. Follow final collaborator fields across source files while preserving owner-bound facade populations and existing ambiguity, transaction and compiled-member checks. Co-authored-by: Qwen-Coder * refactor(managed-agent): split Agent and Session stores Move Agent and Journal responsibilities into immutable internal collaborators while retaining transaction facades and compatibility bridges. Resolve Journal dependencies at construction and migrate factories, fixtures, lock auditing and the bilingual implementation contract. Record focused verification and outstanding full-gate requirements separately. Co-authored-by: Qwen-Coder * fix(managed-agent): repair store decomposition verification Keep declared field receivers visible across method-local type scopes while retaining fail-closed nested class handling. Preserve the Hosted CSI dependency identity assertion at its new owner. Co-authored-by: Qwen-Coder --------- Co-authored-by: Qwen-Coder --- .../2026-11-02-managed-store-decomposition.md | 551 ++ ...11-02-managed-store-decomposition.zh-CN.md | 551 ++ .../persistence/AgentEventJournal.java | 416 + .../persistence/AgentExecutionOperations.java | 1015 +++ .../persistence/AgentLifecycleOperations.java | 1071 +++ .../persistence/AgentMessageProjection.java | 987 +++ .../persistence/AgentSessionAdmissions.java | 850 ++ .../persistence/AgentSessionCommands.java | 1083 +++ .../persistence/AgentSessionPolicies.java | 784 ++ .../persistence/AgentSessionQueries.java | 551 ++ .../persistence/AgentStoreRows.java | 347 + .../persistence/AgentTurnExecution.java | 821 ++ .../persistence/JournalCommit.java | 875 +++ .../persistence/JournalFencing.java | 500 ++ .../persistence/JournalHistoryQueries.java | 404 + .../JournalLifecycleAuthorization.java | 199 + .../persistence/JournalResourceReads.java | 482 ++ .../persistence/JournalResourceWrites.java | 276 + .../persistence/JournalStoreRows.java | 707 ++ .../persistence/JournalWriterLifecycle.java | 638 ++ .../persistence/ManagedAgentStore.java | 6928 +---------------- .../persistence/ManagedSessionStore.java | 3758 +-------- .../maintenance/WorkspaceCsiRuntimeMain.java | 7 +- .../WorkspaceCsiWorkerAckMain.java | 8 +- .../managedagent/HookAdmissionHistory.java | 3 +- .../HostedCleanupRetentionTest.java | 3 +- .../ManagedAgentDefinitionAdmissionTest.java | 2 +- .../managedagent/ManagedAgentMySqlIT.java | 22 +- .../ManagedAgentStoreFactoryWiringTest.java | 4 +- .../ManagedApprovalChangeTest.java | 9 +- .../ManagedArtifactApiIntegrationTest.java | 3 +- .../ManagedContextCompressionMySqlIT.java | 2 +- ...edExtensionConfigurationAdmissionTest.java | 3 +- .../managedagent/ManagedModelContextTest.java | 4 +- .../ManagedModelSelectionTest.java | 3 +- .../ManagedNativeJournalSnapshotsTest.java | 2 +- .../ManagedSchemaJournalAtomicityTest.java | 3 +- .../ManagedSessionCreationSkillsTest.java | 20 +- .../managedagent/ManagedSessionQueryTest.java | 2 +- ...ManagedSessionStoreProcessFixtureMain.java | 4 +- .../PublicationJournalFixture.java | 19 +- .../ToolPublicationLockOrderMySqlIT.java | 3 +- .../ToolPublicationStoreTest.java | 57 +- .../managedagent/VerificationFixtures.java | 68 +- .../WorkspaceSessionCloseMySqlIT.java | 7 +- .../WorkspaceSessionRetentionMySqlIT.java | 11 +- .../api/ToolPublicationControllerTest.java | 3 +- .../WorkspaceCsiSessionMainTest.java | 5 +- .../HarnessPlacementStoreTest.java | 3 +- .../persistence/LocalStorageAliasTest.java | 2 +- .../ManagedAgentCsiMutationMySqlIT.java | 4 +- .../ManagedAgentStoreWiringTest.java | 23 +- .../ManagedNativeSnapshotRetirementTest.java | 2 +- .../ManagedSchemaPublicationStoreTest.java | 5 +- .../ManagedSchemaSessionStoreTest.java | 6 +- .../ManagedSessionGuardConvoyIT.java | 10 +- .../ManagedSessionNativeReadTest.java | 2 +- .../ManagedSessionPhysicalFenceTest.java | 2 +- .../ManagedSessionStoreCredentialTest.java | 14 +- .../ManagedSessionStoreWiringTest.java | 193 + .../ManagedStoreDecompositionTest.java | 162 + .../managedagent/persistence/O4MySqlGate.java | 2 +- ...essionResourceCollectionCollectorTest.java | 22 +- .../persistence/SessionRowLockOrderTest.java | 760 +- .../ToolPublicationAcknowledgementTest.java | 35 +- .../ToolPublicationAsyncVerificationTest.java | 8 +- ...lPublicationRetentionClockFixtureMain.java | 2 +- .../ToolPublicationRetentionStoreTest.java | 12 +- .../persistence/WakeRecoveryStoreTest.java | 2 +- .../WorkspaceCsiApplicationHistoryTest.java | 8 +- .../WorkspaceCsiApplicationInventoryTest.java | 2 +- .../WorkspaceCsiNativeActivationGateTest.java | 2 +- .../WorkspaceCsiRetainedArtifactsTest.java | 4 +- .../WorkspaceCsiSessionGuardTest.java | 2 +- .../WorkspaceCsiSqlWorkerMySqlIT.java | 10 +- .../WorkspaceCsiWorkerAckStoreTest.java | 2 +- .../WorkspaceLifecycleMySqlIT.java | 12 +- .../WorkspaceLifecycleStoreTest.java | 30 +- .../WorkspaceMigrationMySqlIT.java | 2 +- .../WorkspaceProfileLifecycleMySqlIT.java | 2 +- .../WorkspaceProfileLifecycleTest.java | 18 +- .../WorkspaceRecoveryStoreTest.java | 6 +- .../SessionLifecycleCoordinatorTest.java | 42 +- .../SessionRecoveryInitialStateTest.java | 4 +- .../ManagedCheckpointHistorySqlTest.java | 5 +- .../tests/managed-agent-server-e2e.test.js | 2 +- 86 files changed, 14732 insertions(+), 10763 deletions(-) create mode 100644 docs/design/2026-11-02-managed-store-decomposition.md create mode 100644 docs/design/2026-11-02-managed-store-decomposition.zh-CN.md create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentEventJournal.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentExecutionOperations.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentLifecycleOperations.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentMessageProjection.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionCommands.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionQueries.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentTurnExecution.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalFencing.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalHistoryQueries.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalLifecycleAuthorization.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceReads.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceWrites.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalStoreRows.java create mode 100644 packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreWiringTest.java create mode 100644 packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java diff --git a/docs/design/2026-11-02-managed-store-decomposition.md b/docs/design/2026-11-02-managed-store-decomposition.md new file mode 100644 index 00000000000..c5f185ff7ac --- /dev/null +++ b/docs/design/2026-11-02-managed-store-decomposition.md @@ -0,0 +1,551 @@ +# ManagedAgentStore and ManagedSessionStore decomposition + +[English](2026-11-02-managed-store-decomposition.md) | [简体中文](2026-11-02-managed-store-decomposition.zh-CN.md) + +## Status and scope + +This is the implementation contract for the accepted Store decomposition. The baseline is main `e3c7a6d7efc6df6c58ffa8ddd9c42dbb0e1784c4`. R0 ([#16061](https://github.com/QwenLM/qwen-code/pull/16061)) merged as `1bdad7d4b7949a6f8370c95965ec55901e75f67a`; its [full gate](https://github.com/QwenLM/qwen-code/actions/runs/41322812090) was still running when R1 preparation began. Merge and qualification are separate evidence. [#16029](https://github.com/QwenLM/qwen-code/pull/16029) is merged and adds no prerequisite work. + +R1 delivers this complete bilingual contract, the entry/transaction/caller inventory below and a lock-order audit that follows collaborators across source files. A1–A9 and B0–B6 are now implemented together on the implementation branch, retaining the stage ownership and acceptance checklist below. Both facades remain stable; implementation stays inside persistence. Implementation and qualification remain separate: the complete change requires the full Managed Agent gate before acceptance. + +Related designs: [structure review](2026-10-31-managed-agent-structure-review.md), [Agent constructor wiring](2026-11-01-managed-agent-store-wiring.md), [Maven boundaries](2026-10-29-managed-agent-maven-modules.md). + +## Problem and decisions + +The two Stores mix transaction entry points with SQL, mutable business state and several unrelated operation state machines. Moving methods by name would conceal side effects and can disable the existing single-file lock-order audit. Stable facades and explicit component ownership keep transaction behavior and callers reviewable throughout the extraction. + +Keep public business interfaces, DTOs, exception mapping and package-visible compatibility bridges. New collaborators are package-private `final` classes in the same package, constructed once by the facade. Add no Spring beans, Maven modules, public business interfaces, mutable dependency setup or generic Context/Support container. Existing worktree, definition and skills components retain their responsibilities. + +Dependencies use concrete types in one direction. Do not introduce facade callbacks or bundles of constructor functions to escape a cycle. Existing lazy Spring cycles, per-call fencing callbacks and compatibility value types keep their current meaning. If an extraction needs a shared rule, move its actual dependency closure with the first consumer; do not build unused helper frameworks. + +## Agent responsibility ownership + +| Slice | Component | Responsibility | +| ----- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| A1 | `AgentSessionQueries` | Ordinary Session/Turn reads, paging, batch summaries, worktree descriptions and lifecycle presentation views | +| A2 | `AgentEventJournal` | Public event writes, sequence and identity, deduplication, event reads and commit notifications | +| A3 | `AgentMessageProjection` | Materialization, message pages, coverage, replay floor and repair after retraction | +| A4 | `AgentSessionPolicies` | Model, approval, MCP and extension admission/configuration constraints | +| A5 | `AgentSessionCommands` | Turn input/cancel commands, rename, general Session mutation, command receipts and settlement | +| A6 | `AgentSessionAdmissions` | Ordinary/workspace/child/internal/CSI Session admission and replay; definition, skills and worktree preparation | +| A7 | `AgentLifecycleOperations` | Close/archive/delete/unarchive, delivery scans and shared operation claim/retry/completion | +| A8 | `AgentExecutionOperations` | Cwd, context compression, task cancellation and unknown-acceptance settlement, including compression projection backfill | +| A9 | `AgentTurnExecution` | Dispatch, leases, Harness admission/events, recovery wait, retries and Turn terminal state | + +`AgentStoreRows` owns shared Session/Turn row mapping and locked row reads. Commands owns shared receipts/settlement, EventJournal owns event identity and notification, and MessageProjection owns projection repair. Higher-level admission, lifecycle and execution workflows depend on these concrete lower-level collaborators. Move operation scans with their state machines. + +Queries must not become a facade-shaped dependency of every writer. Writers use Rows for shared row access. `contextCompressionState(s)` performs projection backfill and belongs to ExecutionOperations. `deliverableRenames` changes delivery state and belongs to Commands. Preserve unannotated writes such as `markRenameSubmitted` as unannotated entries. + +The worktree presentation entries `findSessionWorktree(s)` and `listSessions` can persist BLOCKED through the existing `SessionWorktreeStore` verification and its `REQUIRES_NEW` quarantine transaction. Exclude these three names from A1's pure-query slice; keep their complete implementation until A6 moves the worktree collaboration into the intended Queries presentation path. Keep that path explicitly side-effecting, reuse the existing worktree component and add no duplicate wrapper or new transaction. A1's boundary is smaller than the final Queries responsibility. + +## Journal responsibility ownership + +| Slice | Component | Responsibility | +| ----- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | +| B0 | Existing facade construction | Replace eight injection setters/nine references with construction-time assembly | +| B1 | `JournalWriterLifecycle` | Acquire, renew, seal, quiesce, release, block recovery and live-writer checks | +| B2 | `JournalLifecycleAuthorization` | Ordinary/lifecycle authorization, approval selection and lifecycle dispatch authorization | +| B3 | `JournalHistoryQueries` | Restore, paged/recent transactions, command transaction lookup and initial-journal integrity | +| B4 | `JournalResourceWrites` | Instructions, skills, inherited resources, tool-result publication and resource writes inside commit | +| B5 | `JournalResourceReads` | Ordinary/snapshot/owner reads, verification updates, quarantine, read leases and receipts | +| B6 | `JournalCommit` | Ordinary/lifecycle commit, CSI terminal preflight/commit, associated projections and notifications | + +`JournalStoreRows` owns shared row access/mapping and the integrity checks required to interpret those rows. B1 already needs activation/head checks and resource-receipt validation: migrate `activationLeaseRow`, `restoreHead`, `verifyStoredResource` and its `toolResultLimit` dependency with their first consumer. The validation can issue read-only capture/quarantine lookups; it does not stream objects, acquire read leases, quarantine objects or update verification timestamps. Those side effects remain in ResourceReads. Move other helpers only when actually used. + +`JournalFencing` owns credentials, writer and CSI fencing, plus bounded lock-wait error mapping. Keep its checks at their existing small-grained call sites. WriterLifecycle, LifecycleAuthorization and resource operations use Rows/Fencing; Commit depends on the concrete resource/authorization collaborators it actually calls. Do not invert the graph by making WriterLifecycle call back through the facade to HistoryQueries or ResourceReads. + +The existing `ensureSessionRoot` is also shared by writer acquisition and lifecycle authorization. Put this explicit, narrow root initialization in Rows when B1 first uses it: preserve its root lock and event-stream INSERT. Rows is therefore a row-access boundary, not a read-only service. Do not move writer or operation state machines into it. + +## Transactions, locks and atomicity + +Every facade entry retains its original transaction manager, propagation, isolation, read-only flag and absence of an annotation. This includes `MANDATORY`, `NOT_SUPPORTED` and `READ_COMMITTED`. Ordinary collaborator objects create no transaction proxy and introduce no `REQUIRES_NEW`. Existing programmatic transactions keep their original placement and timeout. + +For paths locking both Session and Turn, Session remains first. Placement, retention, root and head locks retain each path's existing order. CSI continuation exceptions and bounded lock-wait/deadlock mapping remain explicit; do not impose a new universal order. + +Commit resource changes, extension/action/message projections, journal transaction and head update atomically. Preserve duplicate-return positions, writer/CSI rechecks, configuration read timing and notification timing. Rollback and replay must not add notifications; standalone behavior without synchronization remains unchanged. + +Resource reads are not plain queries. Ordinary and snapshot reads retain their distinct verification/quarantine behavior and retention lease handling. Owner reads continue to reject an ambient transaction and use short admission transaction, object read, short recheck/receipt/cleanup transaction. Compression projection backfill keeps its programmatic transaction. + +Existing external `REQUIRES_NEW` boundaries remain: worktree quarantine and the snapshot caller's isolated `READ_COMMITTED` template. The ban is on introducing new transaction boundaries, not deleting those already required by callers. + +Do not change SQL, digests, JSON or protocol bytes, schema, HTTP/OpenAPI contracts or algorithms while extracting. + +## Construction and compatibility + +B0 resolves required dependencies at construction while retaining standalone defaults. The optional lifecycle pair remains disabled as a pair when either member is absent. CSI bindings still accept only a JDBC repository using the same data source. Update both maintenance entry points, manually assembled fixtures and factory-created Spring beans together. Verify missing required/optional dependencies, the real lazy cycle, proxy identity, actual `@Bean` factories and standalone construction. + +Preserve publication/snapshot methods, `PublicationWriter`, `TerminalCandidate`, same-data-source checks and existing static lock tools on the original facade. CSI terminal credential checks, preflight and commit must use the same physical connection bound to the caller's authorization. Agent CSI admission/credential bridges and static binding mapping also remain compatible. Exact bridge ownership is listed in the entry inventory. + +## Lock-audit implementation + +Keep the JDK parse-tree audit and its bounded resolution model. Load the facade with the same-package `Agent*.java` or `Journal*.java` collaborator family, rather than depending on a manually updated per-class allowlist. Other persistence files retain their existing boundary scan. New SQL-bearing files must still change the exactly pinned sibling populations visibly. + +Harvest members across the selected files using owner, name and parameter types. Keep source-specific imports and constant environments separate; qualify shared constants so one file cannot overwrite another file's SQL spelling. Cross-check parsed members against compiled classes for every selected source, including collaborators and nested types. + +Follow a field receiver only when the reference is final and its declared class is a parsed concrete final type. Resolve each field type in its declaring class. Support bare fields and explicit `this.field`; parameters/locals shadow bare fields without shadowing explicit field access. An external imported type must not borrow members or SQL constants from a same-named parsed collaborator. Keep unresolved, polymorphic, overload-ambiguous, generic/local-class and deferred-body cases fail-closed when a candidate reaches a Session lock or Turn row. Do not broaden ambiguity exemptions or delete population assertions to make moved code pass. + +Retain current facade entry sets, transaction checks, sibling boundary sets and ambiguity/deferred-body assertions. Collect entry populations under their facade owner so a same-named collaborator cannot satisfy them; order, ambiguity and compiled-member checks still cover the whole source family. Regression examples must cover a multi-file transitive delegate, moving the Session lock after Turn access, removing the lock entirely (caught by the pinned locker population), hiding it behind an unresolved receiver, shadowing, ambiguous overloads and file-specific constants/imports. + +The audit establishes source-level ordering, not transaction runtime semantics or database deadlock freedom. Each implemented stage still needs its relevant runtime and MySQL evidence. + +## Implementation checklist + +This table is the sole stage checklist, mirrored completely in the Chinese version. The entry inventory is its method-level detail, not another independent status list. Update both languages with each stage's implementation, exact tested revision, relevant evidence and remaining gaps. The stages are delivered together; none is accepted merely because its code has moved. + +| Order | Deliverable | Entry inventory | Status / evidence | +| ----- | ---------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- | +| R0 | Complete Agent constructor wiring and actual Spring factory regression | Existing wiring design | #16061 merged; full Hosted gate failed in two cases, attribution pending | +| R1 | Bilingual contract/inventory and cross-file lock audit | Both inventories below | R1 revision: 56 focused tests and all reactor Checkstyle checks pass; new-revision evidence below | +| A1 | Ordinary Agent queries and first required row helpers | Agent A1 | Implemented; qualification pending | +| A2 | Event journal | Agent A2 | Implemented; qualification pending | +| A3 | Message projection | Agent A3 | Implemented; qualification pending | +| A4 | Session policies | Agent A4 | Implemented; qualification pending | +| A5 | Commands, receipts and settlement | Agent A5 | Implemented; qualification pending | +| A6 | Session admission | Agent A6 | Implemented; qualification pending | +| A7 | Lifecycle operations | Agent A7 | Implemented; qualification pending | +| A8 | Execution operations | Agent A8 | Implemented; qualification pending | +| A9 | Turn execution and Agent facade cleanup | Agent A9 | Implemented; qualification pending | +| B0 | Session constructor wiring | Journal B0 | Implemented; qualification pending | +| B1 | Rows, fencing and writer lifecycle | Journal B1 | Implemented; qualification pending | +| B2 | Lifecycle authorization | Journal B2 | Implemented; qualification pending | +| B3 | History queries | Journal B3 | Implemented; qualification pending | +| B4 | Resource writes | Journal B4 | Implemented; qualification pending | +| B5 | Resource reads | Journal B5 | Implemented; qualification pending | +| B6 | Ordinary/CSI terminal commit and Journal facade cleanup | Journal B6 | Implemented; qualification pending | + +## Validation and release criteria + +R1 has no CLI/HTTP behavior change: a global CLI baseline and model calls do not exercise this source audit. Use synthetic multi-file fixtures and the real persistence source tree, compile the verification module, run `SessionRowLockOrderTest` and reactor Checkstyle. Check bilingual structure, links, formatting and the complete diff. Follow the module's shared local Java budget, preserve failures and reuse only unchanged inputs; a new SHA or continuation does not reset it. Prior 272-case wiring evidence does not qualify this changed checker. + +Local R1 evidence: the original 44 lock-audit tests and 12 added regressions all pass on macOS/JDK 21, with zero failures, errors or skips. The tested audit source SHA-256 is `bc6317d65f0d68dc9837f1d49f85e9a18ed41506e04ef395d33c326c65cd0052`; all eight reactor Checkstyle checks pass. The shared local Java budget totals 413.575/600 seconds, including the conservative R0 carryover and preserved repair attempts. This is focused evidence only: full verify, SpotBugs, real MySQL, Hosted/fault and cross-platform qualification for R1 remain pending CI. + +Local A1–A9/B0–B6 evidence on the implementation worktree based on `77a0d4a4d7cbc9eeab2213b8bf45af117b47d83d`: 260 distinct cases in 17 completed Java suites pass after repairing two new wiring-fixture failures; the successful 17-case wiring rerun supersedes those two failures only. Production sources compiled; the complete verification source tree compiled before the final source-only lock-audit assertion repairs. Seven reactor Checkstyle executions (parent and six production modules) passed; the verification module's final Checkstyle execution was not reached. Root build/bundle, typecheck and all 54 dedicated script tests passed. Logs, report XML and source fingerprints are retained under `.qwen/investigations/managed-store-split-implementation/`. + +Both bounded Maven invocations hit their deadlines; neither is an overall successful run. The shared Java ledger is 598.655/600 seconds, including prior work and preserved failed attempts. `SessionRowLockOrderTest` started but was interrupted; its updated populations and subsequent source-only repairs to assertion order, SQL-owner populations and the exact same-name reference handle remain unverified. Ten other selected suites did not complete: `JournalEventPublicationPointTest`, `ManagedEventReplayTest`, `ManagedSchemaJournalAtomicityTest`, `ManagedSessionCreationSkillsTest`, `ManagedSessionNativeReadTest`, `ManagedSessionStoreBindingTest`, `ManagedSessionStoreCredentialTest`, `ManagedTaskCancelOperationTest`, `ReplayFloorRetractionTest` and `WorkspaceSessionCloseTest`. Complete those checks and the verification-module Checkstyle in CI, alongside full verify, SpotBugs, real MySQL, Hosted/fault and cross-platform qualification. No new-revision CI acceptance is claimed. + +Qualification of each implemented stage must cover tenant/actor isolation, cursor semantics and batch query counts; admission/replay including definition/skills/worktree; sequence/dedup/projection gaps/retraction/replay floor; after-commit notification and rollback/replay silence; lease generation/token/expiry and unknown-acceptance non-redispatch; atomic journal rollback/replay; resource corruption/read-lease invalidation and owner-read cleanup; publication/snapshot lock order and CSI same-connection atomicity. + +Use real MySQL evidence for SQL/concurrency paths. CI owns full verify, SpotBugs, complete MySQL, Hosted/fault and cross-platform checks. The current push-time fast tier omits required qualification; the full Managed Agent gate on the accepted revision is the release criterion. Keep every unrun gate explicit. + +Each slice needs two consecutive clean complete-diff self-audits and independent review. At final completion both facades contain only assembly, transaction entries and compatible forwarding, with no business SQL or state machine. Collaborator instance dependencies are acyclic; public contracts and all path-specific transaction semantics are preserved. A green focused test is not full acceptance. + +## Risks and open decisions + +The accepted architectural direction has no outstanding product decision. The main implementation risks are hidden query side effects, shared receipt/resource validation, lazy construction cycles, moved transaction boundaries and source-audit blind spots. Resolve an unexpected dependency in the smallest affected slice and update both language versions before changing the design. Do not silently absorb an algorithm, SQL, storage or protocol redesign into a movement PR. + +## Implemented dependency boundaries + +The facade evaluates the same settings and catalog validation once, in the original order, then supplies the same values and dependency instances to final collaborator fields. Agent construction is Rows → Queries/EventJournal → Policies/Projection → Commands → Admissions/Lifecycle/TurnExecution → ExecutionOperations. Journal construction is Rows → Fencing → WriterLifecycle/HistoryQueries/LifecycleAuthorization/ResourceWrites/ResourceReads → Commit. No collaborator receives a facade or a constructor callback bundle. + +Shared Session/Turn point reads live in Rows; Queries forwards its three compatible point-read entries to those implementations. Policies owns shared admission predicates, while Rows owns their existing key encoders and database-time reads. The nine-argument default Session admission remains the original interface-default forwarding entry on the facade, preserving virtual dispatch and its transaction boundary. Resource row verification remains separate from object IO, leases and quarantine. Commit calls the extracted lifecycle-dispatch authorization block at its original location, with the existing call-local resource reader. + +Session construction now resolves all nine former setter references. The required Spring dependencies remain required; standalone constructors retain their original null/in-process/unbound defaults. Either missing lifecycle dependency disables both. Only a same-data-source JDBC runtime binding repository enables CSI. Maintenance entry points and fixtures supply dependencies before constructing downstream consumers; actual Spring factory construction is covered separately. + +The lock audit keeps its facade populations and pins the 24 migrated private-helper overloads by owner/name/arity. Companion sources are analyzed with their complete family and retain separate, explicit SQL-owner boundary populations. Compiled-member coverage, fail-closed receiver resolution and the original negative regressions remain required. The original-line inventories below refer to the recorded baseline, not the new source locations. + +## Agent entry inventory + +Source: `packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java`. + +Transaction legend: **T** = exact plain `@Transactional` (default REQUIRED, DEFAULT isolation, readOnly false), facade class retains `@Qualifier("managedAgentTransactionManager")`; **M** = same with explicit MANDATORY; **RR** = explicit transactionManager `managedAgentTransactionManager`, readOnly true, REPEATABLE_READ; **N** = no declarative transaction on the current entry. N is not a promise of read-only behavior or permission to add a transaction. The worktree rows separately name their existing verification/quarantine transaction. + +All overloads are listed by their source lines. Two constructors remain facade assembly; public static `readBinding` stays available at the facade and may forward to rows. Typed production callers below were cross-checked against actual receiver declarations, with the CSI local `var` construction path checked separately; an empty entry is explicitly marked rather than implying that the method can be removed. Tests remain consumers even where production does not currently call a compatibility entry. + +### A1 AgentSessionQueries + +| Entry / overload lines | Transaction | Direct production callers | +| ------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `findChildWorkspaceCwd` (691) | N | ManagedAgentService | +| `findChildLineage` (980) | N | ManagedAgentService, ManagedUserMessagePolicy, QwenHostedHarnessConnector | +| `hasCompletedWorkspaceClose` (2283) | N | No external direct production call found; preserve interface/internal/test usage | +| `completedWorkspaceCloses` (2290) | N | ManagedAgentService | +| `hasExecutionRegistryFacts` (3911) | N | ManagedActionStore, ManagedUserMessagePolicy | +| `sessionsWithExecutionRegistryFacts` (3919) | N | ManagedAgentService | +| `findSession` (4544) | N | ManagedAutomationService, WorkspaceCsiRuntimeAccess | +| `findSessionById` (4556) | N | ManagedRuntimeCoordinator, WorkspaceRuntimeResolver | +| `findLatestLifecycleOperations` (4648) | N | ManagedAgentService | +| `findTurn` (4674) | N | HarnessCoordinator, ManagedAgentService, QwenHostedHarnessConnector, SessionRecoveryService | +| `findActiveTurns` (4684) | N | ManagedAgentService, QwenHostedHarnessConnector | +| `findLatestTurns` (4708) | N | ManagedAgentService, SessionRecoveryService | +| `findModelContexts` (4747) | N | ManagedAgentService | +| `modelOptions` (4753) | N | ManagedAgentService | +| `findModelSelections` (4756) | N | ManagedAgentService | +| `findTurnModelSelection` (4776) | N | QwenHostedHarnessConnector | +| `findApprovalSelections` (4862) | N | ManagedAgentService, QwenHostedHarnessConnector | +| `findApprovalOwners` (4881) | N | ManagedAgentService | +| `findTurnApprovalSelection` (4895) | N | QwenHostedHarnessConnector | +| `listTurns` (5086) | N | ManagedAgentService | +| `findTurnSummary` (5110) | N | ManagedAgentService | +| `findLastVisibleStatusBeforeDelete` (6634) | N | ManagedAgentService | +| `requireSession` (6647) | N | ChildWorkspaceService, HarnessCoordinator, ManagedAgentService, ManagedExtensionCommandVerifier, ManagedExtensionRecordStore, ManagedRuntimeCoordinator, ManagedSessionStore, ManagedUserMessagePolicy, QwenHostedHarnessConnector, SessionLifecycleCoordinator, SessionLifecycleService, SessionRenameCoordinator | + +### A2 AgentEventJournal + +| Entry / overload lines | Transaction | Direct production callers | +| --------------------------------------- | ----------- | -------------------------------------------------------------------------------- | +| `findEvents` (5122) | N | ManagedAgentService | +| `findLatestEnvironmentEvents` (5140) | N | ManagedAgentService | +| `findControlEvents` (5184) | N | No external direct production call found; preserve interface/internal/test usage | +| `findTranscriptEvents` (5199) | N | No external direct production call found; preserve interface/internal/test usage | +| `appendPublicEventIfAbsent` (6601) | T | HarnessCoordinator, ManagedActionStore, ManagedToolResultStore | +| `appendLiveSessionEventIfAbsent` (6612) | T | No external direct production call found; preserve interface/internal/test usage | + +### A3 AgentMessageProjection + +| Entry / overload lines | Transaction | Direct production callers | +| ------------------------------------ | ----------- | ------------------------- | +| `findMessagePage` (5234, 5242) | RR | ManagedAgentService | +| `findMessageCoveredSequences` (5413) | N | ManagedAgentService | +| `findReplayWindow` (5435) | N | ManagedAgentService | +| `findReplayFloorTargets` (5454) | N | ReplayFloorAdvancer | +| `advanceReplayFloor` (5475) | T | ReplayFloorAdvancer | +| `findMaterializationTargets` (5506) | N | MessageMaterializer | +| `materializeNextBatch` (5516) | T | MessageMaterializer | +| `deferMaterializationTarget` (5620) | T | MessageMaterializer | + +### A4 AgentSessionPolicies + +| Entry / overload lines | Transaction | Direct production callers | +| ------------------------------------------------ | ----------- | --------------------------------- | +| `workspaceSessionOptions` (1096) | T | ManagedAgentService | +| `supportsPlanMode` (1113) | N | ManagedAgentService | +| `hasMcpServers` (1169) | N | SessionLifecycleCoordinator | +| `requireMcpManagement` (1180) | T | ManagedMcpManagementService | +| `requireMcpSettingsCommit` (1215) | M | ManagedExtensionRecordStore | +| `requireExtensionManagement` (1228) | T | ManagedExtensionManagementService | +| `requireConfigurationCommit` (1266) | M | ManagedExtensionRecordStore | +| `workspaceFilesEnabled` (2249) | N | ManagedAgentService | +| `workspaceShellEnabled` (2254) | N | ManagedAgentService | +| `changeSessionModel` (4790) | T | ManagedAgentService | +| `changeSessionApproval` (4926, 4935, 4944, 4954) | T | ManagedAgentService | + +### A5 AgentSessionCommands + +| Entry / overload lines | Transaction | Direct production callers | +| ------------------------------------ | ----------- | -------------------------------------------------------------------------------- | +| `insertTurnCommand` (1514, 1525) | T | ManagedAgentService | +| `insertCancelCommand` (1620, 1629) | T | ManagedAgentService | +| `beginSessionRename` (1685, 1693) | T | ManagedAgentService | +| `completeSessionRename` (1833, 1840) | T | ManagedAgentService, SessionRenameCoordinator | +| `abandonSessionRename` (1918) | T | ManagedAgentService, SessionRenameCoordinator | +| `deliverableRenames` (1933) | T | SessionRenameCoordinator | +| `claimRename` (1953) | T | ManagedAgentService, SessionRenameCoordinator | +| `markRenameSubmitted` (1979) | N | ManagedAgentService, SessionRenameCoordinator | +| `retryRename` (1997) | T | ManagedAgentService, SessionRenameCoordinator | +| `beginSessionMutation` (2080, 2089) | T | ManagedAgentService | +| `completeSessionMutation` (2151) | T | ManagedAgentService | +| `abandonSessionMutation` (2225) | T | No external direct production call found; preserve interface/internal/test usage | +| `replayCommand` (4502) | N | ManagedAgentService | +| `findCommand` (4524) | N | ManagedAgentService | + +### A6 AgentSessionAdmissions + +| Entry / overload lines | Transaction | Direct production callers | +| -------------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------- | +| `insertSessionCommand` (388, 7651) | T | ManagedAgentService | +| `insertWorkspaceSessionCommand` (402) | T | ManagedAgentService | +| `insertInternalTeamLeadSessionCommand` (528) | T | No external direct production call found; preserve interface/internal/test usage | +| `findWorkspaceCreateReplay` (555) | N | ManagedAgentService | +| `replayWorkspaceSessionCommand` (570) | T | ManagedAgentService | +| `insertChildSessionCommand` (667, 677) | T | ManagedAgentService | +| `insertPublicChildSessionCommand` (703) | T | ManagedAgentService | +| `replayChildSessionCommand` (952) | T | ManagedAgentService | +| `insertCsiSessionCommand` (582) | N (requires ambient transaction) | WorkspaceCsiSessionCommands | +| `requireCsiRequest` (633) | N (locking bridge; caller transaction) | WorkspaceCsiRuntimeAccess, WorkspaceCsiSessionCommands | + +### A6 -> AgentSessionQueries: compatibility display paths + +These entries remain unchanged in the facade through A1-A5. A6 moves them into the existing AgentSessionQueries component while integrating worktree collaboration. They retain side effects delegated to the existing SessionWorktreeStore; no additional worktree wrapper is introduced. + +| Entry / overload lines | Transaction | Direct production callers | +| --------------------------------- | --------------------------------------------------------- | --------------------------------------- | +| `findSessionWorktree` (157) | N + existing worktree verification/quarantine transaction | HarnessCoordinator, ManagedAgentService | +| `findSessionWorktrees` (162) | N + existing worktree verification/quarantine transaction | ManagedAgentService | +| `listSessions` (4564, 4569, 4574) | N + existing worktree verification/quarantine transaction | ManagedAgentService | + +### A7 AgentLifecycleOperations + +| Entry / overload lines | Transaction | Direct production callers | +| -------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- | +| `beginOperation` (2238) | T | SessionLifecycleService | +| `beginWorkspaceClose` (2260) | T | No external direct production call found; preserve interface/internal/test usage | +| `beginWorkspaceLifecycle` (2268, 2277) | T | SessionLifecycleService | +| `unarchiveWorkspaceSession` (2322) | T | ManagedAgentService | +| `beginChildClose` (2643) | T | ChildLifecycleAdmissions | +| `findOperation` (4062) | N | ManagedActionStore, ManagedAgentService, SessionLifecycleService, TaskCancelCoordinator, TurnInputEvidence | +| `findDeliverableOperations` (4070) | N | SessionLifecycleCoordinator | +| `claimOperation` (4108) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `completeOperation` (4143) | T | SessionLifecycleCoordinator | +| `failOperation` (4243) | T | SessionLifecycleCoordinator | +| `renewLifecycleOperation` (4308) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `blockLifecycleOperation` (4363, 4371) | T | SessionLifecycleCoordinator | +| `retryOperation` (4418, 4427) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `markOperationAnswered` (4473) | T | ActionResponseCoordinator | + +### A8 AgentExecutionOperations + +| Entry / overload lines | Transaction | Direct production callers | +| ------------------------------------------- | ----------- | -------------------------------------------------------------------------------- | +| `beginContextCompression` (2930) | T | ContextCompressionService | +| `contextCompressionState` (3005) | N | No external direct production call found; preserve interface/internal/test usage | +| `contextCompressionStates` (3020) | N | ManagedAgentService | +| `contextCompressionRequest` (3169) | N | ContextCompressionCoordinator, ContextCompressionService | +| `findContextCompressions` (3177) | N | ContextCompressionCoordinator | +| `claimContextCompression` (3186) | T | ContextCompressionCoordinator | +| `requireContextCompressionCommit` (3200) | N | ManagedExtensionRecordStore | +| `markContextCompressionSubmitted` (3217) | T | ContextCompressionCoordinator | +| `settleContextCompression` (3231) | T | ContextCompressionCoordinator | +| `retryContextCompressionPreparation` (3251) | T | ContextCompressionCoordinator | +| `awaitContextCompression` (3263) | T | ContextCompressionCoordinator | +| `completeCwdChangeOperation` (3289) | T | SessionLifecycleCoordinator | +| `failCwdChangeOperation` (3406) | T | SessionLifecycleCoordinator | +| `beginTaskCancelOperation` (3434) | T | ManagedTaskCancelService | +| `findDeliverableTaskCancels` (3539) | N | TaskCancelCoordinator | +| `findParkedOperations` (3564) | N | OperationReconciler | +| `settleParkedOperation` (3582) | T | AcceptanceEvidence, OperationReconciler | +| `turnInputAcceptance` (3641) | N | TurnInputEvidence | +| `renameAcceptance` (3703) | N | RenameEvidence | +| `settleParkedRename` (3733) | T | RenameEvidence | +| `settleTaskCancel` (3804) | T | TaskCancelCoordinator | + +### A9 AgentTurnExecution + +| Entry / overload lines | Transaction | Direct production callers | +| --------------------------------------- | ----------- | ------------------------- | +| `findDispatchable` (5627) | N | HarnessCoordinator | +| `findRuntimeRecoveryParked` (5639) | N | HarnessCoordinator | +| `claimTurn` (5654) | T | HarnessCoordinator | +| `renewTurn` (5675) | T | HarnessCoordinator | +| `releaseTurnLease` (5685) | T | HarnessCoordinator | +| `markTurnRecoveryBlocked` (5698) | T | HarnessCoordinator | +| `markTurnFailureRecovery` (5721) | T | HarnessCoordinator | +| `requestRecoveryRecheck` (5739) | T | HarnessCoordinator | +| `parkTurnForRuntimeRecovery` (5759) | T | HarnessCoordinator | +| `deferTurnRetry` (5779) | T | HarnessCoordinator | +| `scheduleTurnRetry` (5786) | T | HarnessCoordinator | +| `bindHarness` (5804) | T | HarnessCoordinator | +| `bindRecoveredHarness` (5831) | T | HarnessCoordinator | +| `markSubmissionAttempted` (5862) | T | HarnessCoordinator | +| `withdrawSubmissionAttempted` (5887) | T | HarnessCoordinator | +| `recordAdmission` (5904) | T | HarnessCoordinator | +| `recordRecoveryAdmission` (5948) | T | HarnessCoordinator | +| `retractContinuationOutput` (6009) | T | HarnessCoordinator | +| `retractHarnessTurnOutput` (6079, 6087) | T | HarnessCoordinator | +| `recordHarnessEvents` (6304) | T | HarnessCoordinator | +| `cancelUnadmittedTurn` (6461) | T | HarnessCoordinator | +| `cancelBeforeAdmission` (6505) | T | HarnessCoordinator | +| `failTurn` (6535) | T | HarnessCoordinator | +| `failWorktreeInitialTurns` (6541) | T | SessionWorktreeService | +| `failRecoveryBlockedTurn` (6562) | T | HarnessCoordinator | + +### Rows compatibility forwarding + +| Entry / overload lines | Transaction | Direct production callers | +| ---------------------- | ----------- | ------------------------------------------------------------------- | +| `readBinding` (7569) | N | WorkspaceFileStore, WorkspaceMigrationMain, WorkspaceMigrationStore | + +Inventory covers **136 distinct entry names / 153 declarations**, including two package-private CSI bridge methods and one public static mapper, excluding two constructors. + +### Worktree display decision and side effects + +The worktree display entries are NOT pure reads. `findSessionWorktree` / `findSessionWorktrees` call `SessionWorktreeStore.descriptor(s)` (107-145), and `listSessions` calls `descriptors` after its batched page SQL (4645). `verifiedDescriptor` (112-120) verifies filesystem identity and reads the current branch; on `ManagedAgentException` it calls `blockIdentity` (368-378), which opens an EXISTING `TransactionTemplate` with `PROPAGATION_REQUIRES_NEW`, changes READY to BLOCKED and increments revision. The separate transaction deliberately survives a caller's failed admission rollback. Final decision: exclude these three entry names from the first A1 pure-query slice and retain their original facade implementation until A6. In A6 they move to the existing AgentSessionQueries component as explicitly side-effecting compatibility display paths, reusing SessionWorktreeStore validation/quarantine and its existing transaction. Do not add readOnly, omit verification, copy quarantine code, remove the existing transaction, or add another worktree wrapper. + +The same existing worktree verification path appears in policy/execution admission through `requireCwdChangeRegistryFacts` (3894): `SessionWorktreeStore.requireReady` then `hasExecutionRegistryFacts`. Preserve worktree verification before the creator/binding facts check, including its persisted quarantine semantics. + +### Transaction and bridge exceptions + +- `findWorkspaceCreateReplay` (555) has no declaration; `replayWorkspaceSessionCommand` (570) has plain `@Transactional`. Both re-check replay digest, internal-team-lead identity, workspace binding and current read permission. Do not normalize the two transaction modes. +- `insertCsiSessionCommand` (582) is package-private, has no transaction annotation, explicitly requires an active transaction, and rejects journal event transport. `requireCsiRequest` (633) preserves the same transport isolation, locks Session plus runtime-request key and validates the CSI profile/request identity. The caller `WorkspaceCsiSessionCommands.create` (19-47) rejects an ambient transaction, starts a fresh 10-second TransactionTemplate, checks its original physical transaction connection, locks placement then retention, verifies registration, then calls both Store bridges on that connection. This caller uses the standalone `var sessions = new ManagedAgentStore(...)` construction path. +- `requireCsiRequest` is also called by `WorkspaceCsiRuntimeAccess.request` (183-190); preserve all pre-existing continuation/retention lock behavior in that caller. Keep its facade method/package visibility and the seven-argument standalone constructor available. +- `readBinding` (7569) is public static and directly used by `WorkspaceMigrationMain`, `WorkspaceMigrationStore` and `WorkspaceFileStore`; keep a facade forwarding entry even after moving actual mapping into rows. +- `requireMcpSettingsCommit` (1215) and `requireConfigurationCommit` (1266) are MANDATORY. `requireContextCompressionCommit` (3200) has NO annotation; it is called from `ManagedExtensionRecordStore` in an existing commit flow, and must not acquire an invented MANDATORY/REQUIRES_NEW wrapper. +- `findMessagePage` overloads (5234/5242) explicitly use managedAgentTransactionManager, readOnly true and REPEATABLE_READ. Ordinary Query entries are unannotated and must remain so. +- `deliverableRenames` (1933) is a transactionally mutating scan: it parks expired SUBMITTED claims and resets expired PREPARING ones before selecting work. `markRenameSubmitted` (1979) is a guarded write with no declarative transaction. Keep both in Commands and preserve the difference. +- `contextCompressionState` (3005) calls `ensureCompactionProjection` (3066); a missing projection opens a default TransactionTemplate with a DataSourceTransactionManager and replays journal rows. Batch `contextCompressionStates` (3020) may fall back to this write path and marks unavailable states. Both belong in A8, with the same programmatic transaction, rather than A1. +- `publishAfterCommit` (7489) publishes only after synchronization commit; if no synchronization exists it publishes immediately. Empty events do nothing. The EventJournal extraction must retain the standalone fallback and avoid replay/rollback notifications. + +### Actual lock families (do not replace them with one global order) + +- Row helper `requireSessionForUpdate` (7061/7066) locks the PUBLIC Session row first and reads/locks its event-stream counters only AFTER that lock. The list mapper and the strict single-session mapper have different missing-stream behavior. `requireTurnForUpdate` (7106) then locks a Turn where that path needs both; its NOT_FOUND mapping must survive extraction. +- Many admission/mutation/policy entrypoints begin `WorkspaceMigrationAdmission.lockTenant`, then Session/stream, then Turn/receipt/operation as required. MCP/extension idle rechecks (1340) specifically use fresh locking reads in this order: Session plan -> active Turn -> unsettled child-run record -> requested action records -> active operation/rename/receipt. Existing journal snapshots are the reason these may not become ordinary consistent reads. +- Lifecycle admission `beginLifecycle` (2695) locks migration tenant, conditionally retention tenant, then Session/stream; it preserves failed-operation replay/resume, authorizes state, checks idle journal, writes operation/fence/head and event in original order. `completeOperation` (4143) first applies the legacy CSI guard, then conditionally placement and bounded retention, then deletion lock either before or after Session according to lifecycle protocol, then operation claim. Do not normalize its branches. +- `ManagedLegacySessionGuard.requireLegacyMutation` itself performs the indexed CSI root locking read through `ManagedSessionStore.namedRootDeadlock`, with a 10-second query timeout. Some Turn lease paths use this guard plus a guarded UPDATE, while admission/terminal paths take a full Session/stream lock and then Turn. Retain that distinction; a generic row-helper transaction/lock would change semantics. +- Materialization and replay-floor advancement lock Session/stream before inspecting Turn evidence or updating coverage. Retraction repair `rebuildTurnText` (6172) locks stream coverage and message rows only after the enclosing Turn workflow has acquired its existing owner/epoch locks. + +### A1 dependency closure and boundaries + +The A1 ordinary subset needs JdbcTemplate, configured ObjectMapper, HarnessRules (approval-rule decoding), the construction-time immutable modelCatalog snapshot and Session/Turn mappers. WorktreeFiles and the three worktree display entries are deferred to the A6 -> AgentSessionQueries delivery. Shared row work is `readSession`, sessionMapper/sessionListMapper, turnMapper/turnSummaryMapper, readBinding, nullable/additive column helpers, StreamCounters and the existing locked Session/Turn readers; move each only when first actually needed, preserving strict-vs-list stream behavior and all schema projections. JSON decoding is only shared where a concrete row mapper needs it, not by introducing a generic Support/Context object. + +`findLatestTurns` selects by last turn.accepted event rather than latest gmt_create. The A6 `listSessions` migration must preserve tenant/actor grants, archive/title/cursor filters, limit+1 semantics, batched worktree metadata and validation/quarantine; keep query counts, ordering and byte representation. `findSessionById` is an existing cross-tenant lookup for runtime resolution; do not silently add tenant filtering or expose it as a new HTTP operation. + +Point read/display views can live in Queries; operation WORK SCANS remain with their state machine (rename -> Commands, materialization/replay floor -> Projection, compression/task cancel/parked -> ExecutionOperations, lifecycle dispatch -> LifecycleOperations, Turn dispatch/recovery -> TurnExecution). Read-only Session busy predicates shared across policies/commands/lifecycle should be given one narrow owner when first migrated; do not force a component to call the facade simply to reuse them. + +### Concrete one-way seams for later slices + +- A2 EventJournal owns appendEvent/appendEvents, event identity/source-key handling, eventMapper and after-commit publishing; it can depend on Rows, not on workflow components. It does not perform message materialization synchronously. +- A3 MessageProjection owns projection scan/materialization/replay-floor state and the `rebuildTurnText` repair helper. Its dependency on event reads is one-way toward EventJournal/Rows. Public `retractContinuationOutput` (6009) and both `retractHarnessTurnOutput` overloads (6079/6087) remain A9 workflow entries: owner/lease/epoch checks and event/identity deletion happen before A3 repair, in the same facade transaction. Do not give Projection a callback to TurnExecution or to the facade. +- A4 policy transactions have their own existing actor-scoped SET_MODEL/SET_APPROVAL replay protocol, using ManagedReceipts directly (4790 onward, 4954 onward). They do not call generic insertCommand/settleTurnCommand. Preserve those scopes and existing result JSON rather than rewriting them onto a new generic receipt flow. A4 may use Queries/Rows/EventJournal and existing definitions/skills; later Commands/Admissions use the policy checks in one direction. +- A5 Commands owns common `insertCommand` (7156/7163/7170), `settleTurnCommands` (2477), `settleTurnCommand` (2532), turn command operation inserts (2416), command replay and receipts. A9 recordAdmission/recordRecoveryAdmission/recordHarnessEvents and terminal paths call this settlement seam. A8 accepted-outcome reconciliation must use the same command/rename settlement semantics; no reverse callback from Commands to A8/A9. +- A6 Admissions depends on lower policy/command/event/row components plus existing definitions/skills/worktree components. It must preserve replay-before-new-resolution timing, creator/child scope, definition pinning and rollback behavior. +- A7 LifecycleOperations owns operation point reads, replay/claim/lease/retry/completion and the operation mapper. Current shared APIs are used by SessionLifecycleCoordinator plus ActionResponseCoordinator and TaskCancelCoordinator. Keep their facade routing stable. A8 calls A7's operation lookup/common claim primitives where needed: for example claimContextCompression (3186) writes its specialized CAS then reads findOperation; requireContextCompressionCommit (3200) checks the same operation through that lookup. The coordinator already chooses whether to call generic completion or specialized cwd/task-cancel settlement; A7 must NOT become a dispatcher calling A8/A9. +- A8 ExecutionOperations owns specialized cwd/compaction/task-cancel/unknown-acceptance state machines and their scans, with downward dependencies on Rows/Queries/Policies/Commands/EventJournal and A7's shared operation primitives. Generic claim/retry does not acquire a callback for specialized outcomes. +- A9 TurnExecution needs Rows/Queries, common Commands settlement, EventJournal and Projection repair. It has no reason to depend on LifecycleOperations merely because coordinators separately call lifecycle APIs. Preserve that separation rather than installing a cycle. +- Acyclic instance construction is an explicit acceptance test for each extraction, not a claim proven by these broad labels. In particular, shared predicate helpers such as hasOpenOperation/hasOpenExecutionOperation/hasPendingRenameDelivery and existing schema key encoders must not produce Policies -> Commands -> Policies or Commands -> Lifecycle -> Commands cycles; migrate the actual dependency closure and use the existing schema/receipt helpers directly where appropriate. No constructor function bundle, facade callback or mutable setter is needed. + +## Journal entry inventory + +Baseline: `e3c7a6d7efc6df6c58ffa8ddd9c42dbb0e1784c4` (2026-11-02). `M:` below refers to `packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java`, which has 3,931 lines. Other unqualified class filenames refer to the same persistence package; the controller is in server and the Hosted connector is in adapters. This inventory defines migration boundaries; it does not establish that subsequent slices are implemented or accepted. + +### 1. Decisions and boundaries + +- The implementation has 27 public business signatures under 22 names, all declared by the existing contracts `repository.ManagedSessionStore`. It also has three constructors, eight assembly setters holding nine references, 17 package-private methods, two package-private records and one lock-timeout constant. All are accounted for below. +- This Store has no `MANDATORY` method. Do not assign the overall design's `MANDATORY` requirement to a Journal entry that does not have it. Journal's explicit transaction exceptions are the two `READ_COMMITTED` history queries and the `NOT_SUPPORTED` initial-journal check, plus resource reads and package bridges without declarative transactions. +- New internal objects must not call the facade. Resource-row integrity checks and activation-row mapping needed by B1 move into `JournalStoreRows` with their first actual consumers, so B1 requires neither future B3/B5 components nor facade callbacks. Object reads, leases, verification-time updates and quarantine remain outside Rows. +- Preserve the public business interface, package entry points, `PublicationWriter`, `TerminalCandidate`, static lock helpers and constant on the facade. Exposing internal helpers within the package supports explicit typed dependencies; it must not introduce a generic Context or Support object. +- Preserve path-specific lock sequences. Ordinary heartbeats do not acquire tenant guards; CSI continuations lock the root before placement/retention guards; publication and snapshot paths have their own preceding legacy probes and caller-held locks. Do not replace these paths with one enforced global sequence. + +### 2. Transaction legend + +`T`: the original method has `@Transactional`. Preserve manager resolution through the class-level `@Qualifier("managedAgentTransactionManager")` (M:82); the method annotation does not name a manager explicitly. Propagation is `REQUIRED`, isolation `DEFAULT`, `readOnly=false`, with default timeout and rollback rules. Preserve the annotation on each overload; an existing self-call does not create another proxy boundary. + +`RO`: the same manager, `REQUIRED` / `DEFAULT` / `readOnly=true`. + +`RC`: the same manager, `REQUIRED` / `READ_COMMITTED` / `readOnly=true`. Internal calls to `restore` are currently self-calls and must not acquire a new transaction after extraction. + +`NS`: the same manager, `NOT_SUPPORTED` / `DEFAULT` / `readOnly=true`; suspend an ambient transaction without opening a new one. + +`N`: no declarative transaction. Extraction must not add a proxy or annotation. An existing caller determines whether a transaction is active. Standalone construction never automatically applies Spring method transactions. + +`P`: an `N` outer method with the programmatic owner-read flow described in section 6. + +Lock traces below retain existing order and branches. Repeated acquisition of an already-held lock is abbreviated, not authorized for removal. In lock columns, `P` means the placement tenant guard; `Tg` the output-publication retention tenant guard; `R` the session root; `H` the managed-session head; `D` the harness drain head; and `C` the CSI binding/slot/retirement/progress lock closure on the original connection. Bounded tenant waits remain five seconds. `namedRootDeadlock` continues to translate only its existing deadlock cases. + +### 3. Complete public business entry matrix + +| Original entry and location | Owner | Transaction | Existing principal locks and effects | Production callers | +| ---------------------------------------------------- | -------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `authorizeLifecycle` M:211 | B2 JournalLifecycleAuthorization | T | Credential → P → Tg → ensure R/event stream → placement → session row → lifecycle claim → H. With a kind, verify a live writer; without a kind, the DRAINING branch verifies identity/state under its existing rules. | ManagedSessionStoreController:102 | +| `authorizeOrdinary` M:394 | B2 | T | Credential → R → placement → ordinary claim/drain admission, including the legacy-close exception → config checks → H → writer. Do not add tenant guards. | Controller:63,93 | +| `approvalSelection` M:425 | B2 | T | Ordinary authorization followed by `ma_session FOR UPDATE`; returns model/approval/permission selection. This is not a pure query. | Controller:72 | +| `acquireWriter` M:629,635, four/five arguments | B1 JournalWriterLifecycle | T/T | The lifecycle-authority branch first takes P→Tg→R→claim. The common path takes P→Tg→CSI/R→physical-open→ensure R/event stream→placement/control storage→materialize D→claim/admission→session row→H. Preserve create, renewal and generation changes; cold CSI takeover uses binding and history proof. | Controller:113; manually assembled journal fixtures also exercise these overloads | +| `renewWriter` M:807,813, four/five arguments | B1 | T/T | Preserve authority guards, then placement→CSI continuation, which is R for ordinary sessions and R→P→Tg→C for pinned sessions→H. Ordinary heartbeat does not add Tg. Recheck writer and CSI leases; activation renewal also updates the journal transaction/head. | Controller:124 | +| `sealWriter` M:891,897, four/five arguments | B1 | T/T | Authority guards→placement→lockCsiOriginal, which rejects ordinary sealing of private CSI→R/H. An identical sealed identity replays early. Writer expiration is allowed on this path; identity replacement is not. | Controller:135 | +| `quiesceOwnership` M:938 | B1 | T | Credential placement identity→current placement→R/H→writer→update session to QUIESCING. | Controller:152 | +| `releaseOwnership` M:952 | B1 | T | P→Tg→R→existing receipt/resource locking validation and early replay. A new release performs placement/claim→H→writer/proof→resource/record/ref→SEALED head→RELEASED session, in one transaction. | Controller:144 | +| `blockRecovery` M:1051 | B1 | T | Placement→CSI continuation/R→H. Writer/CSI-current checks precede replay and repeat before the recovery update. | Controller:162 | +| `commit` M:1112,1118, four/five arguments | B6 JournalCommit | T/T | Authority guards→P→placement→CSI/R/C/optional H→command lookup. Duplicates return early. New commits perform physical-open→R/H→resource/projection/journal/head in one transaction; see section 7. | Controller:173; ToolPublicationAdmissionStore:397,411 | +| `restore` M:1485 | B3 JournalHistoryQueries | RO | Plain head read→scope/storage-policy/read grant→activation projection; do not add FOR UPDATE. | Controller:188; ToolPublicationAdmissionStore:282; ToolPublicationDataStore:950,2229; ManagedNativeJournalSnapshots:205,237,249,255,291 | +| `transactions` M:1560 | B3 | RO | Plain head/read grant→revision/byte-size selection→bounded payload read. Preserve continuity, integrity and workspace checks. | Controller:209; ManagedNativeJournalSnapshots:129,131,132; ToolPublicationStore:559,561,562,566,587 | +| `recentTransactions` M:1644 | B3 | RC | Initial restore/prefix validation→two-stage reverse pagination→another restore before return. Do not change to repeatable read. | Controller:221 | +| `commandTransaction` M:1700 | B3 | RC | Initial restore/prefix→command hash/size/row→another restore before return. | Controller:232 | +| `hasPristineInitialJournal` M:1759 | B3 | NS | Validate the complete initial journal/header/resources and require equal initial/final heads. Do not acquire a writer or change fencing; preserve exception-to-false behavior. | SessionRecoveryService:123 | +| `readResource` M:1920 | B5 JournalResourceReads | N | Credential/head/read grant→retention read lease→resource FOR UPDATE→metadata/digest verification→last_verified_at update→optional object IO/quarantine→lease check/close. Do not move into HistoryQueries. | ManagedToolResultProjector:176,318; ToolPublicationStore:630; ChildResultRelay:469,474,568,573,1161,1202,1212; SessionLifecycleCoordinator:937 | +| `readOwnerResource` M:1968 | B5 | P | Reject an ambient transaction. Non-CSI falls back to readResource. CSI uses short admission transaction→fetch on an independent connection→short revalidation/receipt/lease completion transaction; see section 6. | Controller:269 | +| `publishAgentInstructions` M:2328 | B4 JournalResourceWrites | T | Scope→legacy CSI index locking probe→resource FOR UPDATE/insert or idempotent validation. Do not introduce writer-lease requirements. | QwenHostedHarnessConnector:2111 | +| `publishAgentSkills` M:2341 | B4 | T | The same path; retain the original AgentDefinitionExtensions.skillsBytes encoding. | QwenHostedHarnessConnector:2121,2145 | +| `publishInheritedExtensionResource` M:2350 | B4 | T | Reject snapshot resources→legacy probe→kind/schema/bytes/digest→resource FOR UPDATE/insert or idempotent validation. | QwenHostedHarnessConnector:2044,2049,2052 | +| `publishToolResult` M:2410,2416, four/five arguments | B4 | T/T | Authority guards→byte validation→physical P→Tg→placement→CSI continuation/R→H→no-lifecycle-claim, including v2 rejection even with authority→writer/native head→resource→current fence→insert/replay→CSI time. | Controller:198 | +| `hasLiveWriter` M:3459 | B1 | T | Direct H FOR UPDATE, then database time. Do not add root/tenant locks or change this to read-only. | SessionLifecycleCoordinator:602,815,846 | + +Caller discovery excludes similarly named calls that do not target this Store: QwenHostedHarnessConnector:273 calls the SDK client's releaseOwnership, and :889 calls a builder's approvalSelection. ToolPublicationAdmissionStore:295,342,361 and ToolPublicationStore:493 call `data.readResource`, not this Store. + +### 4. Complete package compatibility entry matrix + +| Original entry | Final implementation owner | Transaction/connection contract | Callers and preserved details | +| ---------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `usesDataSource` M:522 | Facade or identity-preserving Rows delegate | N; `source != null && jdbc.getDataSource() == source` | ToolPublicationDataStore:96–99, ToolPublicationAdmissionStore:53–56, WorkspaceCsiApplicationHistory:60–61. Do not replace identity with equals, unwrapping or matching connection-pool URLs. | +| `lockSnapshotWriter` M:577 | B1 WriterLifecycle | N; caller already owns the transaction | ManagedNativeJournalSnapshots:89,108,179,200. Preserve P→lockPublicationWriter→ordinary admission→H→restore mapping and the existing PublicationWriter/RestoreHead value semantics. | +| `lockPublicationWriter` M:598 | B1 WriterLifecycle | N; caller already owns the transaction | ToolPublicationStore:327; ToolPublicationDataStore:1121,1181; ToolPublicationAdmissionStore:374. Legacy index probe first, then physical P→Tg→placement→CSI/R→H→writer/native head. Account for locks already held by each caller. | +| `readSnapshotResource` M:1925 | B5 ResourceReads | N; snapshots already supply an isolated programmatic transaction | ManagedNativeJournalSnapshots:520. updateVerification=false means plain resource SELECT, no last_verified_at update and no quarantine; retain retention-lease acquisition/check/close. | +| `requireCsiApplicationCredential` M:2581 | B1 Fencing | N; the authority's same physical connection must be bound to a transaction on this jdbc DataSource | WorkspaceCsiApplicationStore:105,119,137,184,213,230. Preserve authority.requireConnection, DataSourceUtils.isConnectionTransactional(target, same datasource), then credential→isolated journal→R/H→CSI/token. | +| `requireCsiHistoricalCredential` M:2601 | B1 Fencing | N; no SQL or locks; validate credential and retained token hash | WorkspaceCsiApplicationHistory:92, inside its existing read-only repeatable-read snapshot. Do not introduce live-writer or lease checks. | +| `preflightCsiTerminal` M:2609 | B6 Commit | N; same authority connection, PreCut, same-DataSource bindings | WorkspaceCsiApplicationStore:125,144. Strict bytes→credential/isolation→commit validation→R/H→writer/head→terminalPreflight(target). Return the existing TerminalCandidate type. | +| `commitCsiTerminal` M:2636 | B6 Commit | N; same authority connection, Stage.FROZEN | WorkspaceCsiApplicationStore:192. Frozen bytes/digest match→repeated preflight→resource fence→journal fence→SEALED-head CAS→final time check. Do not call ordinary commit or add transport notification. | +| `validateCsiTerminalBytes` M:2695 | B6 Commit static validation, forwarded by facade | N; no SQL or IO | WorkspaceCsiApplicationStore:336. Preserve strict JSON settings, exactBody and the shared validation rules. | +| `lockCsiOriginal` M:3162 | B1 Fencing | N; pinned CSI requires its original transactional connection; ordinary standalone may still return null | ToolPublicationAdmissionStore:371. Preserve its surrounding original-settled-result and tenant lock ordering. | +| `toolResultLimit` M:2507 | Rows' pure resource-row constraint, with facade static forwarding | N; no SQL or IO | Existing package static entry; fixed kind limits also used by verifyStoredResource. | +| `commandKeyHash` M:3832 | Rows' key encoding, with facade static forwarding | N; pure bytes | ManagedAgentStore:3689. Preserve operation + NUL + commandId, UTF-8 and SHA256. | +| `journalCorrupt` M:3861 | Rows' row-integrity error, with facade static forwarding | N | ToolPublicationStore:535,544,557,563. Preserve HTTP status, code and message. | +| `skipContended` M:301 | B1 Fencing static helper | Execute the caller-supplied TransactionTemplate; suppress only tenant_guard_contended | ManagedProjectionResourceCollector:42; SessionResourceCollectionCollector:188. Do not force REQUIRES_NEW. | +| `lockBounded` M:314 | B1 Fencing static helper | No transaction of its own; execute the existing Runnable | ManagedToolResultStore, ManagedProjectionResourceCollector, ToolPublicationDataStore/AdmissionStore/Store, SessionResourceCollectionCollector, ManagedAgentStore and ManagedUserMessageStore. | +| `lockBoundedResult` M:321 | B1 Fencing static helper | The same | ToolPublicationRetentionStore:156. Translate only PessimisticLockingFailureException / QueryTimeoutException to tenant_guard_contended. | +| `namedRootDeadlock` M:341 | B1 Fencing static helper | The same | ToolPublicationRetentionStore:112; ManagedLegacySessionGuard:14. Translate only DeadlockLoserDataAccessException or MySQL 1213 in the cause chain; preserve ordinary root-wait failures. | + +Retain the facade's existing package records `PublicationWriter` (M:571) and `TerminalCandidate` (M:2577), and `GUARD_ACQUIRE_TIMEOUT_SECONDS = 5` (M:111). Existing external root/retention code continues to call the facade's static helpers. Static compatibility forwarding is not a new instance dependency or facade callback. + +### 5. B0 assembly facts and migration requirements + +| Original setter | References and current Spring condition | Standalone default | Constructor migration requirement | +| -------------------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `configureHarnessRules` M:87 | HarnessRules; required @Autowired | null | Required Spring parameter; retain standalone null instead of adding a SdkHarnessRules default. | +| `configureReaderVersion` M:92 | StorageReadPolicy; required @Autowired | StorageReadPolicy.inProcess() | Required Spring parameter; standalone remains inProcess. | +| `setLifecycleExecution` M:205 | WorkspaceExecutionStore + SessionQueryStore; one two-argument `@Autowired(required=false)` method | null/null | Previously, either missing bean skipped the entire setter. Resolve optional constructor parameters and normalize both to null if either is absent; do not partially enable the pair or fail startup. | +| `setPublicationObjects` M:527 | Optional ToolPublicationObjectStore | null | Optional construction parameter; do not create default object storage. | +| `setToolResults` M:532 | Optional ManagedToolResultStore | null | Optional construction parameter; preserve whether capture is enabled. | +| `setJournalEventTransport` M:539 | Optional JournalEventTransport; no bean when disabled | null | Optional construction parameter and byte-identical disabled behavior. CSI isolation continues to reject a non-null transport. | +| `setCredentials` M:546 | WriterCredentialPolicy; required @Autowired | WriterCredentialPolicy.unbound() | Missing Spring dependency still fails closed; standalone does not gain bound credentials. | +| `setCsiBindings` M:551 | Optional RuntimeBindingRepository | null | Retain only instanceof JdbcRuntimeBindingRepository with `usesDataSource(jdbc.getDataSource())`; mismatches still become null, without a new startup failure. | + +All constructors and these eight setters originally have no declarative transaction (`N`). The three existing constructors at M:177/181/187 are `(JdbcTemplate)`, which creates extensionRecords; `(JdbcTemplate, extensionRecords)`, which uses actions=null; and Spring's `(qualified JdbcTemplate, extensionRecords, actions)`, whose three dependencies are required. Their constructor chain creates one DataSourceTransactionManager for the existing ToolPublicationRetentionStore and ownerReads template, with ownerReads timeout=10. Preserve both programmatic transaction objects and their configuration; do not replace them with new Spring beans or an additional global transaction-manager dependency. + +There are only two production manual callers: `server/.../maintenance/WorkspaceCsiRuntimeMain:106–107`, using bare construction plus same-DataSource CSI bindings, and `WorkspaceCsiWorkerAckMain:53–54`, using bare construction plus SdkHarnessRules. Their defaults and use cases differ; do not replace both with a common fixture that enables additional dependencies. + +The central manual-test path is `VerificationFixtures.configure(ManagedSessionStore):40–46`, which currently assigns only HarnessRules and StorageReadPolicy. B0 replaces it with an explicit construction factory, as with Agent wiring; do not mutate final fields reflectively, rebuild an already-created instance, or merely rename setters. The current scan covers 52 files and 150 construction points, consisting of two production and 148 verification calls, plus 16 explicit setter calls in 11 files. Check each constructor argument, original setter condition and fixture construction order during migration. Refresh the inventory during implementation: eliminating old assembly calls proves only their removal, not behavioral correctness. + +No current explicit `@Bean` factory returning ManagedSessionStore was found. `ToolPublicationAsyncVerificationTest:317` does have `.withBean(ManagedSessionStore.class, () -> fixture.sessions)`, in a context that also supplies HarnessRules, StorageReadPolicy, credentials, bindings and object storage. Migrate the constructed fixture rather than relying on postprocessing to apply removed setters. Add an actual @Bean factory regression in B0 to prevent recurrence of #16061's missing-dependency failure. + +Dependency inspection: ManagedToolResultStore:40 depends on EventProjectionStore, implemented by the Agent facade; WorkspaceExecutionStore:38 depends on jdbc, transaction manager, WorkspaceStorageGuard and settings; LiteTopicEventTransport:45 depends only on producer/provider/topic. These edges do not justify adding a new lazy cycle for B0. Preserve the existing Agent→records and skills→records lazy edges. Full Spring context, transaction-proxy and actual factory tests remain required. + +### 6. Actual resource-read effects and transactions + +#### Ordinary and snapshot reads + +The shared implementation at M:1930 first validates scope, credential, resource identity, head and read grant. The read grant itself reads retention state and database time. `outputRetention.read` (ToolPublicationRetentionStore:379) uses its existing TransactionTemplate, with default REQUIRED/DEFAULT/readOnly=false, to execute Tg→R→H through lockGenericSession and insert a 120-second `ma_read_lease`. It joins an existing ambient transaction. The outer resource method has no NOT_SUPPORTED annotation; do not add one. + +Ordinary reads use findResource with lock=true, verify the row, update last_verified_at (M:1953), and read an object stream for TOOL_PUBLICATION. Length/digest failures quarantine capture/resource state. Snapshot reads use lock=false, do not update verification time and do not quarantine, but still insert, conditionally renew, check and close retention leases. Neither belongs in a pure-query component. `verifyStoredResource` itself does not perform those effects; see section 8. + +The snapshot caller's existing isolated template at `ManagedNativeJournalSnapshots:56–61` uses REQUIRES_NEW + READ_COMMITTED. Its admission/basis (:89), build (:92), lease close (:94) and resource read (:520) retain that template. **Preserve this existing REQUIRES_NEW behavior.** The prohibition on introducing REQUIRES_NEW forbids additional boundaries; it does not authorize deleting this one or copying it into the new components. + +#### Owner reads + +M:1978 rejects both `isActualTransactionActive()` and an already-bound jdbc DataSource resource. Non-CSI traffic at M:1995 still falls back to the ordinary reader. The CSI path remains: + +1. The ownerReads template at M:197–200 uses the same-DataSource manager, REQUIRED/DEFAULT/readOnly=false and a ten-second timeout. Admission runs lockCsiContinuation→original admission or DRAINING checkpoint-continuation check→R/H→read grant→resource metadata FOR UPDATE. Require inline storage, construct identity evidence, recheck CSI/read lease, insert the OPEN CSI_RESOURCE_READ receipt, recheck again, insert ma_read_lease with generation=0, and retain the receipt. +2. After leaving that transaction, fetchNativeResource at M:2172 obtains a separate DataSource connection to SELECT the complete inline bytes with a ten-second statement timeout. This independent reading stage must not hold the admission transaction or reuse its physical connection. +3. finishNativeRead at M:2199 opens another short ownerReads transaction. Relock continuation/original→R/H→read receipt FOR UPDATE; changed identity/receipt is rejected. Unknown fetch failure records UNKNOWN / jdbc_fetch_unknown and retains the lease under the existing path. On a returned fetch, lock/check the read lease, lock resource metadata, compare row identity/bytes/hash, recheck CSI/read grant, update verification time, record RETURNED with verified or the refusal code, and delete the lease only after the final recheck. Existing early fencing failures roll back rather than forcing settlement; do not replace this with unconditional finally cleanup. +4. A RuntimeException/Error during fetch attempts completion and attaches completion failure as suppressed before rethrowing the original error. Do not wrap owner IO in a large transaction or collapse repeated current-time/read-grant checks. + +### 7. Commit atomicity, CSI rechecks and notification + +Ordinary/lifecycle commit at M:1118–1459 retains one complete entry transaction. Command replay at M:1176–1214 returns before new resource/projection writes. CSI replay first validates the current writer, activation replay and current time; ordinary replay still rejects a v2 lifecycle claim. Duplicates emit no event. + +A new commit validates the head/generation/lease, performs native preflight when required for commitFileHistory, toolIntent or commitCheckpoint, then reads database time again to recheck the writer (M:1228–1245). `commitResources` at M:2809 executes its supplied writeFence after reading each existing resource and before writing. This is an existing call-scoped callback and may remain; it must not become a constructor-supplied collection of functions. + +The same transaction then applies the declared-resource census, checkpoint/history/Hosted closure validation, HostedCleanupReferences, lifecycle-dispatch authorization at M:1313 using the paired execution/session dependencies, extensionRecords.apply/applyNative, CSI acceptCommit plus another writer check at M:1343–1367, and actions.apply. requireCsiWriterCurrent runs before journal INSERT (M:1378) and again before head UPDATE (M:1421), followed by tool-result capture and ManagedUserMessageStore.capture. Resource, projection, journal and head writes must not acquire separate commits. + +`publishJournalAfterCommit` at M:1466 is called only at the end of a new commit (M:1455). Null transport does nothing. Without synchronization, standalone operation publishes immediately; with synchronization, register afterCommit. Rollbacks and replays do not notify. CSI continuation/terminal requires isolated journals through requireCsiJournalIsolation (M:3245), rejecting transport, so terminal commit has no notification. Do not add one to unify commit paths. + +The three connection-aware CSI terminal bridges use the existing ten-second transaction created by `WorkspaceCsiApplicationStore.tx:402–410` and its physical connection's bounded proxy. Preserve the authority's connection identity, DataSourceUtils.getTargetConnection and isConnectionTransactional checks; a new connection, same-URL check or helper-owned transaction is not equivalent. Preflight also verifies same-DataSource JdbcRuntimeBindingRepository. commitCsiTerminal compares frozen bytes, repeats preflight, executes authority-time/writer fencing for every resource, fences again before journal INSERT and SEALED-head CAS, and checks authority time before returning. Preserve TerminalCandidate's request, proof and token-hash values. + +### 8. Shared closure and one-way dependencies + +`JournalStoreRows` is limited to existing row access, mapping, integrity checks required when reading rows, fixed key/byte constraints, and the explicitly listed narrow row initialization below. It is not purely read-only and must not become generic business Support. + +| Original method | First actual consumer | SQL/effects | Migration decision | +| ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| headMapper/transactionMapper/resourceMapper and HeadRow/TransactionRow/ResourceRow | B1 writer heads and release receipts | Row mapping; no business-state writes. | Rows. | +| findHeadForUpdate/requireHeadForUpdate M:3119/3359; requireHeartbeatHead M:3140; requireHead M:3367; databaseNow M:3474 | B1 | Preserve R→H and the live read for the first pair; heartbeat locks only H; plain head takes no lock; databaseNow is SELECT. | Rows. A shared helper must neither add nor remove locks. | +| findResource M:3393/3397; requireResourceScope M:3787; requireTransactionWorkspace M:3798 | B1 release receipt | Default FOR UPDATE; only explicit false performs a plain read. Scope checks do not write. | Rows; preserve every path's lock argument. | +| verifyStoredResource M:3575 | B1 releaseOwnership M:997 | State/length/SHA256/metadata checks. TOOL_PUBLICATION uses SELECT COUNT to validate matching capture metadata; inline managed-tool-outcome uses SELECT COUNT to check quarantine. **No UPDATE, object IO, lease operation or quarantine write.** | Move with B1 into Rows' resource-row integrity boundary; B3/B4/B5/B6 reuse it without copies. | +| toolResultLimit M:2507 | verifyStoredResource at M:3618 | Pure kind-to-fixed-limit switch; no SQL or IO. | The same boundary, retaining the facade's static entry. | +| activationLeaseRow M:1512, ActivationLeaseRow and restoreHead M:1495 | B1 renewWriter M:863 and snapshot bridge M:591 | SELECT, strict JSON/digest and head/activation-projection consistency checks; no update, IO or lease operation. | Move into Rows with B1; public history entries move later in B3. | +| requireHeadScope M:3742 | B1 | Scope/state/counter/digest checks and StorageReadPolicy.requireReadable; no SQL writes. | Rows' row-validity boundary. Preserve when the reader policy is consulted; do not precompute it at construction. | +| transactionRow M:3409 and storedTransaction M:3434 | B3 or B6, when first needed | Mapping, byte/hash verification and Base64 encoding; no writes. | Rows; do not build an unused framework ahead of the consumer. | +| storedResource M:2520 | B6 commit closure reader | findResource(FOR UPDATE), state/scope/integrity checks and DTO mapping; no object IO or lease. | Move into Rows when B6 needs it. Do not call B5's public readResource and introduce extra leases, verification updates or quarantine. | +| ensureSessionRoot M:3818 | B1 acquire and B2 lifecycle | R lock plus INSERT IGNORE into ma_event_stream. | Explicitly place this narrow initialization helper in Rows, solely for existing acquire/lifecycle paths. Preserve its call timing, R lock and event-stream INSERT. Do not generalize mutations or make B2 call back into the facade or WriterLifecycle. | + +B1 Fencing owns credentials, writer/read grants, placement, CSI continuation/current-time/lease/history-owner checks and lock-error mapping. `checkLifecycleWriter` (M:266), `requireNoLifecycleClaim` (M:482), `requireOrdinaryAdmission` (M:490), and lifecycleFenced/protocolFenced (M:498/510) are shared low-level gates used by writer/resource/commit paths and move into Fencing with their first consumers. B2 LifecycleAuthorization owns the three business authorization entries and the commit policy that authorizes a new lifecycle effect for dispatch; it does not call back into WriterLifecycle. ensureSessionRoot is explicitly assigned to Rows' narrow initialization boundary for its two existing consumers. Do not create Support or move writer state machines there. + +The final instance dependency graph is: Rows at the bottom; Fencing→Rows; WriterLifecycle→Rows/Fencing; LifecycleAuthorization→Rows/Fencing plus the existing paired execution/session dependencies; HistoryQueries→Rows/Fencing; ResourceWrites→Rows/Fencing; ResourceReads→Rows/Fencing plus existing retention/object storage; Commit→Rows/Fencing/LifecycleAuthorization/ResourceWrites plus existing extensionRecords/actions/toolResults/transport. Commit's reader callback calls Rows.storedResource directly; writeFence retains its existing call-scoped form. The facade constructs each new object once with final fields. Do not pass the facade or introduce Spring beans. Existing external static lock calls through facade compatibility symbols do not introduce instance callbacks. + +commitResources belongs to B4's commit-internal resource writing boundary. Share ValidatedResource through a narrow package-visible value type, including an equivalent nested type if appropriate, without changing its shape. validateCommit remains B6 protocol/full-commit validation; the shared resource value type does not justify moving it early into generic Rows. During B4, the existing facade commit calls ResourceWrites for the complete resource closure; ResourceWrites must not call back into the facade for queries or locks. diff --git a/docs/design/2026-11-02-managed-store-decomposition.zh-CN.md b/docs/design/2026-11-02-managed-store-decomposition.zh-CN.md new file mode 100644 index 00000000000..9379e65dca0 --- /dev/null +++ b/docs/design/2026-11-02-managed-store-decomposition.zh-CN.md @@ -0,0 +1,551 @@ +# ManagedAgentStore 与 ManagedSessionStore 拆分设计 + +[English](2026-11-02-managed-store-decomposition.md) | [简体中文](2026-11-02-managed-store-decomposition.zh-CN.md) + +## 状态与范围 + +本文是已接受 Store 拆分方案的实施契约。基线为 main `e3c7a6d7efc6df6c58ffa8ddd9c42dbb0e1784c4`。R0([#16061](https://github.com/QwenLM/qwen-code/pull/16061))已作为 `1bdad7d4b7949a6f8370c95965ec55901e75f67a` 合入;R1 准备开始时,其 [full gate](https://github.com/QwenLM/qwen-code/actions/runs/41322812090) 仍在运行。合并和验收是两份独立证据。[#16029](https://github.com/QwenLM/qwen-code/pull/16029) 已合入,没有额外前置工作。 + +R1 交付完整双语契约、下文入口/事务/调用者清单及跨文件锁检查。A1–A9 与 B0–B6 现在在实现分支中一并落地,保留下文逐阶段归属和验收清单。两个 facade 保持稳定,实现仍位于 persistence 内。实现与验收分开记录:完整改动必须通过 full Managed Agent gate 才能验收。 + +相关设计:[结构评审](2026-10-31-managed-agent-structure-review.zh-CN.md)、[Agent 构造装配](2026-11-01-managed-agent-store-wiring.zh-CN.md)、[Maven 边界](2026-10-29-managed-agent-maven-modules.zh-CN.md)。 + +## 问题与决策 + +两个 Store 把事务入口、SQL、可变业务状态和多个不同的操作状态机混在一起。按方法名称搬移会掩盖副作用,也可能使原有单文件锁顺序检查失效。保留稳定 facade 和明确组件归属,使整个提取过程中的事务行为和调用者可以逐项评审。 + +保留公共业务接口、DTO、异常映射及包可见兼容桥接。新增协作者均为同包、包可见的 `final` 类,由 facade 构造一次。不增加 Spring Bean、Maven 模块、公开业务接口、可变依赖装配或通用 Context/Support 容器。已有 worktree、definition 和 skills 组件继续承担原职责。 + +组件通过明确类型单向依赖。不为绕开回环新增 facade 回调或构造函数集合。现有 lazy Spring 回环、调用内 fencing 回调和兼容值类型保持原语义。提取需要共享规则时,随首个实际消费者迁移完整依赖闭包,不提前建设未使用的 helper 框架。 + +## Agent 职责归属 + +| 切片 | 组件 | 职责 | +| ---- | -------------------------- | ------------------------------------------------------------------------------------- | +| A1 | `AgentSessionQueries` | 普通 Session/Turn 读取、分页、批量摘要、worktree 描述及生命周期展示视图 | +| A2 | `AgentEventJournal` | 公共事件写入、序号与 identity、去重、事件读取及提交通知 | +| A3 | `AgentMessageProjection` | 物化、消息分页、覆盖进度、replay floor 及撤回后的修复 | +| A4 | `AgentSessionPolicies` | model、approval、MCP、extension 的准入与配置提交约束 | +| A5 | `AgentSessionCommands` | Turn 输入/取消命令、rename、一般 Session mutation、命令回执与结算 | +| A6 | `AgentSessionAdmissions` | 普通/workspace/child/内部/CSI Session 准入和重放;definition、skills 与 worktree 准备 | +| A7 | `AgentLifecycleOperations` | close/archive/delete/unarchive、投递扫描及共用 operation claim/retry/completion | +| A8 | `AgentExecutionOperations` | cwd、context compression、task cancel 和接受结果未知的结算,含 compression 投影补写 | +| A9 | `AgentTurnExecution` | dispatch、租约、Harness admission/events、恢复等待、重试及 Turn 终态 | + +`AgentStoreRows` 持有共享 Session/Turn 行映射与加锁读取。Commands 持有共享回执/结算,EventJournal 持有事件 identity 和通知,MessageProjection 持有投影修复。高层准入、生命周期和执行工作流依赖这些明确的下层协作者。操作扫描与对应状态机一起迁移。 + +Queries 不能成为每个 writer 都依赖的另一个 facade。writer 通过 Rows 共享行访问。`contextCompressionState(s)` 会补写投影,归 ExecutionOperations。`deliverableRenames` 会更新投递状态,归 Commands。保留 `markRenameSubmitted` 等写入口原本没有事务注解的行为。 + +worktree 展示入口 `findSessionWorktree(s)` 和 `listSessions` 会通过已有 `SessionWorktreeStore` 校验及其 `REQUIRES_NEW` 隔离事务持久化 BLOCKED。这三个名称不进入 A1 的纯查询首片;完整实现保留到 A6 将 worktree 协作迁入既定 Queries 展示路径。明确该路径具有副作用,复用现有 worktree 组件,不新增同用途包装或事务。A1 的边界小于最终 Queries 职责。 + +## Journal 职责归属 + +| 切片 | 组件 | 职责 | +| ---- | ------------------------------- | -------------------------------------------------------------------------- | +| B0 | 现有 facade 构造装配 | 将八个注入 setter 涉及的九个引用改为构造确定 | +| B1 | `JournalWriterLifecycle` | acquire、renew、seal、quiesce、release、block recovery 及 live-writer 检查 | +| B2 | `JournalLifecycleAuthorization` | ordinary/lifecycle 授权、approval selection 及生命周期派发授权 | +| B3 | `JournalHistoryQueries` | restore、分页/近期事务、command transaction 查询及初始 journal 完整性 | +| B4 | `JournalResourceWrites` | instructions、skills、继承资源、tool result 发布及 commit 内资源写入 | +| B5 | `JournalResourceReads` | 普通/snapshot/owner 读取、验证更新、隔离、读取租约与回执 | +| B6 | `JournalCommit` | 普通/lifecycle commit、CSI terminal 预检/提交、关联投影与通知 | + +`JournalStoreRows` 持有共享行访问/映射,以及解释这些行所需的完整性检查。B1 已需要 activation/head 检查和资源回执校验:`activationLeaseRow`、`restoreHead`、`verifyStoredResource` 及其 `toolResultLimit` 依赖随首个消费者迁移。这些校验可以进行只读 capture/quarantine 查询,但不读取对象流、不获取读取租约、不隔离对象,也不更新验证时间戳;这些副作用留在 ResourceReads。其他 helper 仅在实际使用时迁移。 + +`JournalFencing` 持有凭据、writer 和 CSI fencing,以及有界锁等待错误映射。检查保留在原有的小粒度调用点。WriterLifecycle、LifecycleAuthorization 和资源操作使用 Rows/Fencing;Commit 依赖实际调用的明确资源/授权组件。不让 WriterLifecycle 经 facade 回调 HistoryQueries 或 ResourceReads 来倒置依赖图。 + +现有 `ensureSessionRoot` 同时被 writer 获取和生命周期授权使用。B1 首次需要时,将此明确且窄的 root 初始化放入 Rows,保留 root 锁及 event-stream INSERT。因此 Rows 是行访问边界,不是只读服务;不把 writer 或 operation 状态机放进去。 + +## 事务、锁与原子性 + +每个 facade 入口保留原 transaction manager、传播方式、隔离级别、readOnly 及无注解行为,包括 `MANDATORY`、`NOT_SUPPORTED` 和 `READ_COMMITTED`。普通协作者不创建事务代理,不引入 `REQUIRES_NEW`。原程序式事务的位置和超时保持不变。 + +同时锁 Session 和 Turn 的路径继续 Session 在前。placement、retention、root、head 保留各路径的现有顺序。CSI continuation 例外和有界锁等待/死锁映射单独保留,不引入新的统一全局顺序。 + +资源变更、extension/action/message 投影、journal transaction 和 head 更新原子提交。保留重复提交返回位置、writer/CSI 复核、配置读取时点和通知时点。回滚和重放不新增通知,无同步上下文的 standalone 行为不变。 + +资源读取不是纯查询。普通和 snapshot 读取保留各自的验证/隔离行为及 retention 租约。owner read 继续拒绝环境事务,保持短事务准入、对象读取、短事务复核/回执/收尾。compression 投影补写保留程序式事务。 + +保留已有外部 `REQUIRES_NEW` 边界:worktree 隔离及 snapshot 调用者的独立 `READ_COMMITTED` 模板。禁止的是新增事务边界,不是删除调用者已经依赖的边界。 + +提取过程中不改变 SQL、摘要、JSON 或协议字节、schema、HTTP/OpenAPI 契约及算法。 + +## 构造装配与兼容 + +B0 在构造时解析必需依赖,并保留 standalone 默认值。可选 lifecycle 双依赖任一缺失时仍两者均不启用。CSI bindings 仍只接受同一数据源上的 JDBC repository。两个维护入口、手工夹具和工厂创建的 Spring Bean 一起迁移。验证必需/可选依赖缺失、真实 lazy 回环、代理身份、实际 `@Bean` 工厂及 standalone 构造。 + +publication/snapshot 方法、`PublicationWriter`、`TerminalCandidate`、同数据源检查和已有静态锁工具继续从原 facade 提供。CSI terminal 凭据检查、预检和提交必须使用调用方授权绑定的同一物理连接。Agent CSI 准入/凭据桥接及静态 binding 映射也保持兼容。精确桥接归属见入口清单。 + +## 锁检查器实现 + +保留基于 JDK 语法树的检查及有界解析模型。加载 facade 与同包的 `Agent*.java` 或 `Journal*.java` 协作类族,不依赖手工逐类更新的允许列表。其他 persistence 文件保留已有边界扫描。新增包含 SQL 的文件仍须显式改变精确固定的 sibling 集合。 + +跨选定文件按 owner、名称和参数类型采集成员。各文件的 import 和常量环境独立;共享常量使用限定名,避免一个文件覆盖另一文件的 SQL 文本。每个选定源文件及嵌套类的解析成员都与编译后的类交叉核对。 + +只追踪引用为 final、声明类型是已解析的具体 final 类的字段接收者。字段类型按声明它的类解析。支持裸字段及显式 `this.field`;参数/局部变量遮蔽裸字段时不能遮蔽显式字段访问。外部导入类型不能借用同名已解析协作类的成员或 SQL 常量。不可解析、多态、重载歧义、泛型/局部类和延迟执行体只要候选涉及 Session 锁或 Turn 行,继续按失败处理。不通过扩大歧义豁免或删除集合断言让搬移后的代码通过。 + +保留现有 facade 入口集合、事务检查、sibling 边界集合,以及歧义/延迟执行体断言。入口集合按所属 facade 采集,不能由协作类的同名方法满足;锁顺序、歧义及编译成员覆盖仍检查整个协作类族。回归覆盖多文件传递委托、把 Session 锁移到 Turn 访问之后、完全删除锁(由固定的 locker 集合捕获)、隐藏到不可解析接收者、遮蔽、歧义重载及文件独立的常量/import。 + +检查器证明源码层面的顺序,不证明运行时事务语义或数据库无死锁。后续切片仍需相应运行时及 MySQL 证据。 + +## 实施清单 + +此表为唯一阶段清单,英文版本完整同步。入口清单是其方法级明细,不另立独立状态表。两种语言同步记录各阶段实现、精确验证版本、相关证据和未完成项。各阶段一起交付;代码已迁移不代表已验收。 + +| 顺序 | 交付 | 入口清单 | 状态 / 证据 | +| ---- | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------- | +| R0 | 完成 Agent 构造装配及实际 Spring 工厂回归 | 已有装配设计 | #16061 已合入;full Hosted 两项失败,待归因 | +| R1 | 双语契约/清单及跨文件锁检查 | 下文两个清单 | R1 版本:56 个定向用例及全部 reactor Checkstyle 通过;新版本证据见下文 | +| A1 | Agent 普通查询及首次需要的行 helper | Agent A1 | 已实现;待验收 | +| A2 | 事件 journal | Agent A2 | 已实现;待验收 | +| A3 | 消息投影 | Agent A3 | 已实现;待验收 | +| A4 | Session 策略 | Agent A4 | 已实现;待验收 | +| A5 | 命令、回执与结算 | Agent A5 | 已实现;待验收 | +| A6 | Session 准入 | Agent A6 | 已实现;待验收 | +| A7 | 生命周期操作 | Agent A7 | 已实现;待验收 | +| A8 | 执行操作 | Agent A8 | 已实现;待验收 | +| A9 | Turn 执行与 Agent facade 收尾 | Agent A9 | 已实现;待验收 | +| B0 | Session 构造装配 | Journal B0 | 已实现;待验收 | +| B1 | 行访问、fencing 与 writer 生命周期 | Journal B1 | 已实现;待验收 | +| B2 | 生命周期授权 | Journal B2 | 已实现;待验收 | +| B3 | 历史查询 | Journal B3 | 已实现;待验收 | +| B4 | 资源写入 | Journal B4 | 已实现;待验收 | +| B5 | 资源读取 | Journal B5 | 已实现;待验收 | +| B6 | 普通/CSI terminal commit 与 Journal facade 收尾 | Journal B6 | 已实现;待验收 | + +## 验证与交付标准 + +R1 不改变 CLI/HTTP 行为,全局 CLI baseline 或模型调用无法验证此源码检查器。使用多文件合成夹具和真实 persistence 源码树,编译 verification 模块,运行 `SessionRowLockOrderTest` 及 reactor Checkstyle。检查双语结构、链接、格式和完整 diff。遵守模块共享本地 Java 预算,保留失败记录,只复用输入未变的证据;新 SHA 或继续会话不会重置预算。此前 272 个装配用例不能验收改动后的检查器。 + +R1 本地证据:macOS/JDK 21 上原有 44 个锁检查用例和新增 12 个回归全部通过,失败、错误和跳过均为零。已测检查器源码 SHA-256 为 `bc6317d65f0d68dc9837f1d49f85e9a18ed41506e04ef395d33c326c65cd0052`;8 个 reactor Checkstyle 均通过。共享本地 Java 预算累计 413.575/600 秒,含保守延续的 R0 耗时及保留的修复尝试。这仅是定向证据:R1 的完整 verify、SpotBugs、真实 MySQL、Hosted/故障及跨平台资格仍待 CI。 + +A1–A9/B0–B6 在基于 `77a0d4a4d7cbc9eeab2213b8bf45af117b47d83d` 的实现工作树上的本地证据:修复新增装配夹具的两项失败后,17 个已完成 Java 测试类中的 260 个不同用例通过;17 用例装配复测仅替代这两项失败。生产源码已编译;全部验证源码在最后仅静态修改锁审计断言前完成编译。七次 reactor Checkstyle(parent 与六个生产模块)通过,verification 模块最后的 Checkstyle 尚未执行到。根项目 build/bundle、typecheck 及 54 个专用脚本测试通过。日志、XML 报告和源码指纹保存在 `.qwen/investigations/managed-store-split-implementation/`。 + +两次受限 Maven 调用均到达截止时间,不能称为整次运行成功。包含此前工作和保留的失败尝试,共享 Java 账本为 598.655/600 秒。`SessionRowLockOrderTest` 开始后被中断;更新的入口集合,以及随后仅静态修复的断言排序、SQL 文件归属集合和精确同名引用标识仍未验证。另十个已选择测试类未完成:`JournalEventPublicationPointTest`、`ManagedEventReplayTest`、`ManagedSchemaJournalAtomicityTest`、`ManagedSessionCreationSkillsTest`、`ManagedSessionNativeReadTest`、`ManagedSessionStoreBindingTest`、`ManagedSessionStoreCredentialTest`、`ManagedTaskCancelOperationTest`、`ReplayFloorRetractionTest` 和 `WorkspaceSessionCloseTest`。这些检查与 verification 模块 Checkstyle,以及 full verify、SpotBugs、真实 MySQL、Hosted/fault 和跨平台资格项均须由 CI 补齐;不宣称新版本已通过 CI 验收。 + +各已实现阶段的验收需覆盖 tenant/actor 隔离、游标语义和批量查询次数;含 definition/skills/worktree 的准入/重放;序号/去重/投影缺口/撤回/replay floor;提交后通知及回滚/重放不通知;租约 generation/token/到期及接受结果未知时不重复派发;journal 原子回滚/重放;资源损坏/读取租约失效/owner read 收尾;publication/snapshot 锁顺序和 CSI 同连接原子性。 + +SQL/并发路径需要真实 MySQL 证据。CI 承担完整 verify、SpotBugs、完整 MySQL、Hosted/故障及跨平台检查。当前 push 的 fast tier 缺少必需资格项,接受版本的 full Managed Agent gate 才是交付标准。所有未运行项都单独列明。 + +每片均需完整 diff 连续两轮干净自审及独立评审。最终两个 facade 只保留装配、事务入口和兼容转发,不包含业务 SQL 或状态机。协作者实例依赖无环,公共契约和各路径事务语义保持一致。定向测试通过不等于完整验收。 + +## 风险与待决事项 + +已接受的架构方向没有未决产品问题。主要实施风险是隐蔽查询副作用、共享回执/资源校验、lazy 构造回环、事务边界搬移和源码检查盲区。发现意外依赖时,在最小受影响切片内解决,并在改变设计前同步两个语言版本。不把算法、SQL、存储或协议重设计悄然并入搬移 PR。 + +## 已实现的依赖边界 + +facade 按原顺序只计算一次配置和 catalog 校验,再把相同值和依赖实例传给协作类的 final 字段。Agent 构造顺序为 Rows → Queries/EventJournal → Policies/Projection → Commands → Admissions/Lifecycle/TurnExecution → ExecutionOperations。Journal 构造顺序为 Rows → Fencing → WriterLifecycle/HistoryQueries/LifecycleAuthorization/ResourceWrites/ResourceReads → Commit。协作类不接收 facade 或构造器回调集合。 + +共享 Session/Turn 单点读取位于 Rows;Queries 的三个兼容读取入口转发至这些实现。Policies 持有共享准入谓词,Rows 持有它们已有的 key 编码和数据库时间读取。九参数 Session 准入默认方法仍在 facade 上按原样转发到接口默认实现,保留虚分派和事务边界。资源行校验与对象 IO、租约、隔离分别保留。Commit 在原位置调用提取出的生命周期派发授权块,传入已有的调用内资源 reader。 + +Session 构造现在确定原 setter 涉及的全部九个引用。Spring 必需依赖仍必需;standalone 构造保留原 null/in-process/unbound 默认值。生命周期双依赖任一缺失时,两者均不启用。CSI 仅接受同数据源 JDBC runtime binding repository。维护入口与夹具在创建下游消费者前传入依赖;实际 Spring 工厂构造另有覆盖。 + +锁检查保留 facade 入口集合,并按 owner/name/arity 钉住已迁移的 24 个 private helper 重载。协作类与完整源文件族一起分析,同时逐文件保留显式 SQL 归属边界集合。编译成员覆盖、不可解析接收者的拒绝规则和原有反例仍为必需。下文原行号清单指记录的基线,不指迁移后位置。 + +## Agent 入口清单 + +源码:`packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java`。 + +事务图例:**T** = 保留原始普通 `@Transactional`(默认 REQUIRED、DEFAULT 隔离级别、readOnly false),facade 类保留 `@Qualifier("managedAgentTransactionManager")`;**M** = 在上述基础上显式使用 MANDATORY;**RR** = 显式 transactionManager `managedAgentTransactionManager`、readOnly true、REPEATABLE_READ;**N** = 当前入口没有声明式事务。N 不代表只读,也不授权新增事务。worktree 行另行标明其现有的验证/隔离事务。 + +所有重载均按源码行号列出。两个构造器保留为 facade 装配;public static `readBinding` 继续在 facade 提供,可转发到行组件。下列生产调用者已按实际接收者声明核对,并单独检查了 CSI 本地 `var` 构造路径;未发现调用的入口明确注明,不意味着可以删除该方法。即使生产代码当前未调用某个兼容入口,测试仍是其消费者。 + +### A1 AgentSessionQueries + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| ------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `findChildWorkspaceCwd` (691) | N | ManagedAgentService | +| `findChildLineage` (980) | N | ManagedAgentService, ManagedUserMessagePolicy, QwenHostedHarnessConnector | +| `hasCompletedWorkspaceClose` (2283) | N | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `completedWorkspaceCloses` (2290) | N | ManagedAgentService | +| `hasExecutionRegistryFacts` (3911) | N | ManagedActionStore, ManagedUserMessagePolicy | +| `sessionsWithExecutionRegistryFacts` (3919) | N | ManagedAgentService | +| `findSession` (4544) | N | ManagedAutomationService, WorkspaceCsiRuntimeAccess | +| `findSessionById` (4556) | N | ManagedRuntimeCoordinator, WorkspaceRuntimeResolver | +| `findLatestLifecycleOperations` (4648) | N | ManagedAgentService | +| `findTurn` (4674) | N | HarnessCoordinator, ManagedAgentService, QwenHostedHarnessConnector, SessionRecoveryService | +| `findActiveTurns` (4684) | N | ManagedAgentService, QwenHostedHarnessConnector | +| `findLatestTurns` (4708) | N | ManagedAgentService, SessionRecoveryService | +| `findModelContexts` (4747) | N | ManagedAgentService | +| `modelOptions` (4753) | N | ManagedAgentService | +| `findModelSelections` (4756) | N | ManagedAgentService | +| `findTurnModelSelection` (4776) | N | QwenHostedHarnessConnector | +| `findApprovalSelections` (4862) | N | ManagedAgentService, QwenHostedHarnessConnector | +| `findApprovalOwners` (4881) | N | ManagedAgentService | +| `findTurnApprovalSelection` (4895) | N | QwenHostedHarnessConnector | +| `listTurns` (5086) | N | ManagedAgentService | +| `findTurnSummary` (5110) | N | ManagedAgentService | +| `findLastVisibleStatusBeforeDelete` (6634) | N | ManagedAgentService | +| `requireSession` (6647) | N | ChildWorkspaceService, HarnessCoordinator, ManagedAgentService, ManagedExtensionCommandVerifier, ManagedExtensionRecordStore, ManagedRuntimeCoordinator, ManagedSessionStore, ManagedUserMessagePolicy, QwenHostedHarnessConnector, SessionLifecycleCoordinator, SessionLifecycleService, SessionRenameCoordinator | + +### A2 AgentEventJournal + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| --------------------------------------- | ---- | -------------------------------------------------------------- | +| `findEvents` (5122) | N | ManagedAgentService | +| `findLatestEnvironmentEvents` (5140) | N | ManagedAgentService | +| `findControlEvents` (5184) | N | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `findTranscriptEvents` (5199) | N | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `appendPublicEventIfAbsent` (6601) | T | HarnessCoordinator, ManagedActionStore, ManagedToolResultStore | +| `appendLiveSessionEventIfAbsent` (6612) | T | 未发现外部直接生产调用;保留接口、内部及测试用法 | + +### A3 AgentMessageProjection + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| ------------------------------------ | ---- | ------------------- | +| `findMessagePage` (5234, 5242) | RR | ManagedAgentService | +| `findMessageCoveredSequences` (5413) | N | ManagedAgentService | +| `findReplayWindow` (5435) | N | ManagedAgentService | +| `findReplayFloorTargets` (5454) | N | ReplayFloorAdvancer | +| `advanceReplayFloor` (5475) | T | ReplayFloorAdvancer | +| `findMaterializationTargets` (5506) | N | MessageMaterializer | +| `materializeNextBatch` (5516) | T | MessageMaterializer | +| `deferMaterializationTarget` (5620) | T | MessageMaterializer | + +### A4 AgentSessionPolicies + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| ------------------------------------------------ | ---- | --------------------------------- | +| `workspaceSessionOptions` (1096) | T | ManagedAgentService | +| `supportsPlanMode` (1113) | N | ManagedAgentService | +| `hasMcpServers` (1169) | N | SessionLifecycleCoordinator | +| `requireMcpManagement` (1180) | T | ManagedMcpManagementService | +| `requireMcpSettingsCommit` (1215) | M | ManagedExtensionRecordStore | +| `requireExtensionManagement` (1228) | T | ManagedExtensionManagementService | +| `requireConfigurationCommit` (1266) | M | ManagedExtensionRecordStore | +| `workspaceFilesEnabled` (2249) | N | ManagedAgentService | +| `workspaceShellEnabled` (2254) | N | ManagedAgentService | +| `changeSessionModel` (4790) | T | ManagedAgentService | +| `changeSessionApproval` (4926, 4935, 4944, 4954) | T | ManagedAgentService | + +### A5 AgentSessionCommands + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| ------------------------------------ | ---- | ------------------------------------------------ | +| `insertTurnCommand` (1514, 1525) | T | ManagedAgentService | +| `insertCancelCommand` (1620, 1629) | T | ManagedAgentService | +| `beginSessionRename` (1685, 1693) | T | ManagedAgentService | +| `completeSessionRename` (1833, 1840) | T | ManagedAgentService, SessionRenameCoordinator | +| `abandonSessionRename` (1918) | T | ManagedAgentService, SessionRenameCoordinator | +| `deliverableRenames` (1933) | T | SessionRenameCoordinator | +| `claimRename` (1953) | T | ManagedAgentService, SessionRenameCoordinator | +| `markRenameSubmitted` (1979) | N | ManagedAgentService, SessionRenameCoordinator | +| `retryRename` (1997) | T | ManagedAgentService, SessionRenameCoordinator | +| `beginSessionMutation` (2080, 2089) | T | ManagedAgentService | +| `completeSessionMutation` (2151) | T | ManagedAgentService | +| `abandonSessionMutation` (2225) | T | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `replayCommand` (4502) | N | ManagedAgentService | +| `findCommand` (4524) | N | ManagedAgentService | + +### A6 AgentSessionAdmissions + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| -------------------------------------------- | ------------------------- | ------------------------------------------------------ | +| `insertSessionCommand` (388, 7651) | T | ManagedAgentService | +| `insertWorkspaceSessionCommand` (402) | T | ManagedAgentService | +| `insertInternalTeamLeadSessionCommand` (528) | T | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `findWorkspaceCreateReplay` (555) | N | ManagedAgentService | +| `replayWorkspaceSessionCommand` (570) | T | ManagedAgentService | +| `insertChildSessionCommand` (667, 677) | T | ManagedAgentService | +| `insertPublicChildSessionCommand` (703) | T | ManagedAgentService | +| `replayChildSessionCommand` (952) | T | ManagedAgentService | +| `insertCsiSessionCommand` (582) | N(要求环境事务) | WorkspaceCsiSessionCommands | +| `requireCsiRequest` (633) | N(加锁桥接;调用方事务) | WorkspaceCsiRuntimeAccess, WorkspaceCsiSessionCommands | + +### A6 -> AgentSessionQueries:兼容展示路径 + +这些入口在 A1-A5 期间保持 facade 中的原实现。A6 处理 worktree 协作时,将它们迁入既定的 AgentSessionQueries 组件。副作用继续委托现有 SessionWorktreeStore,不引入额外的 worktree 包装层。 + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| --------------------------------- | ------------------------------- | --------------------------------------- | +| `findSessionWorktree` (157) | N + 现有 worktree 验证/隔离事务 | HarnessCoordinator, ManagedAgentService | +| `findSessionWorktrees` (162) | N + 现有 worktree 验证/隔离事务 | ManagedAgentService | +| `listSessions` (4564, 4569, 4574) | N + 现有 worktree 验证/隔离事务 | ManagedAgentService | + +### A7 AgentLifecycleOperations + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| -------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------- | +| `beginOperation` (2238) | T | SessionLifecycleService | +| `beginWorkspaceClose` (2260) | T | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `beginWorkspaceLifecycle` (2268, 2277) | T | SessionLifecycleService | +| `unarchiveWorkspaceSession` (2322) | T | ManagedAgentService | +| `beginChildClose` (2643) | T | ChildLifecycleAdmissions | +| `findOperation` (4062) | N | ManagedActionStore, ManagedAgentService, SessionLifecycleService, TaskCancelCoordinator, TurnInputEvidence | +| `findDeliverableOperations` (4070) | N | SessionLifecycleCoordinator | +| `claimOperation` (4108) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `completeOperation` (4143) | T | SessionLifecycleCoordinator | +| `failOperation` (4243) | T | SessionLifecycleCoordinator | +| `renewLifecycleOperation` (4308) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `blockLifecycleOperation` (4363, 4371) | T | SessionLifecycleCoordinator | +| `retryOperation` (4418, 4427) | T | ActionResponseCoordinator, SessionLifecycleCoordinator, TaskCancelCoordinator | +| `markOperationAnswered` (4473) | T | ActionResponseCoordinator | + +### A8 AgentExecutionOperations + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| ------------------------------------------- | ---- | -------------------------------------------------------- | +| `beginContextCompression` (2930) | T | ContextCompressionService | +| `contextCompressionState` (3005) | N | 未发现外部直接生产调用;保留接口、内部及测试用法 | +| `contextCompressionStates` (3020) | N | ManagedAgentService | +| `contextCompressionRequest` (3169) | N | ContextCompressionCoordinator, ContextCompressionService | +| `findContextCompressions` (3177) | N | ContextCompressionCoordinator | +| `claimContextCompression` (3186) | T | ContextCompressionCoordinator | +| `requireContextCompressionCommit` (3200) | N | ManagedExtensionRecordStore | +| `markContextCompressionSubmitted` (3217) | T | ContextCompressionCoordinator | +| `settleContextCompression` (3231) | T | ContextCompressionCoordinator | +| `retryContextCompressionPreparation` (3251) | T | ContextCompressionCoordinator | +| `awaitContextCompression` (3263) | T | ContextCompressionCoordinator | +| `completeCwdChangeOperation` (3289) | T | SessionLifecycleCoordinator | +| `failCwdChangeOperation` (3406) | T | SessionLifecycleCoordinator | +| `beginTaskCancelOperation` (3434) | T | ManagedTaskCancelService | +| `findDeliverableTaskCancels` (3539) | N | TaskCancelCoordinator | +| `findParkedOperations` (3564) | N | OperationReconciler | +| `settleParkedOperation` (3582) | T | AcceptanceEvidence, OperationReconciler | +| `turnInputAcceptance` (3641) | N | TurnInputEvidence | +| `renameAcceptance` (3703) | N | RenameEvidence | +| `settleParkedRename` (3733) | T | RenameEvidence | +| `settleTaskCancel` (3804) | T | TaskCancelCoordinator | + +### A9 AgentTurnExecution + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| --------------------------------------- | ---- | ---------------------- | +| `findDispatchable` (5627) | N | HarnessCoordinator | +| `findRuntimeRecoveryParked` (5639) | N | HarnessCoordinator | +| `claimTurn` (5654) | T | HarnessCoordinator | +| `renewTurn` (5675) | T | HarnessCoordinator | +| `releaseTurnLease` (5685) | T | HarnessCoordinator | +| `markTurnRecoveryBlocked` (5698) | T | HarnessCoordinator | +| `markTurnFailureRecovery` (5721) | T | HarnessCoordinator | +| `requestRecoveryRecheck` (5739) | T | HarnessCoordinator | +| `parkTurnForRuntimeRecovery` (5759) | T | HarnessCoordinator | +| `deferTurnRetry` (5779) | T | HarnessCoordinator | +| `scheduleTurnRetry` (5786) | T | HarnessCoordinator | +| `bindHarness` (5804) | T | HarnessCoordinator | +| `bindRecoveredHarness` (5831) | T | HarnessCoordinator | +| `markSubmissionAttempted` (5862) | T | HarnessCoordinator | +| `withdrawSubmissionAttempted` (5887) | T | HarnessCoordinator | +| `recordAdmission` (5904) | T | HarnessCoordinator | +| `recordRecoveryAdmission` (5948) | T | HarnessCoordinator | +| `retractContinuationOutput` (6009) | T | HarnessCoordinator | +| `retractHarnessTurnOutput` (6079, 6087) | T | HarnessCoordinator | +| `recordHarnessEvents` (6304) | T | HarnessCoordinator | +| `cancelUnadmittedTurn` (6461) | T | HarnessCoordinator | +| `cancelBeforeAdmission` (6505) | T | HarnessCoordinator | +| `failTurn` (6535) | T | HarnessCoordinator | +| `failWorktreeInitialTurns` (6541) | T | SessionWorktreeService | +| `failRecoveryBlockedTurn` (6562) | T | HarnessCoordinator | + +### Rows 兼容转发 + +| 入口 / 重载行号 | 事务 | 直接生产调用者 | +| -------------------- | ---- | ------------------------------------------------------------------- | +| `readBinding` (7569) | N | WorkspaceFileStore, WorkspaceMigrationMain, WorkspaceMigrationStore | + +清单覆盖 **136 个不同入口名 / 153 个声明**,包括两个包可见的 CSI 桥接方法和一个 public static 映射器,不包括两个构造器。 + +### worktree 展示路径的决定与副作用 + +worktree 展示入口不是纯读取。`findSessionWorktree` / `findSessionWorktrees` 调用 `SessionWorktreeStore.descriptor(s)`(107-145),`listSessions` 在批量分页 SQL 后调用 `descriptors`(4645)。`verifiedDescriptor`(112-120)验证文件系统身份并读取当前分支;遇到 `ManagedAgentException` 时调用 `blockIdentity`(368-378),后者通过已有的 `TransactionTemplate` 和 `PROPAGATION_REQUIRES_NEW` 将 READY 改为 BLOCKED 并增加 revision。该独立事务特意保证调用方准入失败回滚后,隔离结果仍然保留。最终决定:首个 A1 纯查询切片排除这三个入口名,保留其 facade 原实现直到 A6。A6 将它们迁入既定 AgentSessionQueries 组件,作为明确带副作用的兼容展示路径,复用 SessionWorktreeStore 验证/隔离及其现有事务。不得添加 readOnly、跳过验证、复制隔离代码、删除现有事务,也不增加另一层 worktree 包装。 + +策略/执行准入也会通过 `requireCwdChangeRegistryFacts`(3894)进入同一条 worktree 验证路径:先执行 `SessionWorktreeStore.requireReady`,再执行 `hasExecutionRegistryFacts`。必须保留 worktree 验证先于 creator/binding 事实检查的顺序,包括持久化隔离语义。 + +### 事务与桥接例外 + +- `findWorkspaceCreateReplay`(555)没有事务声明;`replayWorkspaceSessionCommand`(570)带普通 `@Transactional`。二者都会重新检查重放摘要、internal-team-lead 身份、workspace 绑定和当前读取权限。不得统一这两种事务模式。 +- `insertCsiSessionCommand`(582)为包可见方法,没有事务注解,显式要求活动事务并拒绝 journal event transport。`requireCsiRequest`(633)保留相同的 transport 隔离,锁定 Session 和 runtime-request key,并验证 CSI profile/request 身份。调用方 `WorkspaceCsiSessionCommands.create`(19-47)拒绝环境事务,创建新的 10 秒 TransactionTemplate,检查原始物理事务连接,先锁 placement 再锁 retention,验证 registration,随后在该连接上调用两个 Store 桥接入口。此调用方使用 standalone 的 `var sessions = new ManagedAgentStore(...)` 构造路径。 +- `requireCsiRequest` 也由 `WorkspaceCsiRuntimeAccess.request`(183-190)调用;该调用方原有的 continuation/retention 锁行为全部保留。保留 facade 方法的包可见性和七参数 standalone 构造器。 +- `readBinding`(7569)是 public static 方法,由 `WorkspaceMigrationMain`、`WorkspaceMigrationStore` 和 `WorkspaceFileStore` 直接使用;实际映射迁入行组件后,仍需保留 facade 转发入口。 +- `requireMcpSettingsCommit`(1215)和 `requireConfigurationCommit`(1266)使用 MANDATORY。`requireContextCompressionCommit`(3200)没有注解;它在 `ManagedExtensionRecordStore` 的已有提交流程中被调用,不得额外增加 MANDATORY/REQUIRES_NEW 包装。 +- `findMessagePage` 重载(5234/5242)显式使用 managedAgentTransactionManager、readOnly true 和 REPEATABLE_READ。普通 Query 入口没有注解,应继续保持。 +- `deliverableRenames`(1933)是事务内的修改型扫描:先停放过期的 SUBMITTED claim、重置过期的 PREPARING claim,再选择待处理工作。`markRenameSubmitted`(1979)是没有声明式事务的带条件写入。二者均归 Commands,并保留该差异。 +- `contextCompressionState`(3005)调用 `ensureCompactionProjection`(3066);缺少投影时,通过 DataSourceTransactionManager 创建默认 TransactionTemplate 并重放 journal 行。批量方法 `contextCompressionStates`(3020)可能回落到此写路径,并标记不可用状态。二者归 A8,保留同一程序式事务,不归 A1。 +- `publishAfterCommit`(7489)仅在事务同步提交后发布;没有同步上下文时立即发布。空事件不执行任何操作。EventJournal 提取必须保留 standalone 回退行为,不得增加重放或回滚通知。 + +### 实际锁路径(不统一为一个全局顺序) + +- 行 helper `requireSessionForUpdate`(7061/7066)先锁 PUBLIC Session 行,仅在取得该锁后才读取并锁定对应 event-stream 计数器。列表映射器与严格单 Session 映射器对缺少 stream 的处理不同。需要同时锁二者的路径随后通过 `requireTurnForUpdate`(7106)锁 Turn;提取后保留其 NOT_FOUND 映射。 +- 许多准入、修改和策略入口首先执行 `WorkspaceMigrationAdmission.lockTenant`,然后按路径依次处理 Session/stream、Turn/receipt/operation。MCP/extension 的 idle 复核(1340)专门按此顺序进行新鲜加锁读取:Session plan -> 活动 Turn -> 未结算 child-run record -> requested action records -> 活动 operation/rename/receipt。已有 journal 快照是这些读取不能改成普通一致性读取的原因。 +- 生命周期准入 `beginLifecycle`(2695)先锁 migration tenant,按条件锁 retention tenant,再锁 Session/stream;保留失败操作的重放/恢复、状态授权和 idle journal 检查,按原顺序写 operation/fence/head 及事件。`completeOperation`(4143)先执行 legacy CSI guard,再按条件锁 placement 和 bounded retention,随后根据生命周期协议在 Session 之前或之后加 deletion 锁,最后处理 operation claim。不得统一其分支顺序。 +- `ManagedLegacySessionGuard.requireLegacyMutation` 本身通过 `ManagedSessionStore.namedRootDeadlock` 进行使用索引的 CSI root 加锁读取,查询超时为 10 秒。一些 Turn lease 路径采用此 guard 加带条件 UPDATE,准入/终态路径则取得完整 Session/stream 锁后再处理 Turn。保留差异;通用的行 helper 事务或锁会改变语义。 +- 物化和 replay-floor 推进在检查 Turn 证据或更新 coverage 之前先锁 Session/stream。撤回修复 `rebuildTurnText`(6172)只在外层 Turn 工作流已经取得原有 owner/epoch 锁之后,才锁 stream coverage 和 message 行。 + +### A1 依赖闭包与边界 + +A1 普通查询子集需要 JdbcTemplate、已配置的 ObjectMapper、HarnessRules(用于审批规则解码)、构造时固定的 modelCatalog 快照及 Session/Turn 映射器。WorktreeFiles 和三个 worktree 展示入口推迟到 A6 -> AgentSessionQueries 交付。共享行职责包括 `readSession`、sessionMapper/sessionListMapper、turnMapper/turnSummaryMapper、readBinding、nullable/additive 列 helper、StreamCounters 和已有的加锁 Session/Turn 读取器;各项只在首次实际需要时迁移,保留严格读取与列表读取的 stream 差异及全部 schema projection。JSON 解码仅在具体行映射器确实需要时共享,不引入通用 Support/Context 对象。 + +`findLatestTurns` 按最后一个 turn.accepted 事件选择,而非最新 gmt_create。A6 迁移 `listSessions` 时,必须保留 tenant/actor 授权、archive/title/cursor 过滤、limit+1 语义、批量 worktree 元数据及验证/隔离;查询次数、顺序和字节表示不变。`findSessionById` 是运行时解析已有的跨 tenant 查询,不得擅自增加 tenant 过滤,也不得将其暴露为新的 HTTP 操作。 + +单点读取和展示视图可以归 Queries;操作任务扫描跟随其状态机(rename -> Commands、materialization/replay floor -> Projection、compression/task cancel/parked -> ExecutionOperations、lifecycle dispatch -> LifecycleOperations、Turn dispatch/recovery -> TurnExecution)。策略、命令和生命周期共享的只读 Session busy 谓词,应在首次迁移时确定唯一的窄职责持有者,不得为了复用它们而让组件回调 facade。 + +### 后续切片的具体单向协作边界 + +- A2 EventJournal 持有 appendEvent/appendEvents、event identity/source-key 处理、eventMapper 和提交后发布;可以依赖 Rows,不依赖工作流组件。它不执行同步消息物化。 +- A3 MessageProjection 持有投影扫描、物化、replay-floor 状态及 `rebuildTurnText` 修复 helper。事件读取依赖单向指向 EventJournal/Rows。public `retractContinuationOutput`(6009)和两个 `retractHarnessTurnOutput` 重载(6079/6087)仍是 A9 工作流入口:owner/lease/epoch 检查以及 event/identity 删除先于 A3 修复,并处于同一 facade 事务。不得给 Projection 添加指向 TurnExecution 或 facade 的回调。 +- A4 策略事务拥有现有的 actor 作用域 SET_MODEL/SET_APPROVAL 重放协议,直接使用 ManagedReceipts(4790 起、4954 起)。它们不调用通用 insertCommand/settleTurnCommand。保留这些作用域和原有 result JSON,不将其重写到新的通用 receipt 流程。A4 可以使用 Queries/Rows/EventJournal 和既有 definitions/skills;后续 Commands/Admissions 单向使用策略检查。 +- A5 Commands 持有共同的 `insertCommand`(7156/7163/7170)、`settleTurnCommands`(2477)、`settleTurnCommand`(2532)、Turn command operation 插入(2416)、命令重放和回执。A9 的 recordAdmission/recordRecoveryAdmission/recordHarnessEvents 和终态路径调用此结算边界。A8 接受结果对账必须使用相同的命令/rename 结算语义;Commands 不反向回调 A8/A9。 +- A6 Admissions 依赖下层 policy/command/event/row 组件和既有 definitions/skills/worktree 组件。保留先重放、再进行新准入解析的时点,以及 creator/child 作用域、definition 固定和回滚行为。 +- A7 LifecycleOperations 持有 operation 单点读取、重放、claim/lease/retry/completion 和 operation 映射器。当前共享 API 由 SessionLifecycleCoordinator、ActionResponseCoordinator 和 TaskCancelCoordinator 使用;facade 路由保持不变。A8 按需调用 A7 的 operation 查询及共同 claim 原语,例如 claimContextCompression(3186)完成专用 CAS 后读取 findOperation;requireContextCompressionCommit(3200)通过同一查询检查操作。协调器已经负责选择通用 completion 或专用 cwd/task-cancel 结算;A7 不得变成调用 A8/A9 的派发器。 +- A8 ExecutionOperations 持有专用 cwd/compaction/task-cancel/unknown-acceptance 状态机和对应扫描,向下依赖 Rows/Queries/Policies/Commands/EventJournal 及 A7 共同 operation 原语。通用 claim/retry 不接收用于专用结果处理的回调。 +- A9 TurnExecution 需要 Rows/Queries、共同 Commands 结算、EventJournal 和 Projection 修复。不能仅因协调器另行调用生命周期 API 就让它依赖 LifecycleOperations,应保留两者分离,避免形成环。 +- 实例构造无环是每次提取的显式验收项,不能仅凭这些职责标签宣称已证明。尤其是 hasOpenOperation/hasOpenExecutionOperation/hasPendingRenameDelivery 等共享谓词和已有 schema key 编码器,不得形成 Policies -> Commands -> Policies 或 Commands -> Lifecycle -> Commands 环。应迁移真实依赖闭包,并在适当位置直接使用现有 schema/receipt helper。无需构造器函数集合、facade 回调或可变 setter。 + +## Journal 入口清单 + +基线:`e3c7a6d7efc6df6c58ffa8ddd9c42dbb0e1784c4`(2026-11-02)。下文 `M:` 指 `packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java`,共 3931 行。同包文件简称均指该 persistence 目录;Controller 位于 server,Hosted connector 位于 adapters。本清单只定义迁移边界,不代表后续切片已实现或通过验收。 + +### 1. 结论与边界 + +- 实现公开业务方法共 27 个签名、22 个名称,全部来自现有 contracts `repository.ManagedSessionStore`。构造器 3 个,装配 setter 8 个、引用 9 个;另有包内方法 17 个、包内 record 2 个、锁等待常量 1 个。下面全部列出。 +- 该 Store 没有 `MANDATORY` 方法。不能把整体方案提到的 `MANDATORY` 错填到 Journal 入口;Journal 的显式特殊事务是两个 `READ_COMMITTED` history 查询和 `NOT_SUPPORTED` 的 initial-journal 检查,外加无声明式事务的资源读取及包内桥接。 +- 新增内部对象不要调用 facade。B1 所需的资源行完整性检查、activation 行映射随首次实际使用进入 `JournalStoreRows`,因此 B1 不需要等待 B3/B5 或回调 facade。对象读取、lease、更新验证时间和 quarantine 均不进入 Rows。 +- 公开业务接口、既有包内入口、`PublicationWriter`、`TerminalCandidate`、静态锁工具与常量继续从 facade 提供;内部 helper 的包可见化只为显式类型依赖,不能新增通用 Context/Support。 +- 锁顺序按路径保留:普通 heartbeat 不获取 tenant guard,CSI continuation 先锁 root 再获取 placement/retention;publication/snapshot 又有自身先行 legacy guard/probe 和调用者锁。不要把这些路径概括成一个强制全局顺序。 + +### 2. 事务图例 + +`T`:原方法 `@Transactional`;manager 由类上 `@Qualifier("managedAgentTransactionManager")`(M:82)保持现有解析,注解未指定显式 manager;传播 `REQUIRED`,隔离 `DEFAULT`,`readOnly=false`,默认 timeout/rollback。两个 overload 的注解分别保留,原 self-call 不产生额外代理边界。 + +`RO`:同一 manager,`REQUIRED` / `DEFAULT` / `readOnly=true`。 + +`RC`:同一 manager,`REQUIRED` / `READ_COMMITTED` / `readOnly=true`。内部调用 `restore` 目前是 self-call,不能迁移成额外事务。 + +`NS`:同一 manager,`NOT_SUPPORTED` / `DEFAULT` / `readOnly=true`;挂起调用者事务,自己不创建事务。 + +`N`:无声明式事务;不得因为提取而新增代理或注解。是否在事务内取决于现有调用者。特别是 standalone 构造器从来不会自动建立 Spring 方法事务。 + +`P`:`N` 外壳,程序式 owner-read 流程,见第 6 节。 + +以下锁轨迹记录原有顺序及分支,省略重复取得同一已持有锁,不表示可以删除这些重复调用。`P`(锁列内)= placement tenant guard;`Tg`= output-publication retention tenant guard;`R`= session root;`H`= managed-session head;`D`= harness drain head;`C`= CSI binding/slot/retirement/progress 原连接锁闭包。所有 bounded tenant 等待继续 5 秒,namedRootDeadlock 仅映射其现有死锁集合。 + +### 3. 公开业务入口完整矩阵 + +| 原入口和行号 | 归属 | 事务 | 原主要锁/副作用 | 生产调用者 | +| ------------------------------------------- | -------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `authorizeLifecycle` M:211 | B2 JournalLifecycleAuthorization | T | credential → P → Tg → ensure R/event stream → placement → session row → lifecycle claim → H;有 kind 时验证有效 writer,无 kind 的 DRAINING 分支只校验身份/状态 | ManagedSessionStoreController:102 | +| `authorizeOrdinary` M:394 | B2 | T | credential → R → placement → ordinary claim/drain admission(legacy-close 有例外)→ config checks → H → writer;不新增 tenant guard | Controller:63,93 | +| `approvalSelection` M:425 | B2 | T | 调用 ordinary 授权后 `ma_session FOR UPDATE`;返回模型/approval/permission selection,不是纯查询 | Controller:72 | +| `acquireWriter` M:629,635(4/5 参数) | B1 JournalWriterLifecycle | T/T | lifecycle authority 分支先 P→Tg→R→claim;公共核心 P→Tg→CSI/R→physical-open→ensure R/event stream→placement/control storage→materialize D→claim/admission→session row→H;创建/续用/换 generation;cold CSI takeover 经绑定与 history proof | Controller:113;大量手工 journal fixtures | +| `renewWriter` M:807,813(4/5 参数) | B1 | T/T | authority 分支的 guards 保留;placement→CSI continuation(ordinary R,pinned R→P→Tg→C)→H;普通 heartbeat 不加 Tg;writer 和 CSI lease 复核;activation renew 还更新 journal tx/head | Controller:124 | +| `sealWriter` M:891,897(4/5 参数) | B1 | T/T | authority guards→placement→lockCsiOriginal(private CSI 拒绝普通 seal)→R/H;sealed 相同身份提前 replay;writer 允许过期但不允许身份变更 | Controller:135 | +| `quiesceOwnership` M:938 | B1 | T | credential placement identity→placement current→R/H→writer→更新 session QUIESCING | Controller:152 | +| `releaseOwnership` M:952 | B1 | T | P→Tg→R→已有 receipt/resource 的 FOR UPDATE 校验与提前 replay;新请求 placement/claim→H→writer/proof→resource/record/ref→head SEALED→session RELEASED,同一事务 | Controller:144 | +| `blockRecovery` M:1051 | B1 | T | placement→CSI continuation/R→H;writer/CSI current 先于 replay,并在更新 recovery 前再次复核 | Controller:162 | +| `commit` M:1112,1118(4/5 参数) | B6 JournalCommit | T/T | authority guards→P→placement→CSI/R/C/可选H→查 command;duplicate 分支提前返回;新提交 physical-open→R/H→resource/projection/journal/head,同一事务。详见第 7 节 | Controller:173;ToolPublicationAdmissionStore:397,411 | +| `restore` M:1485 | B3 JournalHistoryQueries | RO | plain head→scope/storage-policy/read grant→activation projection;不加 FOR UPDATE | Controller:188;ToolPublicationAdmissionStore:282;ToolPublicationDataStore:950,2229;ManagedNativeJournalSnapshots:205,237,249,255,291 | +| `transactions` M:1560 | B3 | RO | plain head/read grant→先查 revision/bytes 再读 bounded payload;保留连续性/完整性与 workspace 检查 | Controller:209;ManagedNativeJournalSnapshots:129,131,132;ToolPublicationStore:559,561,562,566,587 | +| `recentTransactions` M:1644 | B3 | RC | 首次 restore/prefix 验证→反向分页两阶段读取→结束前再次 restore,不能改成 RR | Controller:221 | +| `commandTransaction` M:1700 | B3 | RC | 首次 restore/prefix→command hash/size/row→结束前再次 restore | Controller:232 | +| `hasPristineInitialJournal` M:1759 | B3 | NS | 全初始 journal/header/resource integrity 检查,开始和结束 head 一致;不获取 writer/不改变 fence;异常映射为 false | SessionRecoveryService:123 | +| `readResource` M:1920 | B5 JournalResourceReads | N | credential/head/read grant→retention read lease→resource FOR UPDATE→metadata/digest verify→更新 last_verified_at→可选 object IO/quarantine→lease check/close;不得迁入 HistoryQueries | ManagedToolResultProjector:176,318;ToolPublicationStore:630;ChildResultRelay:469,474,568,573,1161,1202,1212;SessionLifecycleCoordinator:937 | +| `readOwnerResource` M:1968 | B5 | P | 拒绝环境事务;非 CSI 回退 readResource;CSI 是短事务 admission→独立连接 fetch→短事务复核/receipt/lease 收尾,见第 6 节 | Controller:269 | +| `publishAgentInstructions` M:2328 | B4 JournalResourceWrites | T | scope→legacy CSI index locking probe→resource FOR UPDATE/插入或幂等验证;无 writer lease 新语义 | QwenHostedHarnessConnector:2111 | +| `publishAgentSkills` M:2341 | B4 | T | 同上,继续使用 AgentDefinitionExtensions.skillsBytes 的原字节编码 | QwenHostedHarnessConnector:2121,2145 | +| `publishInheritedExtensionResource` M:2350 | B4 | T | snapshot resource 拒绝→legacy probe→kind/schema/byte/digest→resource FOR UPDATE/插入或幂等 | QwenHostedHarnessConnector:2044,2049,2052 | +| `publishToolResult` M:2410,2416(4/5 参数) | B4 | T/T | authority guards→验证 bytes→physical P→Tg→placement→CSI continuation/R→H→no-lifecycle-claim(authority 也不能豁免 v2)→writer/native-head→resource→current fence→insert/replay→CSI time | Controller:198 | +| `hasLiveWriter` M:3459 | B1 | T | 直接 H FOR UPDATE,再 DB time;不补 root/tenant 锁,不改只读注解 | SessionLifecycleCoordinator:602,815,846 | + +调用者扫描有两个名称相似但并非 Journal 调用,已排除:QwenHostedHarnessConnector:273 是 SDK client.releaseOwnership,:889 是 builder.approvalSelection。ToolPublicationAdmissionStore:295,342,361 和 ToolPublicationStore:493 的 `data.readResource` 也不是这个 Store。 + +### 4. 包内兼容入口完整矩阵 + +| 原入口 | 最终实现归属 | 事务/连接要求 | 调用者及保留点 | +| ---------------------------------------- | ---------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `usesDataSource` M:522 | facade 或 Rows 恒等委托 | N;`source != null && jdbc.getDataSource() == source` | ToolPublicationDataStore:96–99、ToolPublicationAdmissionStore:53–56、WorkspaceCsiApplicationHistory:60–61;不能改 equals/unwrap/连接池同 URL 判断 | +| `lockSnapshotWriter` M:577 | B1 WriterLifecycle | N,调用者已有事务 | ManagedNativeJournalSnapshots:89,108,179,200;原 P→lockPublicationWriter→ordinary admission→H→restore mapping;PublicationWriter/RestoreHead 保持值语义 | +| `lockPublicationWriter` M:598 | B1 WriterLifecycle | N,调用者已有事务 | ToolPublicationStore:327;ToolPublicationDataStore:1121,1181;ToolPublicationAdmissionStore:374;先 legacy index probe,再 physical P→Tg→placement→CSI/R→H→writer/native-head;不能忽略调用者之前的锁 | +| `readSnapshotResource` M:1925 | B5 ResourceReads | N;snapshots 自己已有隔离程序式事务 | ManagedNativeJournalSnapshots:520;updateVerification=false:resource plain SELECT,不更新 last_verified_at,不 quarantine,但仍持有/检查/关闭 retention lease | +| `requireCsiApplicationCredential` M:2581 | B1 Fencing | N,authority 同物理连接且已绑定 jdbc DataSource 事务 | WorkspaceCsiApplicationStore:105,119,137,184,213,230;先 authority.requireConnection,再 DataSourceUtils.isConnectionTransactional(target, same datasource),credential→isolated journal→R/H→CSI/token | +| `requireCsiHistoricalCredential` M:2601 | B1 Fencing | N,无 SQL/锁;校验 credential+retained token hash | WorkspaceCsiApplicationHistory:92,在其已有只读 RR snapshot 内调用;不能改成 live-writer/lease 检查 | +| `preflightCsiTerminal` M:2609 | B6 Commit | N,同 authority 连接;PreCut;bindings 必须同 DataSource | WorkspaceCsiApplicationStore:125,144;strict bytes→credential/isolation→commit validation→R/H→writer/head→terminalPreflight(target);返回原 TerminalCandidate | +| `commitCsiTerminal` M:2636 | B6 Commit | N,同 authority 连接,Stage.FROZEN | WorkspaceCsiApplicationStore:192;冻结字节与摘要匹配→重复 preflight→resource fence→journal fence→SEALED head CAS→最后 time check;不调用普通 commit,不新增 transport 通知 | +| `validateCsiTerminalBytes` M:2695 | B6 Commit 静态纯验证,facade 转发 | N,无 SQL/IO | WorkspaceCsiApplicationStore:336;保持 strict JSON 参数、exactBody 和同一验证规则 | +| `lockCsiOriginal` M:3162 | B1 Fencing | N;pinned CSI 必须原事务连接;ordinary standalone 仍可 null | ToolPublicationAdmissionStore:371;保留其先行 original-settled-result 及 tenant 顺序 | +| `toolResultLimit` M:2507 | Rows 的纯资源行约束,facade 保留静态转发 | N,无 SQL/IO | 现有包内静态入口;返回 kind 固定上限,verifyStoredResource 也使用 | +| `commandKeyHash` M:3832 | Rows 的键编码,facade 保留静态转发 | N,纯 bytes | ManagedAgentStore:3689;operation + NUL + commandId、UTF-8、SHA256 不变 | +| `journalCorrupt` M:3861 | Rows 行校验错误,facade 静态转发 | N | ToolPublicationStore:535,544,557,563;保留 HTTP/code/message | +| `skipContended` M:301 | B1 Fencing 静态工具 | 执行调用者传入 TransactionTemplate;只吞 tenant_guard_contended | ManagedProjectionResourceCollector:42;SessionResourceCollectionCollector:188;不强制 REQUIRES_NEW | +| `lockBounded` M:314 | B1 Fencing 静态工具 | 本身无事务;运行现有 Runnable | ManagedToolResultStore、ManagedProjectionResourceCollector、ToolPublicationDataStore/AdmissionStore/Store、SessionResourceCollectionCollector、ManagedAgentStore、ManagedUserMessageStore | +| `lockBoundedResult` M:321 | B1 Fencing 静态工具 | 同上 | ToolPublicationRetentionStore:156;仅 PessimisticLockingFailureException / QueryTimeoutException → tenant_guard_contended | +| `namedRootDeadlock` M:341 | B1 Fencing 静态工具 | 同上 | ToolPublicationRetentionStore:112;ManagedLegacySessionGuard:14;只转换 DeadlockLoserDataAccessException 或链中 MySQL 1213,普通 root wait 错误保留 | + +还需保留 facade 上 `PublicationWriter` M:571、`TerminalCandidate` M:2577 两个既有包内 record,以及 `GUARD_ACQUIRE_TIMEOUT_SECONDS = 5` M:111。Root/retention 的已有外部调用会继续调用 facade 静态工具;静态转发不是新增实例依赖或 facade 回调。 + +### 5. B0 装配事实与迁移要求 + +| 原 setter | 引用及当前 Spring 条件 | standalone 默认 | 构造迁移要求 | +| -------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `configureHarnessRules` M:87 | HarnessRules,required @Autowired | null | Spring required 参数;保留现有 standalone null,不擅自加 SdkHarnessRules 默认 | +| `configureReaderVersion` M:92 | StorageReadPolicy,required @Autowired | StorageReadPolicy.inProcess() | Spring required;standalone 仍 inProcess | +| `setLifecycleExecution` M:205 | WorkspaceExecutionStore + SessionQueryStore,两参数的 `@Autowired(required=false)` 方法 | null/null | 任一 bean 缺失整个 setter 原本跳过;构造解析两可选参数后任一缺失必须归一化成 null/null,不能改成半启用或启动失败 | +| `setPublicationObjects` M:527 | ToolPublicationObjectStore,可选 | null | 构造可选,不创建对象存储默认 | +| `setToolResults` M:532 | ManagedToolResultStore,可选 | null | 构造可选,保留 capture 是否启用 | +| `setJournalEventTransport` M:539 | JournalEventTransport,可选、flag-off 无 bean | null | 构造可选,byte-identical flag-off;CSI isolation 会拒绝非空 transport | +| `setCredentials` M:546 | WriterCredentialPolicy,required @Autowired | WriterCredentialPolicy.unbound() | Spring 缺失仍 fail-closed,standalone 不新增 bound credential | +| `setCsiBindings` M:551 | RuntimeBindingRepository,可选 | null | 仅 instanceof JdbcRuntimeBindingRepository 且 `usesDataSource(jdbc.getDataSource())` 才保存;不匹配仍转成 null,不新增启动错误 | + +以下构造器及八个 setter 原本均无声明式事务(N)。已有 3 个构造器 M:177/181/187:`(JdbcTemplate)` → 新建 extensionRecords;`(JdbcTemplate, extensionRecords)` → actions=null;Spring 当前 `(qualified JdbcTemplate, extensionRecords, actions)` 三者 required。三个构造链创建同一个 DataSourceTransactionManager,分别给 ToolPublicationRetentionStore 和 ownerReads(timeout=10);保留这两个原程序式事务对象的配置,不把它们换成新增 Spring Bean 或全局事务 manager 参数。 + +生产手工调用者只有两个:`server/.../maintenance/WorkspaceCsiRuntimeMain:106–107`(裸构造 + 同 datasource CSI bindings);`WorkspaceCsiWorkerAckMain:53–54`(裸构造 + SdkHarnessRules)。两者使用场景和默认值不同,不能用一个有额外 enabled 依赖的统一 fixture 替代。 + +手工测试的中心路径是 `VerificationFixtures.configure(ManagedSessionStore)` :40–46,目前只赋 HarnessRules / StorageReadPolicy。B0 应像 Agent wiring 那样改成明确构造工厂,避免 final 字段反射、先造后重建及 setter 变名。当前构造调用扫描覆盖 52 文件、150 处(2 处生产、148 处 verification),另有 11 文件、16 处显式 setter 调用。B0 迁移逐点核对构造参数、原 setter 条件及 fixture 创建顺序;实施时刷新清单,扫描结果归零只能证明旧装配调用已移除,不能代替行为验证。 + +扫描当前源文件未发现显式返回 ManagedSessionStore 的 `@Bean` 工厂;有 `ToolPublicationAsyncVerificationTest:317` 的 `.withBean(ManagedSessionStore.class, () -> fixture.sessions)` 路径,其 context 同时提供 HarnessRules、StorageReadPolicy、credentials、bindings 和对象存储。不能依赖 bean postprocessing 继续补已移除 setter,应迁移其已构造 fixture;B0 新增 @Bean 工厂回归验证,避免重演 #16061 漏洞。 + +依赖图审查:ManagedToolResultStore:40 依赖 EventProjectionStore(Agent facade),WorkspaceExecutionStore:38 依赖 jdbc/transactionManager/WorkspaceStorageGuard/settings;LiteTopicEventTransport:45 只依赖 producer/provider/topic。没有理由为 B0 增加新的 lazy 回环;Agent→records、skills→records 的既有 lazy 边仍原样保留。Spring constructor injection 的 full-context、事务代理和实际工厂测试仍必需。 + +### 6. 资源读取的实际副作用与事务 + +#### ordinary / snapshot + +M:1930 共享实现先做 scope/credential/resource id/head/read grant;readGrant 自己查 retention live 和 DB now。`outputRetention.read`(ToolPublicationRetentionStore:379)用其现有 `TransactionTemplate`(默认 REQUIRED、DEFAULT、readOnly=false)执行 Tg→R→H 的 `lockGenericSession`,再插入 120 秒 `ma_read_lease`。这会加入既有环境事务;外层方法没有 NOT_SUPPORTED,不能新增。 + +ordinary 的 findResource `lock=true`,然后 verifyStoredResource,再写 last_verified_at(M:1953),TOOL_PUBLICATION 时读取 object stream;digest/length 错会 quarantine capture/resource。snapshot 的 findResource `lock=false`、不更新 verified、不 quarantine,但 retention lease 仍会插入、按需续期、检查和关闭。两者不是只读组件。`verifyStoredResource` 本身不做这些副作用,见第 8 节。 + +snapshot 调用者 `ManagedNativeJournalSnapshots:56–61` 既有 isolated template 为 REQUIRES_NEW + READ_COMMITTED;:89 admission/basis、:92 build、:94 lease close、:520 resource read 都沿用该模板。**必须保留这里已经存在的 REQUIRES_NEW**;“不引入 REQUIRES_NEW”只禁止新增,不能删除或复制到新组件。 + +#### owner + +M:1978 同时拒绝 `isActualTransactionActive()` 或已绑定 jdbc datasource 的 resource。非 CSI(M:1995)仍回退 ordinary read。CSI 路径: + +1. ownerReads template(M:197–200;同 datasource manager、REQUIRED/DEFAULT/readOnly=false、timeout 10 秒)开启 admission。lockCsiContinuation → original admission 或 DRAINING checkpoint continuation → R/H → read grant → resource metadata FOR UPDATE。校验 inline-only;生成 identity evidence → CSI/read-lease复核 → INSERT `CSI_RESOURCE_READ` OPEN receipt → 再复核 → INSERT ma_read_lease(generation=0)→保存回执。 +2. 退出事务后 `fetchNativeResource` M:2172 使用 DataSource.getConnection() 自己取新连接,仅 SELECT 完整 inline bytes、statement timeout=10。这里才是独立读取阶段,不能持有 admission 事务或复用其物理连接。 +3. `finishNativeRead` M:2199 再开 ownerReads 短事务:重锁 continuation/original→R/H→read receipt FOR UPDATE;身份/receipt变化直接拒绝。fetch 未知异常时记录 UNKNOWN / jdbc_fetch_unknown,原路径不删除 lease;正常返回时检查 read lease FOR UPDATE、metadata FOR UPDATE、row bytes/hash,复核 CSI/read grant,更新 verified,记录 RETURNED(verified 或具体拒绝码),最后复核后删 lease。某些早期 fencing 异常仍回滚而不强行结算,不能“优化”成 finally 删除。 +4. fetch 的 RuntimeException/Error 路径尝试收尾,收尾异常作为 suppressed,原异常继续抛。不能把 owner IO 包成大事务,不能把多次 current-time/read-grant 合并成一次。 + +### 7. Commit 原子性、CSI 复核和通知 + +普通/lifecycle commit(M:1118–1459)完整保持一个入口事务。command replay(M:1176–1214)发生在新 resource/projection 写入之前;CSI replay 同样先核验当前 writer、activation replay 和 current time,ordinary replay 仍拒绝 v2 lifecycle claim。duplicate 路径不发事件。 + +新 commit 先 validateHead / generation / lease,再在需要时对 native `commitFileHistory`/`toolIntent`/`commitCheckpoint` 做 preflight,并重新读 DB time 检验 writer(M:1228–1245)。`commitResources` M:2809 每个 resource 在查 existing 之后、写资源之前执行传入 writeFence;这是已有调用内 callback,可原样保留,禁止把它改成构造器函数集合。 + +随后同事务完成 declared-resource census、checkpoint/history/hosted closure 校验、HostedCleanupReferences、lifecycle dispatch authorization(M:1313;用成对 execution/session 依赖)、extensionRecords.apply/applyNative、CSI acceptCommit + 再次 writer 检查(M:1343–1367)、actions.apply。journal INSERT 前 requireCsiWriterCurrent(M:1378),head UPDATE 前又 requireCsiWriterCurrent(M:1421),继续 capture tool result events、ManagedUserMessageStore.capture。不能把 resource/projection/journal/head 任一部分分成独立提交。 + +`publishJournalAfterCommit` M:1466 只由新 commit 尾部 M:1455 调用。transport null 不做事;无 synchronization(standalone)立即 publishCommitted;有 synchronization 注册 afterCommit。rollback 不通知,replay 不通知。CSI continuation/terminal 明确 `requireCsiJournalIsolation`(M:3245)拒绝 transport,因此 terminal 分支没有通知;不要为了统一 commit 加上通知。 + +CSI terminal 三个桥接方法使用 `WorkspaceCsiApplicationStore.tx` :402–410 创建的既有 10 秒事务和该事务物理连接的 bounded proxy。authority 持有的 connection identity、`DataSourceUtils.getTargetConnection` 和 `isConnectionTransactional` 不可替换为新连接、仅 same URL 判断或单独 helper transaction。preflight 还检查同 datasource JdbcRuntimeBindingRepository。commitCsiTerminal 在冻结字节比较后再次 preflight,每 resource 执行 authority time + writer fence,journal INSERT 前再 fence,head SEALED CAS 前再 fence,返回前再 authority time。`TerminalCandidate` 中 request/proof/token hash 原样保留。 + +### 8. 共享闭包与单向依赖 + +`JournalStoreRows` 的允许闭包限于原来的行访问、映射、读取时必要的完整性验证、固定键/字节约束,以及下表显式列出的窄行初始化。它不是纯只读组件,也不能扩展成通用业务 Support: + +| 原方法 | 第一次需要 | 是否有 SQL/副作用 | 迁移裁定 | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| headMapper/transactionMapper/resourceMapper,HeadRow/TransactionRow/ResourceRow | B1 writer 的 head 与 release receipt | 行映射;不写业务状态 | Rows | +| findHeadForUpdate/requireHeadForUpdate M:3119/3359;requireHeartbeatHead M:3140;requireHead M:3367;databaseNow M:3474 | B1 | 前两者保留 R→H 和 live read;heartbeat 只 H;plain head 不锁;databaseNow 是 SELECT | Rows;不能因统一 helper 加锁或丢锁 | +| findResource M:3393/3397、requireResourceScope M:3787、requireTransactionWorkspace M:3798 | B1 release receipt | 默认 FOR UPDATE,显式 false 才 plain;scope 检查无写入 | Rows;保留 lock 参数各路径值 | +| verifyStoredResource M:3575 | B1 releaseOwnership M:997 | 状态/长度/SHA256/metadata;TOOL_PUBLICATION 用 SELECT COUNT 验证 capture 对应,inline managed-tool-outcome 用 SELECT COUNT 检查 quarantine;**没有 UPDATE、对象 IO、租约或 quarantine 写入** | 随 B1 迁入 Rows 的 resource-row 完整性边界,后续 B3/B4/B5/B6 复用,不能复制 | +| toolResultLimit M:2507 | verifyStoredResource 内部调用 M:3618 | 纯 kind→固定上限 switch;无 SQL/IO | 同上;facade 静态入口保留 | +| activationLeaseRow M:1512、ActivationLeaseRow、restoreHead M:1495 | B1 renewWriter M:863、snapshot bridge M:591 | 只 SELECT + strict JSON/digest + head/activation 投影一致性校验,无更新/IO/lease操作 | 随 B1 放 Rows;B3 公开 history 稍后迁移 | +| requireHeadScope M:3742 | B1 | scope/state/counter/digest 检查并调用 StorageReadPolicy.requireReadable;不写 SQL | Rows 行合法性;必须保留 reader policy 实际读取时点,不能构造期预先计算 | +| transactionRow M:3409、storedTransaction M:3434 | B3 或 B6 首次需要时 | 行映射、byte/hash verify、Base64编码,无写入 | Rows;不提前构建框架 | +| storedResource M:2520 | B6 commit closure reader | findResource(FOR UPDATE)+state/scope/verify+DTO,无 object IO/lease | B6 首次需要时迁入 Rows;不调用 B5 公共 readResource,避免额外 lease、last_verified_at/quarantine | +| ensureSessionRoot M:3818 | B1 acquire / B2 lifecycle | R 锁 + INSERT IGNORE ma_event_stream | 选定为 Rows 内显式的窄行初始化 helper,只供现有 acquire/lifecycle 路径调用。保留调用时点、R 锁和 event-stream INSERT,不扩成通用 mutation,不让 B2 回调 facade 或 WriterLifecycle。 | + +Fencing(B1)承接 credential、writer/read-grant、placement、CSI continuation/current-time/lease/history-owner 和锁错误映射;`checkLifecycleWriter` M:266、`requireNoLifecycleClaim` M:482、`requireOrdinaryAdmission` M:490、lifecycleFenced/protocolFenced M:498/510 是被 writer/resource/commit 共享的底层 gate,可随首次使用进入 Fencing。B2 LifecycleAuthorization 承接 3 个业务授权入口及 commit 的“新 lifecycle effect 是否可派发”策略,不回调 WriterLifecycle。`ensureSessionRoot` 已选定归 Rows 的**明确窄行初始化**,只服务现有两个入口,DAG 保持单向;不要为此建 Support,也不要把 writer 状态机放进去。 + +最终实例 DAG:Rows 在底层;Fencing→Rows;WriterLifecycle→Rows/Fencing;LifecycleAuthorization→Rows/Fencing(及已有 execution/session 成对依赖);HistoryQueries→Rows/Fencing;ResourceWrites→Rows/Fencing;ResourceReads→Rows/Fencing/既有 retention+object store;Commit→Rows/Fencing/LifecycleAuthorization/ResourceWrites(及已有 extensionRecords/actions/toolResults/transport)。Commit 内 reader callback 直接调用 Rows 的 storedResource,writeFence 是调用内已有回调。每个新对象由 facade 构造一次、字段 final,不传 facade,不引入新 Spring Bean。外部静态锁工具回转现有 facade 兼容符号不形成新的实例回调。 + +commitResources 是 B4 的 commit 内资源写入方法;ValidatedResource 可用同包的窄值类型或嵌套包内值类型共享,保持现有数据形状。validateCommit 是 B6 的协议/完整提交校验,不因共享资源值类型提前迁入通用 Rows。B4 提取时既有 facade commit 直接调用 ResourceWrites 完整资源闭包,不允许 ResourceWrites 再调用 facade 取查询/锁工具。 diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentEventJournal.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentEventJournal.java new file mode 100644 index 00000000000..890713a23eb --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentEventJournal.java @@ -0,0 +1,416 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.event.CommittedEventPublisher; +import com.alibaba.qwen.code.managedagent.contracts.persistence.EventIdentity; +import com.alibaba.qwen.code.managedagent.contracts.persistence.EventIdentity.Identity; +import com.alibaba.qwen.code.managedagent.model.StoreModels; +import com.alibaba.qwen.code.managedagent.model.StoreModels.EventPage; +import com.alibaba.qwen.code.managedagent.model.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ProjectedEvent; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnSummary; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class AgentEventJournal { + private final AgentStoreRows storeRows; + private final JdbcTemplate jdbc; + private final Clock clock; + private final CommittedEventPublisher eventPublisher; + + AgentEventJournal(AgentStoreRows storeRows, + JdbcTemplate jdbc, + Clock clock, + CommittedEventPublisher eventPublisher) { + this.storeRows = storeRows; + this.jdbc = jdbc; + this.clock = clock; + this.eventPublisher = eventPublisher; + this.eventMapper = (result, row) -> + new EventRecord(result.getString("tenant_id"), + result.getString("session_id"), + result.getLong("sequence_id"), + result.getString("event_id"), + result.getString("turn_id"), + result.getString("event_type"), + storeRows.readMap(result.getString("data_json")), + result.getBoolean("terminal"), + result.getString("source_key"), + result.getLong("gmt_create"), + result.getInt("schema_version"), + result.getInt("projection_version"), + result.getString("item_id"), + result.getString("content_part_id")); + } + + static final String INSERT_EVENT = "INSERT INTO ma_public_event" + + " (tenant_id, session_id, sequence_id, event_id, turn_id," + + " event_type, data_json, terminal, source_key, gmt_create," + + " schema_version, projection_version, item_id, content_part_id)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + final RowMapper eventMapper; + + List findEvents(String tenantId, String sessionId, + long afterSequence, int limit) { + storeRows.requireSession(tenantId, sessionId); + return jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id = X''" + + " AND tenant_id = ? AND session_id = ? AND sequence_id > ? ORDER BY" + + " sequence_id ASC LIMIT ?", + eventMapper, + tenantId, + sessionId, + afterSequence, + limit); + } + + /** + * The latest environment event of each Session's latest Turn, in one round trip; the Turns are + * already loaded, so the turn-row join of the single-session read is implied. + */ + Map findLatestEnvironmentEvents( + String tenantId, Map latestTurns) { + if (latestTurns.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(latestTurns.size() * 2 + 2); + arguments.add(tenantId); + StringBuilder pairs = new StringBuilder(); + for (TurnSummary turn : latestTurns.values()) { + if (pairs.length() > 0) { + pairs.append(","); + } + pairs.append(" (?, ?)"); + // Bound as identity bytes: H2 types row-comparison parameters + // as VARCHAR, and a command it recompiles after binding (any + // new session or SET in between) refuses VARBINARY rows against + // VARCHAR ones. Bytes compare the same on MySQL and MariaDB. + arguments.add(ManagedSchemaKeys.identity(turn.sessionId())); + arguments.add(ManagedSchemaKeys.identity(turn.turnId())); + } + arguments.add(tenantId); + // One row per session: the latest environment event of its latest + // Turn, selected in SQL instead of shipping every match per pair. + List rows = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns("event") + " FROM ma_public_event event JOIN (SELECT environment.session_id," + + " MAX(environment.sequence_id) AS max_sequence FROM ma_public_event environment WHERE" + + " environment.stream_kind = 'SESSION' AND environment.stream_id = X'' AND environment.tenant_id = ? AND" + + " environment.event_type IN ('environment.provisioning', 'environment.ready'," + + " 'environment.failed') AND (environment.session_id, environment.turn_id) IN (" + + pairs + + ") GROUP BY environment.session_id) latest ON latest.session_id =" + + " event.session_id AND latest.max_sequence = event.sequence_id" + + " WHERE event.stream_kind = 'SESSION' AND event.stream_id = X''" + + " AND event.tenant_id = ?", + eventMapper, + arguments.toArray()); + Map result = new HashMap<>(latestTurns.size() * 2); + for (EventRecord row : rows) { + result.put(row.sessionId(), row); + } + return result; + } + + List findControlEvents(String tenantId, + String sessionId, long throughSequence) { + storeRows.requireSession(tenantId, sessionId); + return jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id = X''" + + " AND tenant_id = ? AND session_id = ? AND sequence_id <= ? AND event_type" + + " NOT IN ('turn.accepted', 'item.output_text.delta', 'item.reasoning.delta'," + + " 'item.tool_call.updated', 'item.tool_result.updated') ORDER BY sequence_id" + + " ASC", + eventMapper, + tenantId, + sessionId, + throughSequence); + } + + EventPage findTranscriptEvents(String tenantId, String sessionId, + Long beforeSequence, int limit) { + storeRows.requireSession(tenantId, sessionId); + List rows = + beforeSequence == null + ? jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? ORDER" + + " BY sequence_id DESC LIMIT ?", + eventMapper, + tenantId, + sessionId, + limit + 1) + : jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" + + " sequence_id < ? ORDER BY sequence_id DESC LIMIT ?", + eventMapper, + tenantId, + sessionId, + beforeSequence, + limit + 1); + boolean hasMore = rows.size() > limit; + if (hasMore) { + rows = new ArrayList<>(rows.subList(0, limit)); + } else { + rows = new ArrayList<>(rows); + } + java.util.Collections.reverse(rows); + return new EventPage(List.copyOf(rows), hasMore); + } + + List appendEvents(String tenantId, + String sessionId, String turnId, String eventEpoch, + long lastSourceId, long sequence, List events, + long now) { + List records = new ArrayList<>(); + long next = sequence; + Identity previous = events.isEmpty() ? null + : findIdentity(tenantId, sessionId, next, + events.getFirst().projection().type()); + for (HarnessEvent event : events) { + ProjectedEvent projection = event.projection(); + previous = EventIdentity.of(projection.type(), turnId, ++next, + projection.data(), previous); + records.add(new EventRecord(tenantId, sessionId, next, + AgentStoreRows.publicId("evt"), turnId, projection.type(), + projection.data(), projection.terminal(), + event.sourceKey(), now, EventIdentity.SCHEMA_VERSION, + EventIdentity.PROJECTION_VERSION, previous.itemId(), + previous.contentPartId())); + } + jdbc.update("UPDATE ma_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + eventEpoch, lastSourceId, now, tenantId, sessionId); + jdbc.update( + "UPDATE ma_event_stream SET last_sequence = ?, gmt_modified = ? WHERE tenant_id = ?" + + " AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", + next, + now, + tenantId, + sessionId); + if (!records.isEmpty()) { + jdbc.batchUpdate(INSERT_EVENT, records, records.size(), + (statement, event) -> { + statement.setString(1, event.tenantId()); + statement.setString(2, event.sessionId()); + statement.setLong(3, event.sequence()); + statement.setString(4, event.eventId()); + statement.setString(5, event.turnId()); + statement.setString(6, event.type()); + statement.setString(7, storeRows.writeJson(event.data())); + statement.setBoolean(8, event.terminal()); + statement.setString(9, event.sourceKey()); + statement.setLong(10, event.createdAt()); + statement.setInt(11, event.schemaVersion()); + statement.setInt(12, event.projectionVersion()); + statement.setString(13, event.itemId()); + statement.setString(14, event.contentPartId()); + }); + } + return List.copyOf(records); + } + + void appendPublicEventIfAbsent(String tenantId, String sessionId, + String turnId, String type, Map data, + boolean terminal, String sourceKey) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (!hasSourceEvent(tenantId, sessionId, sourceKey)) { + appendEvent(tenantId, sessionId, turnId, type, data, terminal, + sourceKey, clock.millis()); + } + } + + void appendLiveSessionEventIfAbsent(String tenantId, + String sessionId, String type, Map data, + String sourceKey) { + storeRows.requireLegacyMutation(tenantId, sessionId); + // A locking read sees the latest committed status, where a plain one + // could still see the snapshot taken before a deletion committed. + Optional session = jdbc.query(AgentStoreRows.SESSION_WITH_STREAM + " WHERE ma_session.tenant_id = ? AND" + + " ma_session.session_kind = 'PUBLIC' AND" + + " ma_session.session_id = ? FOR UPDATE", + storeRows.sessionMapper, tenantId, sessionId).stream() + .findFirst(); + if (session.isEmpty() || "DELETING".equals(session.get().status()) + || "DELETED".equals(session.get().status())) { + return; + } + if (!hasSourceEvent(tenantId, sessionId, sourceKey)) { + appendEvent(tenantId, sessionId, null, type, data, false, + sourceKey, clock.millis()); + } + } + + static Map acceptedData(String turnId, + List> input) { + return Map.of("turnId", turnId, + "itemId", StoreModels.inputItemId(turnId), "input", input); + } + + static String operationSource(String operationId, + String phase) { + return "operation:" + operationId + ":" + phase; + } + + EventRecord appendEvent(String tenantId, String sessionId, + String turnId, String type, Map data, + boolean terminal, String sourceKey, long now) { + Long sequence = + jdbc.queryForObject( + "SELECT last_sequence FROM ma_event_stream WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" + + " UPDATE", + Long.class, + tenantId, + sessionId); + if (sequence == null) { + throw new IllegalStateException("Session sequence is unavailable"); + } + long next = sequence + 1; + Identity identity = EventIdentity.of(type, turnId, next, data, + findIdentity(tenantId, sessionId, sequence, type)); + EventRecord event = new EventRecord(tenantId, sessionId, next, + AgentStoreRows.publicId("evt"), turnId, type, data, terminal, sourceKey, + now, EventIdentity.SCHEMA_VERSION, + EventIdentity.PROJECTION_VERSION, identity.itemId(), + identity.contentPartId()); + jdbc.update("UPDATE ma_event_stream SET last_sequence = ?," + + " gmt_modified = ? WHERE tenant_id = ? AND session_id = ?" + + " AND stream_kind = 'SESSION' AND stream_id = X''", + next, now, tenantId, sessionId); + jdbc.update(INSERT_EVENT, event.tenantId(), event.sessionId(), + event.sequence(), event.eventId(), event.turnId(), + event.type(), storeRows.writeJson(event.data()), event.terminal(), + event.sourceKey(), event.createdAt(), event.schemaVersion(), + event.projectionVersion(), event.itemId(), + event.contentPartId()); + publishAfterCommit(List.of(event)); + return event; + } + + // Emptied deltas name nothing, and a delta that continued one now starts + // a Part of its own, so identities from the first emptied event on are + // derived again as the rebuilt Items will name them. + void reassignIdentity(String tenantId, String sessionId, + long fromSequence) { + List events = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" + + " = X'' AND tenant_id = ? AND session_id = ? AND sequence_id >= ?" + + " ORDER BY sequence_id ASC", + eventMapper, + tenantId, + sessionId, + fromSequence - 1); + Identity previous = null; + for (EventRecord event : events) { + if (event.sequence() < fromSequence) { + previous = new Identity(event.type(), event.itemId(), + event.contentPartId()); + continue; + } + Identity identity = EventIdentity.of(event.type(), + event.turnId(), event.sequence(), event.data(), previous); + if (!Objects.equals(identity.itemId(), event.itemId()) + || !Objects.equals(identity.contentPartId(), + event.contentPartId())) { + jdbc.update( + "UPDATE ma_public_event SET item_id = ?, content_part_id = ? WHERE" + + " tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND" + + " stream_id = X'' AND sequence_id = ?", + identity.itemId(), + identity.contentPartId(), + tenantId, + sessionId, + event.sequence()); + } + previous = identity; + } + } + + // Only a text delta continues the event before it, so other events skip + // the lookup. + Identity findIdentity(String tenantId, String sessionId, + long sequence, String nextType) { + if (!EventIdentity.continuesPrevious(nextType)) { + return null; + } + List rows = + jdbc.query( + "SELECT event_type, item_id, content_part_id FROM ma_public_event WHERE" + + " stream_kind = 'SESSION' AND stream_id = X'' AND tenant_id = ? AND" + + " session_id = ? AND sequence_id = ?", + (result, row) -> + new Identity( + result.getString("event_type"), + result.getString("item_id"), + result.getString("content_part_id")), + tenantId, + sessionId, + sequence); + return rows.isEmpty() ? null : rows.getFirst(); + } + + void publishAfterCommit(List events) { + if (events.isEmpty()) { + return; + } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + eventPublisher.publish(events); + return; + } + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + eventPublisher.publish(events); + } + }); + } + + boolean hasEventType(String tenantId, String sessionId, + String turnId, String type) { + Integer count = + jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND turn_id =" + + " ? AND event_type = ?", + Integer.class, + tenantId, + sessionId, + turnId, + type); + return count != null && count > 0; + } + + boolean hasSourceEvent(String tenantId, String sessionId, + String sourceKey) { + Integer count = + jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND source_key" + + " = ?", + Integer.class, + tenantId, + sessionId, + sourceKey); + return count != null && count > 0; + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentExecutionOperations.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentExecutionOperations.java new file mode 100644 index 00000000000..e04de0eba27 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentExecutionOperations.java @@ -0,0 +1,1015 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.ContextCompressionState; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.CwdChangeOutcome; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.TaskCancelOutcome; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.AcceptanceVerdict; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationAdmission; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationTarget; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ParkedOperation; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +final class AgentExecutionOperations { + private final AgentEventJournal eventJournal; + private final AgentLifecycleOperations lifecycleOperations; + private final AgentSessionPolicies sessionPolicies; + private final AgentSessionQueries sessionQueries; + private final AgentStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles; + private final JdbcTemplate jdbc; + private final Clock clock; + private final ManagedWorkspaceRegistry workspaces; + private final boolean workspaceFilesEnabled; + + AgentExecutionOperations(AgentEventJournal eventJournal, + AgentLifecycleOperations lifecycleOperations, + AgentSessionPolicies sessionPolicies, + AgentSessionQueries sessionQueries, + AgentStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles, + JdbcTemplate jdbc, + Clock clock, + ManagedWorkspaceRegistry workspaces, + boolean workspaceFilesEnabled) { + this.eventJournal = eventJournal; + this.lifecycleOperations = lifecycleOperations; + this.sessionPolicies = sessionPolicies; + this.sessionQueries = sessionQueries; + this.storeRows = storeRows; + this.worktreeFiles = worktreeFiles; + this.jdbc = jdbc; + this.clock = clock; + this.workspaces = workspaces; + this.workspaceFilesEnabled = workspaceFilesEnabled; + } + + // D7-2: RECOVERY_BLOCKED carries two meanings, decided by kind. For + // these kinds it means the command's acceptance is unknown: no claim + // ever takes the row, nothing re-sends it, and only the reconciler + // settles it, from the kind's own keyed evidence. + static final String ACCEPTANCE_UNKNOWN_KINDS = + "('TASK_CANCEL', 'SUBMIT_INPUT', 'CANCEL', 'RENAME')"; + + /** A parked Session title the sealed journal proves never landed. */ + static final String SESSION_TITLE_UNDELIVERED = "session_title_undelivered"; + + OperationAdmission beginContextCompression(String tenantId, String sessionId, + String actorId, String actorDigest, String key, String digest, + long expectedCompactionRevision, boolean supported) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + ContextBinding binding = session.workspace(); + if (binding == null) { + throw new ManagedAgentException(400, "unsupported_feature", + "Context compression requires a Workspace Session."); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, binding.getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + Optional replay = lifecycleOperations.replayOperation(tenantId, sessionId, + OperationKind.CONTEXT_COMPRESSION, actorDigest, key); + if (replay.isPresent()) { + if (!digest.equals(replay.get().requestDigest())) { + throw new ManagedAgentException(409, "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new OperationAdmission(replay.get(), true); + } + if (!access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(403, "session_operation_forbidden", + "Only a Workspace operator may compress Session context."); + } + if (!supported || !workspaceFilesEnabled) { + throw new ManagedAgentException(409, "unsupported_feature", + "Hosted context compression is unavailable."); + } + AgentSessionPolicies.requireSessionStatus(session.status(), "ACTIVE"); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, binding.getStorageId()); + sessionPolicies.requireCwdChangeRegistryFacts(session); + if (contextCompressionState(tenantId, sessionId).compactionRevision() != expectedCompactionRevision) { + throw new ManagedAgentException(409, "compaction_revision_conflict", + "The Session compaction revision has changed."); + } + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + if (sessionPolicies.hasOpenOperation(tenantId, sessionId) || sessionPolicies.hasPendingRenameDelivery(tenantId, sessionId) + || sessionPolicies.hasActiveTurn(tenantId, sessionId) || sessionPolicies.hasDecidableAction(session) + || !jdbc.queryForList("SELECT 1 FROM ma_session_record WHERE session_scope_key = ?" + + " AND record_kind = 'DOMAIN' AND domain IN ('hook_execution', 'history_maintenance')" + + " AND settled_at IS NULL LIMIT 1", scope).isEmpty() + || !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?" + + " AND status = 'RECOVERY_BLOCKED' LIMIT 1", tenantId, sessionId).isEmpty() + || !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ? AND session_id = ?" + + " AND head_kind = 'MANAGED_SESSION' AND (state <> 'ACTIVE' OR recovery_status <> 'READY')", + tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(409, "session_context_busy", + "The Session has an active Turn, approval, Hook, recovery or context operation."); + } + String operationId = AgentStoreRows.publicId("op"); + long now = storeRows.lifecycleDatabaseTime(); + Map request = new LinkedHashMap<>(); + request.put("operationId", operationId); + request.put("expectedCompactionRevision", expectedCompactionRevision); + var model = sessionQueries.findModelSelections(tenantId, List.of(sessionId)).get(sessionId); + if (model != null && model.modelId() != null) { + request.put("modelSelection", Map.of("modelId", model.modelId(), "revision", model.revision())); + } + jdbc.update("INSERT INTO ma_operation (operation_key, scope_key, session_scope_key, target_kind," + + " target_key, target_id, phase, request_json, idempotency_digest, tenant_id, session_id," + + " operation_id, operation_kind, actor_digest, idempotency_key, request_digest, state," + + " admission_stage, delivery_state, session_status_before, actor_key, available_at," + + " gmt_create, gmt_modified) VALUES (?, ?, ?, 'SESSION', ?, ?, 'PENDING', ?, ?, ?, ?, ?," + + " 'CONTEXT_COMPRESSION', ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?, ?)", + AgentStoreRows.sessionOperationKey(tenantId, sessionId, OperationKind.CONTEXT_COMPRESSION, operationId), + scope, scope, AgentStoreRows.sessionOperationTarget(sessionId), sessionId, storeRows.writeJson(request), + ManagedSchemaKeys.key(key), tenantId, sessionId, operationId, actorDigest, key, digest, + session.status(), com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId), now, now, now); + return new OperationAdmission(lifecycleOperations.findOperation(tenantId, sessionId, operationId).orElseThrow(), false); + } + + ContextCompressionState contextCompressionState(String tenantId, String sessionId) { + ensureCompactionProjection(tenantId, sessionId); + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + List revisions = jdbc.queryForList("SELECT revision FROM ma_session_record WHERE" + + " session_scope_key = ? AND record_key = ? AND record_kind = 'DOMAIN'", Long.class, + scope, ManagedExtensionProjection.recordKey(sessionId, "hosted_context_compaction", "latest")); + List operations = jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE" + + " scope_key = ? AND operation_kind = 'CONTEXT_COMPRESSION' AND target_key = ?" + + " AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') ORDER BY gmt_create LIMIT 1", + String.class, scope, AgentStoreRows.sessionOperationTarget(sessionId)); + return new ContextCompressionState(revisions.isEmpty() ? 0 : revisions.getFirst(), + operations.isEmpty() ? null : operations.getFirst()); + } + + Map contextCompressionStates(String tenantId, List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(); + arguments.add(tenantId); + sessionIds.forEach(id -> arguments.add(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, id))); + Map result = new HashMap<>(); + Set indexed = new HashSet<>(); + sessionIds.forEach(id -> result.put(id, new ContextCompressionState(0, null))); + jdbc.query("SELECT session_id, revision FROM ma_session_record WHERE tenant_id = ?" + + " AND session_scope_key IN (" + AgentStoreRows.placeholders(sessionIds.size()) + ")" + + " AND record_kind = 'DOMAIN' AND domain = 'hosted_context_compaction'", + (RowCallbackHandler) row -> { + indexed.add(row.getString("session_id")); + result.put(row.getString("session_id"), new ContextCompressionState(row.getLong("revision"), null)); + }, arguments.toArray()); + List missing = sessionIds.stream().filter(id -> !indexed.contains(id)).toList(); + if (!missing.isEmpty()) { + List heads = new ArrayList<>(); + heads.add(tenantId); + heads.addAll(missing); + for (String id : jdbc.queryForList("SELECT session_id FROM ma_session_head WHERE tenant_id = ?" + + " AND session_id IN (" + AgentStoreRows.placeholders(missing.size()) + ") AND head_kind = 'MANAGED_SESSION'", + String.class, heads.toArray())) { + try { + result.put(id, contextCompressionState(tenantId, id)); + } catch (ManagedAgentException unavailable) { + if (!"context_compression_state_unavailable".equals(unavailable.getCode())) { + throw unavailable; + } + result.put(id, new ContextCompressionState(0, null, false)); + } + } + } + jdbc.query("SELECT session_id, operation_id FROM ma_operation WHERE tenant_id = ?" + + " AND scope_key IN (" + AgentStoreRows.placeholders(sessionIds.size()) + ")" + + " AND operation_kind = 'CONTEXT_COMPRESSION' AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED')", + (RowCallbackHandler) row -> { + String id = row.getString("session_id"); + result.put(id, new ContextCompressionState(result.get(id).compactionRevision(), + row.getString("operation_id"), result.get(id).available())); + }, arguments.toArray()); + return result; + } + + void ensureCompactionProjection(String tenantId, String sessionId) { + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + String key = ManagedExtensionProjection.recordKey(sessionId, "hosted_context_compaction", "latest"); + if (!jdbc.queryForList("SELECT 1 FROM ma_session_record WHERE session_scope_key = ? AND record_key = ?", + scope, key).isEmpty()) { + return; + } + var transactions = new org.springframework.transaction.support.TransactionTemplate( + new org.springframework.jdbc.datasource.DataSourceTransactionManager(jdbc.getDataSource())); + transactions.executeWithoutResult(status -> backfillCompactionProjection(tenantId, sessionId, scope, key)); + } + + void backfillCompactionProjection(String tenantId, String sessionId, String scope, String key) { + var heads = jdbc.queryForList("SELECT workspace_id, journal_revision, compacted_through_revision FROM" + + " ma_session_head WHERE tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", + tenantId, sessionId); + if (heads.isEmpty() || ((Number) heads.getFirst().get("journal_revision")).longValue() == 0) { + return; + } + var head = heads.getFirst(); + if (((Number) head.get("compacted_through_revision")).longValue() != 0) { + throw compressionStateUnavailable(); + } + long through = ((Number) head.get("journal_revision")).longValue(); + long scanned = 0; + com.fasterxml.jackson.databind.JsonNode latest = null; + while (scanned < through) { + var page = jdbc.queryForList("SELECT journal_revision, record_bytes, byte_length, record_digest FROM" + + " ma_session_journal_tx WHERE tenant_id = ? AND session_id = ? AND journal_revision > ?" + + " AND journal_revision <= ? ORDER BY journal_revision LIMIT 16", tenantId, sessionId, scanned, through); + if (page.isEmpty()) { + throw compressionStateUnavailable(); + } + for (var row : page) { + byte[] bytes = (byte[]) row.get("record_bytes"); + long revision = ((Number) row.get("journal_revision")).longValue(); + if (revision != scanned + 1 || bytes == null + || bytes.length != ((Number) row.get("byte_length")).longValue() + || !DigestUtils.sha256Hex(bytes).equals(row.get("record_digest"))) { + throw compressionStateUnavailable(); + } + for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { + var record = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(line); + if (record == null) { + throw compressionStateUnavailable(); + } + var event = record.path("managedSession"); + if ("context.compacted".equals(event.path("kind").asText())) { + latest = event; + } + } + scanned = revision; + } + } + String workspace = (String) head.get("workspace_id"); + long revision = latest == null ? 0 : latest.path("sequence").asLong(); + String resourceId; + if (latest == null) { + byte[] empty = "{}".getBytes(StandardCharsets.UTF_8); + String digest = DigestUtils.sha256Hex(empty); + resourceId = "compaction-state:" + digest; + jdbc.update("INSERT IGNORE INTO ma_resource (resource_key, session_scope_key, tenant_id, workspace_id," + + " session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes," + + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 2, ?, 'MYSQL_INLINE', ?," + + " 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", + ManagedSchemaKeys.key(scope, resourceId), scope, tenantId, workspace, sessionId, resourceId, + "managed-context-compaction-empty", digest, empty); + } else { + var ref = latest.path("payload").path("summaryRef"); + try { + ManagedExtensionRecords.durableRef(ref, "compaction.summaryRef"); + } catch (IllegalArgumentException error) { + throw compressionStateUnavailable(); + } + resourceId = ref.path("resourceId").asText(); + var resources = jdbc.queryForList("SELECT sha256, byte_length, kind FROM ma_resource WHERE" + + " session_scope_key = ? AND resource_id = ? AND state IN ('REFERENCED', 'PUBLISHED')", + scope, resourceId); + if (resources.size() != 1 || !ref.path("digest").asText().equals(resources.getFirst().get("sha256")) + || !ref.path("kind").asText().equals(resources.getFirst().get("kind")) + || ref.path("byteLength").asLong() != ((Number) resources.getFirst().get("byte_length")).longValue()) { + throw compressionStateUnavailable(); + } + } + long now = storeRows.lifecycleDatabaseTime(); + int inserted = jdbc.update("INSERT IGNORE INTO ma_session_record (session_scope_key, record_key, tenant_id," + + " workspace_id, session_id, domain, record_id, revision, record_resource_id, gmt_create, settled_at," + + " first_sequence) VALUES (?, ?, ?, ?, ?, 'hosted_context_compaction', ?, ?, ?, ?, ?, ?)", + scope, key, tenantId, workspace, sessionId, "latest", revision, resourceId, now, now, revision); + if (inserted == 1) { + jdbc.update("INSERT IGNORE INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision," + + " resource_key, tenant_id, workspace_id, session_id, resource_id, gmt_create) VALUES" + + " (?, 'SESSION_RECORD', ?, ?, ?, ?, ?, ?, ?, ?)", scope, key, revision, + ManagedSchemaKeys.key(scope, resourceId), tenantId, workspace, sessionId, resourceId, now); + } + } + + static ManagedAgentException compressionStateUnavailable() { + return new ManagedAgentException(409, "context_compression_state_unavailable", + "The retained journal cannot establish the Session compaction revision."); + } + + Map contextCompressionRequest(String tenantId, String sessionId, String operationId) { + return jdbc.queryForList("SELECT request_json FROM ma_operation WHERE scope_key = ?" + + " AND operation_kind = 'CONTEXT_COMPRESSION' AND operation_id = ?", String.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), operationId).stream() + .findFirst().map(storeRows::readMap).orElse(Map.of()); + } + + List findContextCompressions(int limit) { + return jdbc.query("SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" + + " operation_kind = 'CONTEXT_COMPRESSION' AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED')" + + " AND available_at <= " + ManagedSchemaTime.SQL_NOW + " AND (claim_owner IS NULL OR claim_until < " + + ManagedSchemaTime.SQL_NOW + ") ORDER BY available_at LIMIT ?", lifecycleOperations.operationTargetMapper, limit); + } + + Optional claimContextCompression(String tenantId, String sessionId, + String operationId, String owner, Duration duration) { + storeRows.requireLegacyMutation(tenantId, sessionId); + int changed = jdbc.update("UPDATE ma_operation SET state = 'RUNNING', delivery_state = 'LEASED'," + + " claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?)," + + " claim_generation = claim_generation + 1, attempt_count = attempt_count + 1 WHERE" + + " scope_key = ? AND operation_kind = 'CONTEXT_COMPRESSION' AND operation_id = ?" + + " AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') AND available_at <= " + + ManagedSchemaTime.SQL_NOW + " AND (claim_owner IS NULL OR claim_until < " + ManagedSchemaTime.SQL_NOW + ")", + owner, duration.toMillis(), com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), operationId); + return changed == 1 ? lifecycleOperations.findOperation(tenantId, sessionId, operationId) : Optional.empty(); + } + + void requireContextCompressionCommit(String tenantId, String sessionId, String operationId) { + OperationRecord operation = lifecycleOperations.findOperation(tenantId, sessionId, operationId).orElse(null); + if (operation == null || operation.kind() != OperationKind.CONTEXT_COMPRESSION) { + return; + } + SessionRecord session = storeRows.requireSession(tenantId, sessionId); + if (!List.of("PENDING", "RUNNING", "RECOVERY_BLOCKED").contains(operation.state()) + || session.workspace() == null || !"ACTIVE".equals(session.status()) + || operation.actorKey() == null || !initiatorKeepsOperate(session, operation)) { + throw new ManagedAgentException(409, "context_compression_authorization_revoked", + "The compression initiator no longer has execution authority."); + } + sessionPolicies.requireCwdChangeRegistryFacts(session); + } + + boolean markContextCompressionSubmitted(OperationRecord operation, String owner) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(operation.tenantId(), operation.sessionId()); + if (!"ACTIVE".equals(session.status()) || session.workspace() == null + || !initiatorKeepsOperate(session, operation) + || !sessionQueries.hasExecutionRegistryFacts(session.tenantId(), session.sessionId())) { + settleContextCompression(operation, owner, "failed", null, "workspace_unavailable"); + return false; + } + return jdbc.update("UPDATE ma_operation SET admission_stage = 'HARNESS_SUBMITTED' WHERE" + + compressionClaim(), compressionArguments(operation, owner)) == 1; + } + + boolean settleContextCompression(OperationRecord operation, String owner, + String outcome, Long revision, String failureCode) { + boolean failed = "failed".equals(outcome); + Map result = new LinkedHashMap<>(); + result.put("outcome", outcome); + if (revision != null) { + result.put("compactionRevision", revision); + } + List args = new ArrayList<>(List.of(failed ? "FAILED" : "COMPLETED", + failed ? "FAILED" : "COMPLETED", storeRows.writeJson(result), AgentStoreRows.publicId("rcpt"))); + args.add(failureCode); + args.addAll(List.of(compressionArguments(operation, owner))); + return jdbc.update("UPDATE ma_operation SET state = ?, phase = ?, result_json = ?, receipt_id = ?," + + " failure_code = ?, admission_stage = 'HARNESS_CONFIRMED', delivery_state = 'CONFIRMED'," + + " claim_owner = NULL, claim_until = NULL, completed_at = " + ManagedSchemaTime.SQL_NOW + + ", gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim(), args.toArray()) == 1; + } + + void retryContextCompressionPreparation(OperationRecord operation, String owner, long retryAt) { + List args = new ArrayList<>(); + args.add(retryAt); + args.addAll(List.of(compressionArguments(operation, owner))); + jdbc.update("UPDATE ma_operation SET state = 'PENDING', admission_stage = 'JAVA_DURABLE'," + + " delivery_state = 'BLOCKED', available_at = ?, claim_owner = NULL, claim_until = NULL," + + " gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim() + + " AND admission_stage = 'JAVA_DURABLE'", args.toArray()); + } + + void awaitContextCompression(OperationRecord operation, String owner, + boolean acceptanceKnown, String failureCode, long retryAt) { + List args = new ArrayList<>(); + args.add(acceptanceKnown && failureCode == null ? "RUNNING" : "RECOVERY_BLOCKED"); + args.add(acceptanceKnown ? "HARNESS_ACCEPTED" : "HARNESS_SUBMITTED"); + args.add(acceptanceKnown ? failureCode : "context_compression_acceptance_unknown"); + args.add(retryAt); + args.addAll(List.of(compressionArguments(operation, owner))); + jdbc.update("UPDATE ma_operation SET state = ?, admission_stage = ?, failure_code = ?," + + " delivery_state = 'BLOCKED', available_at = ?, claim_owner = NULL, claim_until = NULL," + + " gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim(), args.toArray()); + } + + static String compressionClaim() { + return " operation_kind = 'CONTEXT_COMPRESSION' AND tenant_id = ? AND session_id = ?" + + " AND operation_id = ? AND state = 'RUNNING' AND claim_owner = ? AND claim_generation = ?" + + " AND claim_until > " + ManagedSchemaTime.SQL_NOW; + } + + static Object[] compressionArguments(OperationRecord operation, String owner) { + return new Object[] {operation.tenantId(), operation.sessionId(), operation.operationId(), owner, + operation.claimGeneration()}; + } + + CwdChangeOutcome completeCwdChangeOperation(String tenantId, + String sessionId, String operationId, String owner, + long claimGeneration) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + OperationRecord operation = lifecycleOperations.findSessionOperation(tenantId, sessionId, operationId, true) + .orElseThrow(() -> new IllegalStateException("Session operation is unavailable")); + if (operation.kind() != OperationKind.CWD_CHANGE) { + throw new IllegalStateException("Operation " + operationId + + " is not a cwd change"); + } + Boolean leaseLive = + jdbc.queryForObject( + "SELECT claim_until > " + ManagedSchemaTime.SQL_NOW + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ? FOR UPDATE", + Boolean.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + if (!Boolean.TRUE.equals(leaseLive) + || !"RUNNING".equals(operation.state()) + || !"LEASED".equals(operation.deliveryState()) + || !owner.equals(operation.leaseOwner()) + || operation.claimGeneration() != claimGeneration) { + return null; + } + long now = storeRows.lifecycleDatabaseTime(); + ContextBinding binding = session.workspace(); + long expected = operation.expectedContextRevision() == null ? -1 + : operation.expectedContextRevision(); + String failure = null; + if (binding == null || !"ACTIVE".equals(session.status()) + || WorkspaceMigrationAdmission.owner(jdbc, tenantId, + binding.getStorageId()) != null) { + failure = "workspace_unavailable"; + } else if (binding.getContextRevision() != expected) { + failure = "context_revision_conflict"; + } else if (sessionPolicies.hasOpenOperation(tenantId, sessionId, operationId) + || sessionPolicies.hasActiveTurn(tenantId, sessionId) + || sessionPolicies.hasDecidableAction(session) + || sessionPolicies.hasRetainedRuntimeSession(tenantId, sessionId)) { + failure = "session_context_busy"; + } else if (!sessionQueries.hasExecutionRegistryFacts(session.tenantId(), + session.sessionId()) || !initiatorKeepsOperate(session, + operation)) { + failure = "workspace_unavailable"; + } + if (failure == null) { + try { + SessionWorktreeStore.requireReady(worktreeFiles, jdbc, tenantId, sessionId); + SessionWorktreeStore.requireCwd(jdbc, tenantId, sessionId, operation.targetCwdRelative()); + } catch (ManagedAgentException error) { + failure = error.getCode(); + } + } + if (failure != null) { + jdbc.update( + "UPDATE ma_operation SET phase = 'FAILED', state =" + + " 'FAILED', delivery_state = 'CONFIRMED'," + + " failure_code = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " completed_at = ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", + failure, + now, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + return new CwdChangeOutcome(false, failure, null); + } + long result = expected + 1; + jdbc.update("UPDATE ma_session SET cwd_relative = ?," + + " context_revision = ?, gmt_modified = ?, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + operation.targetCwdRelative(), result, now, tenantId, + sessionId); + jdbc.update( + "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED', delivery_state =" + + " 'CONFIRMED', receipt_id = ?, result_context_revision = ?, claim_owner =" + + " NULL, claim_until = NULL, gmt_modified = ?, completed_at = ? WHERE scope_key" + + " = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", + AgentStoreRows.publicId("rcpt"), + result, + now, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + Map data = new LinkedHashMap<>(); + data.put("sessionId", sessionId); + data.put("operationId", operationId); + data.put("workspaceId", binding.getWorkspaceId()); + data.put("cwdRelative", operation.targetCwdRelative()); + data.put("contextRevision", result); + eventJournal.appendEvent(tenantId, sessionId, null, "session.context.changed", + Map.copyOf(data), false, AgentEventJournal.operationSource(operationId, + "completed"), now); + return new CwdChangeOutcome(true, null, result); + } + + boolean failCwdChangeOperation(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + String failureCode) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + return jdbc.update( + "UPDATE ma_operation SET phase = 'FAILED', state =" + + " 'FAILED', delivery_state = 'CONFIRMED'," + + " failure_code = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " completed_at = ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + failureCode, + now, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + owner, + claimGeneration) + == 1; + } + + OperationAdmission beginTaskCancelOperation(String tenantId, + String sessionId, String taskId, String actorDigest, + String idempotencyKey, String requestDigest) { + // The caller has already validated the key and checked current + // access (404, then 403 task_forbidden): those run first by + // contract, so a revoked caller never replays. Under the Session + // lock the retained key replays before any new-request check, so + // a lost 202 survives capability and state changes; only a new + // request rechecks the Session state, the task's cancel action + // and the one-open-operation rule, atomically with its insert. + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + Optional existing = lifecycleOperations.replayOperation(tenantId, sessionId, + OperationKind.TASK_CANCEL, actorDigest, idempotencyKey); + if (existing.isPresent()) { + if (!existing.get().requestDigest().equals(requestDigest)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new OperationAdmission(existing.get(), true); + } + if ("DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", + "The Session was not found."); + } + if (!"ACTIVE".equals(session.status())) { + throw new ManagedAgentException(409, "session_not_active", + "The Session is " + session.status().toLowerCase( + java.util.Locale.ROOT) + + " and accepts no task cancellation."); + } + // The task row is the projection its own commits update, so the + // locking read serializes this admission with the transition that + // would settle the task. + List task = jdbc.query("SELECT task_kind, task_state FROM" + + " ma_session_record WHERE" + + " session_scope_key = ? AND record_key = ?" + + " AND task_kind IS NOT NULL FOR UPDATE", + (result, row) -> new String[] {result.getString("task_kind"), + result.getString("task_state")}, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + taskId.substring("task_".length())); + if (task.isEmpty()) { + throw new ManagedAgentException(404, "task_not_found", + "The task was not found."); + } + if (!ManagedExtensionProjection.taskActions(task.getFirst()[0], + task.getFirst()[1]).contains("cancel")) { + throw new ManagedAgentException(409, + "task_action_unavailable", + "The task does not accept a cancellation now."); + } + // A bound Session under storage migration admits no new work, as + // every sibling bound admission refuses it: a cancel admitted + // behind the fence would wedge the migration's idle checks. + if (session.workspace() != null) { + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, + session.workspace().getStorageId()); + } + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + String operationId = AgentStoreRows.publicId("op"); + jdbc.update( + "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," + + " target_kind, target_key, target_id, phase, request_json," + + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," + + " actor_digest, idempotency_key, request_digest, state, admission_stage," + + " delivery_state, session_status_before, task_id, available_at, gmt_create," + + " gmt_modified) VALUES (?, ?, ?, 'SESSION', ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?," + + " ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?, ?)", + AgentStoreRows.sessionOperationKey(tenantId, sessionId, OperationKind.TASK_CANCEL, operationId), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + sessionId, + storeRows.writeJson( + Map.of( + "schemaVersion", + 1, + "kind", + "TASK_CANCEL", + "sessionId", + sessionId, + "taskId", + taskId)), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + idempotencyKey), + tenantId, + sessionId, + operationId, + OperationKind.TASK_CANCEL.name(), + actorDigest, + idempotencyKey, + requestDigest, + session.status(), + taskId, + now, + now, + now); + return new OperationAdmission(lifecycleOperations.findOperation(tenantId, sessionId, + operationId).orElseThrow(), false); + } + + List findDeliverableTaskCancels(int limit) { + long now = storeRows.lifecycleDatabaseTime(); + List targets = + new ArrayList<>( + jdbc.query( + "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" + + " operation_kind = 'TASK_CANCEL' AND state IN ('PENDING'," + + " 'RUNNING') AND delivery_state = 'PENDING' AND available_at" + + " <= ? AND EXISTS (SELECT 1 FROM ma_session private_scope" + + " WHERE private_scope.tenant_id = ma_operation.tenant_id AND" + + " private_scope.session_id = ma_operation.session_id AND" + + " private_scope.csi_guard = FALSE) ORDER BY available_at" + + " LIMIT ?", + lifecycleOperations.operationTargetMapper, + now, + limit)); + if (targets.size() < limit) { + targets.addAll(jdbc.query( + "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind = 'TASK_CANCEL' AND delivery_state = 'LEASED' AND claim_until < " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY claim_until LIMIT ?", + lifecycleOperations.operationTargetMapper, limit - targets.size())); + } + return List.copyOf(targets); + } + + List findParkedOperations(int limit) { + // Kind and state lead the due index, so the per-second scan reads + // only parked rows, never a kind's settled history. + return jdbc.query( + "SELECT tenant_id, session_id, operation_id, operation_kind FROM ma_operation" + + " WHERE operation_kind IN " + ACCEPTANCE_UNKNOWN_KINDS + " AND state =" + + " 'RECOVERY_BLOCKED' AND available_at <= ? AND delivery_state = 'BLOCKED'" + + " AND EXISTS (SELECT 1 FROM ma_session s WHERE s.tenant_id =" + + " ma_operation.tenant_id AND s.session_id = ma_operation.session_id AND" + + " s.csi_guard = FALSE) ORDER BY available_at LIMIT ?", + (result, row) -> new ParkedOperation(result.getString("tenant_id"), + result.getString("session_id"), result.getString("operation_id"), + result.getString("operation_kind")), + storeRows.lifecycleDatabaseTime(), limit); + } + + boolean settleParkedOperation(ParkedOperation parked, + AcceptanceVerdict verdict, long retryAt) { + storeRows.requireLegacyMutation(parked.tenantId(), parked.sessionId()); + long now = storeRows.lifecycleDatabaseTime(); + List arguments = new ArrayList<>(); + String update = switch (verdict.acceptance()) { + case COMMITTED -> { + arguments.add(AgentStoreRows.publicId("rcpt")); + arguments.add(now); + arguments.add(now); + yield "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED'," + + " admission_stage = 'HARNESS_CONFIRMED'," + + " delivery_state = 'CONFIRMED', receipt_id = ?," + + " failure_code = NULL, gmt_modified = ?, completed_at = ?"; + } + case NOT_COMMITTED -> { + // Terminal yet blocked, as the contract has it: available_at + // moves past every scan of the blocked range for good. + arguments.add(verdict.failureCode()); + arguments.add(Long.MAX_VALUE); + arguments.add(now); + arguments.add(now); + yield "UPDATE ma_operation SET phase = 'FAILED', state = 'FAILED'," + + " admission_stage = 'JAVA_DURABLE'," + + " delivery_state = 'BLOCKED', failure_code = ?," + + " available_at = ?, gmt_modified = ?, completed_at = ?"; + } + case UNKNOWN -> { + arguments.add(Math.addExact(now, + Math.max(0, retryAt - clock.millis()))); + arguments.add(now); + yield "UPDATE ma_operation SET available_at = ?, gmt_modified = ?," + + " attempt_count = attempt_count + 1"; + } + }; + arguments.addAll(List.of(parked.kind(), parked.operationId(), + parked.tenantId(), parked.sessionId())); + // Only a still-parked row: a reconciliation never rewrites an + // outcome already recorded, and no claim holds a parked row. + return jdbc.update(update + " WHERE operation_kind = ? AND operation_id = ?" + + " AND tenant_id = ? AND session_id = ? AND operation_kind IN " + + ACCEPTANCE_UNKNOWN_KINDS + " AND state = 'RECOVERY_BLOCKED' AND" + + " delivery_state = 'BLOCKED' AND claim_owner IS NULL", + arguments.toArray()) == 1; + } + + /** + * The Harness admits a prompt by committing {@code submitInput} to the + * Session journal under the prompt's id, so the journal row keyed by that + * command is the admission's own evidence. Its absence proves nothing + * while a writer may still commit it; once the head refuses every writer + * (DELETING or DELETED) and compaction has not started, the journal is + * complete and the absence is proof. Both facts come from one statement, + * whose single read view holds under any isolation level: a commit that + * preceded the deletion is visible beside it, and no commit can land + * between the two reads, as it could between two statements under READ + * COMMITTED and turn an admitted input into a failed one. + */ + AcceptanceVerdict turnInputAcceptance(String tenantId, + String sessionId, String operationId) { + String turnId = jdbc.queryForList("SELECT target_id FROM ma_operation WHERE" + + " scope_key = ? AND operation_kind = 'SUBMIT_INPUT' AND operation_id = ?", + String.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + operationId).stream().findFirst().orElse(null); + TurnRecord turn = turnId == null ? null + : storeRows.findTurn(tenantId, sessionId, turnId).orElse(null); + if (turn == null) { + return AcceptanceVerdict.unknown(); + } + if (turn.harnessEventEpoch() != null) { + return AcceptanceVerdict.committed(); + } + JournalEvidence evidence = journalEvidence(tenantId, sessionId, + "submitInput", turn.promptId()); + if (evidence.admittedDigest() != null) { + if (("sha256:" + evidence.admittedDigest()).equals(turn.payloadDigest())) { + return AcceptanceVerdict.committed(); + } + AgentStoreRows.LOG.warn("Journal admission digest differs from its Turn tenant={}" + + " session={} turn={}", tenantId, sessionId, turnId); + return AcceptanceVerdict.unknown(); + } + return evidence.sealed() ? AcceptanceVerdict.notCommitted(turn.errorCode()) + : AcceptanceVerdict.unknown(); + } + + record JournalEvidence(String admittedDigest, boolean sealed) { + } + + /** + * The Session journal's own record of one keyed command: the content + * digest it was admitted with, if any, and whether the journal is sealed + * complete (no writer can still commit, compaction has not started). + */ + JournalEvidence journalEvidence(String tenantId, String sessionId, + String operation, String commandId) { + return jdbc.queryForObject("SELECT (SELECT content_digest" + + " FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " AND command_key_hash = ?) AS admitted_digest, (SELECT COUNT(*) FROM" + + " ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" + + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'" + + " AND state IN ('DELETING', 'DELETED') AND" + + " COALESCE(compacted_through_revision, 0) = 0) AS sealed", + (result, row) -> new JournalEvidence(result.getString("admitted_digest"), + result.getLong("sealed") > 0), + tenantId, sessionId, + ManagedSessionStore.commandKeyHash(operation, commandId), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, + sessionId); + } + + /** + * The Hosted Harness commits a fenced title to the Session journal as + * {@code renameSession} under {@code hosted-title:}, with the + * title's SHA-256 as its content digest. That commit is the delivery's + * own evidence, read in one statement beside the head exactly as a Turn + * input's admission is. + */ + Optional renameAcceptance(ParkedOperation parked) { + var rows = jdbc.queryForList("SELECT target_revision, request_json FROM ma_operation" + + " WHERE scope_key = ? AND operation_kind = 'RENAME' AND operation_id = ?" + + " AND tenant_id = ? AND session_id = ? AND state = 'RECOVERY_BLOCKED'", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(parked.tenantId(), parked.sessionId()), + parked.operationId(), parked.tenantId(), parked.sessionId()); + if (rows.isEmpty()) { + return Optional.empty(); + } + long revision = ((Number) rows.getFirst().get("target_revision")).longValue(); + String title = (String) storeRows.readMap((String) rows.getFirst().get("request_json")).get("title"); + JournalEvidence evidence = journalEvidence(parked.tenantId(), parked.sessionId(), + "renameSession", "hosted-title:" + revision); + // A row without its title can still be proven undelivered, never + // matched (review of #14475). + if (evidence.admittedDigest() != null) { + if (title != null && DigestUtils.sha256Hex(title).equals(evidence.admittedDigest())) { + return Optional.of(AcceptanceVerdict.committed()); + } + AgentStoreRows.LOG.warn("Journal title digest differs from its delivery tenant={}" + + " session={} revision={}", parked.tenantId(), parked.sessionId(), revision); + return Optional.of(AcceptanceVerdict.unknown()); + } + return Optional.of(evidence.sealed() + ? AcceptanceVerdict.notCommitted(SESSION_TITLE_UNDELIVERED) + : AcceptanceVerdict.unknown()); + } + + boolean settleParkedRename(ParkedOperation parked, AcceptanceVerdict verdict, + long retryAt) { + String tenantId = parked.tenantId(); + String sessionId = parked.sessionId(); + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + // Only a still-parked row: the claim's own late reply or a later + // title may have settled it since the evidence was read. + var rows = jdbc.queryForList("SELECT operation_key, idempotency_key, request_json" + + " FROM ma_operation WHERE scope_key = ? AND operation_kind = 'RENAME'" + + " AND operation_id = ? AND tenant_id = ? AND session_id = ? AND state =" + + " 'RECOVERY_BLOCKED' AND delivery_state = 'BLOCKED' FOR UPDATE", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), parked.operationId(), + tenantId, sessionId); + if (rows.isEmpty()) { + return false; + } + String operationKey = (String) rows.getFirst().get("operation_key"); + String key = (String) rows.getFirst().get("idempotency_key"); + String receipt = ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", key); + long now = clock.millis(); + switch (verdict.acceptance()) { + case COMMITTED -> { + // The Harness holds the title, so the Session shows it in + // whatever status a lifecycle has since reached, unless a + // deletion took the Session away. A later title would have + // superseded this row, so it is still the newest. + String title = (String) storeRows.readMap((String) rows.getFirst().get("request_json")) + .get("title"); + if (title != null && !List.of("DELETING", "DELETED").contains(session.status())) { + jdbc.update("UPDATE ma_session SET title = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND session_id = ?", + title, now, tenantId, sessionId); + eventJournal.appendEvent(tenantId, sessionId, null, + AgentSessionCommands.mutationEvent(SessionMutationKind.RENAME, "completed"), + Map.of("sessionId", sessionId, "metadata", Map.of("title", title)), + false, AgentSessionCommands.mutationSource("RENAME_SESSION", key, "completed"), now); + } + jdbc.update("UPDATE ma_request_receipt SET state = 'COMPLETED', gmt_modified = ?" + + " WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'", + now, receipt); + jdbc.update("UPDATE ma_operation SET state = 'COMPLETED', phase = 'COMPLETED'," + + " admission_stage = 'HARNESS_CONFIRMED', delivery_state = 'COMPLETED'," + + " failure_code = NULL, claim_owner = NULL, claim_until = NULL," + + " gmt_modified = ?, completed_at = ? WHERE operation_key = ?", + now, now, operationKey); + } + case NOT_COMMITTED -> { + // Terminal yet blocked, as every proven non-commit is: its + // available_at moves past every scan for good. + jdbc.update("UPDATE ma_request_receipt SET state = 'FAILED', gmt_modified = ?" + + " WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'" + + " AND state = 'PENDING'", + now, receipt); + jdbc.update("UPDATE ma_operation SET state = 'FAILED', phase = 'FAILED'," + + " admission_stage = 'JAVA_DURABLE', delivery_state = 'BLOCKED'," + + " failure_code = ?, available_at = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?, completed_at = ?" + + " WHERE operation_key = ?", + verdict.failureCode(), Long.MAX_VALUE, now, now, operationKey); + } + case UNKNOWN -> jdbc.update("UPDATE ma_operation SET available_at = ?, gmt_modified = ?," + + " attempt_count = attempt_count + 1 WHERE operation_key = ?", + Math.addExact(storeRows.lifecycleDatabaseTime(), Math.max(0, retryAt - now)), now, + operationKey); + } + return true; + } + + boolean settleTaskCancel(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + TaskCancelOutcome outcome, long retryAt) { + if (owner == null) { + throw new IllegalArgumentException( + "A parked task cancel settles through settleParkedOperation"); + } + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + // A leased claim settles only while it is still this worker's. + String claim = " AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + ""; + List arguments = new ArrayList<>(); + String update = switch (outcome.status()) { + case "completed" -> { + arguments.add(AgentStoreRows.publicId("rcpt")); + arguments.add(now); + arguments.add(now); + yield "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED'," + + " admission_stage = 'HARNESS_CONFIRMED'," + + " delivery_state = 'CONFIRMED', receipt_id = ?," + + " failure_code = NULL, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " completed_at = ?"; + } + case "failed" -> { + // A failed cancel is terminal yet keeps the contract's + // blocked delivery state: its available_at moves past + // every scan of the blocked index range for good. + arguments.add(outcome.failureCode()); + arguments.add(Long.MAX_VALUE); + arguments.add(now); + arguments.add(now); + yield "UPDATE ma_operation SET phase = 'FAILED', state = 'FAILED'," + + " admission_stage = 'JAVA_DURABLE'," + + " delivery_state = 'BLOCKED', failure_code = ?," + + " available_at = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " completed_at = ?"; + } + case "recovery_blocked" -> { + arguments.add(outcome.failureCode()); + arguments.add(Math.addExact(now, + Math.max(0, retryAt - clock.millis()))); + arguments.add(now); + yield "UPDATE ma_operation SET" + + " phase = 'RECOVERY_BLOCKED', state = 'RECOVERY_BLOCKED'," + + " admission_stage = 'JAVA_DURABLE'," + + " delivery_state = 'BLOCKED', failure_code = ?," + + " available_at = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " attempt_count = attempt_count + 1"; + } + default -> throw new IllegalArgumentException( + "Unknown task cancel outcome " + outcome.status()); + }; + arguments.addAll(List.of(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), AgentStoreRows.sessionOperationTarget(sessionId), tenantId, sessionId, operationId, owner, claimGeneration)); + return jdbc.update( + update + + " WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ? AND operation_kind = 'TASK_CANCEL'" + + claim, + arguments.toArray()) + == 1; + } + + // The initiator, persisted at V56 admission: settlement fails when the + // operating actor no longer holds OPERATOR — revoking one in-flight + // initiator stops only their own admitted change, as the W2 guard + // intends. A pre-V56 (NULL key) row settles on the creator-keyed facts + // alone. The vocabulary filter keeps an out-of-enum stored role (only + // reachable by an out-of-band write past V53's CHECK) on the same + // fail-closed workspace_unavailable verdict as a revocation, rather + // than an IllegalArgumentException looping through the retry. + boolean initiatorKeepsOperate(SessionRecord session, + OperationRecord operation) { + if (operation.actorKey() == null) { + return true; + } + List roles = jdbc.queryForList("SELECT role FROM" + + " ma_workspace_access WHERE tenant_id = ? AND" + + " workspace_id = ? AND actor_id = ? AND role IN" + + " ('READER', 'OPERATOR', 'OWNER')", + String.class, session.tenantId(), + session.workspace().getWorkspaceId(), operation.actorKey()); + return !roles.isEmpty() && WorkspaceAccess.valueOf(roles.getFirst()) + .atLeast(WorkspaceAccess.OPERATOR); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentLifecycleOperations.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentLifecycleOperations.java new file mode 100644 index 00000000000..e22fbd1414a --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentLifecycleOperations.java @@ -0,0 +1,1071 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.CommandRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationAdmission; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationTarget; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionMutation; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +final class AgentLifecycleOperations { + private final AgentEventJournal eventJournal; + private final AgentSessionCommands sessionCommands; + private final AgentSessionPolicies sessionPolicies; + private final AgentSessionQueries sessionQueries; + private final AgentStoreRows storeRows; + private final JdbcTemplate jdbc; + private final WorkspaceLifecycleStore lifecycle; + private final ObjectMapper objectMapper; + private final Clock clock; + private final ManagedWorkspaceRegistry workspaces; + private final boolean workspaceFilesEnabled; + private final long lifecycleHandoffMillis; + + AgentLifecycleOperations(AgentEventJournal eventJournal, + AgentSessionCommands sessionCommands, + AgentSessionPolicies sessionPolicies, + AgentSessionQueries sessionQueries, + AgentStoreRows storeRows, + JdbcTemplate jdbc, + WorkspaceLifecycleStore lifecycle, + ObjectMapper objectMapper, + Clock clock, + ManagedWorkspaceRegistry workspaces, + boolean workspaceFilesEnabled, + long lifecycleHandoffMillis) { + this.eventJournal = eventJournal; + this.sessionCommands = sessionCommands; + this.sessionPolicies = sessionPolicies; + this.sessionQueries = sessionQueries; + this.storeRows = storeRows; + this.jdbc = jdbc; + this.lifecycle = lifecycle; + this.objectMapper = objectMapper; + this.clock = clock; + this.workspaces = workspaces; + this.workspaceFilesEnabled = workspaceFilesEnabled; + this.lifecycleHandoffMillis = lifecycleHandoffMillis; + this.operationMapper = (result, row) -> new OperationRecord( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("operation_id"), + OperationKind.valueOf(result.getString("operation_kind")), + result.getString("request_digest"), + result.getString("state"), + result.getString("admission_stage"), + result.getString("delivery_state"), + result.getString("session_status_before"), + result.getString("receipt_id"), + result.getString("claim_owner"), + result.getLong("claim_generation"), + result.getInt("attempt_count"), + AgentStoreRows.additiveString(result, "target_cwd_relative"), + AgentStoreRows.additiveLong(result, "expected_context_revision"), + AgentStoreRows.additiveLong(result, "result_context_revision"), + result.getString("failure_code"), + AgentStoreRows.additiveInt(result, "budget_exempt_attempt"), + AgentStoreRows.hasColumn(result, "lifecycle_protocol_version") + ? result.getInt("lifecycle_protocol_version") : 0, + // NULL on a pre-V56 operation: those operations settle + // against the creator-keyed facts alone. + AgentStoreRows.hasColumn(result, "actor_key") + ? result.getBytes("actor_key") : null, + AgentStoreRows.additiveString(result, "task_id")); + this.operationTargetMapper = (result, row) -> new OperationTarget( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("operation_id")); + } + + final RowMapper operationMapper; + + final RowMapper operationTargetMapper; + + OperationAdmission beginOperation(String tenantId, + String sessionId, OperationKind kind, String actorDigest, + String idempotencyKey, String requestDigest) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + if (storeRows.requireLegacySessionForUpdate(tenantId, sessionId).workspace() != null) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, null, false); + } + + OperationAdmission beginWorkspaceClose(String tenantId, String sessionId, String actorId, + String actorDigest, String key, String digest, boolean supported) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + return beginLifecycle(tenantId, sessionId, OperationKind.CLOSE, actorDigest, key, digest, actorId, supported); + } + + OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, + OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean closeSupported) { + // H4b child close is the only production caller. Public lifecycle uses + // the protocol overload and requires v2 for an active Shell or MCP Session. + return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, closeSupported, 0, false); + } + + OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, + OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean supported, int protocolVersion) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, supported, protocolVersion, true); + } + + // The lifecycle owner gate: the Session's recorded owner (with the + // pre-V40 creator fallback) holding a current read grant. Unreadable is + // invisible; a readable non-owner gets the sibling family's 403. + void requireWorkspaceCreator(SessionRecord session, String actorId) { + if (!workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId())) { + throw new ManagedAgentException( + 404, "session_not_found", "The Session was not found."); + } + if (!workspaces.isSessionOwner(session.tenantId(), actorId, session.sessionId())) { + throw new ManagedAgentException(403, "session_operation_forbidden", + "Only the Session owner may manage it."); + } + } + + SessionMutation unarchiveWorkspaceSession(String tenantId, String sessionId, + String actorId, String scopedKey, String requestDigest) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + requireWorkspaceCreator(session, actorId); + String namespace = "UNARCHIVE_WORKSPACE_SESSION"; + // Replay first, like every other mutation path: a recorded success + // must not 404 after a later delete. + Optional existing = sessionCommands.findCommand(tenantId, namespace, scopedKey, true); + if (existing.isPresent()) { + if (!existing.get().requestDigest().equals(requestDigest) || !existing.get().sessionId().equals(sessionId)) { + throw new ManagedAgentException(409, "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new SessionMutation(session, true); + } + // The tombstone 404 precedes the migration fence and the shell + // refusal: a fresh key against a deleted Session must not answer + // a retryable 409 for a Session that will never come back. + if ("DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + if (WorkspaceToolProfiles.isShellOrMcp(session.toolProfile()) + && (WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) != 2 + || !List.of("CLOSED", "ARCHIVED").contains(session.status()))) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + AgentSessionPolicies.requireSessionStatus(session.status(), "ARCHIVED"); + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + if (!sessionQueries.hasCompletedWorkspaceClose(tenantId, sessionId)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + long now = storeRows.lifecycleDatabaseTime(); + sessionCommands.insertCommand( + tenantId, + namespace, + scopedKey, + requestDigest, + sessionId, + null, + "COMPLETED", + "ARCHIVED", + null, + now); + Map data = Map.of("sessionId", sessionId); + eventJournal.appendEvent(tenantId, sessionId, null, "session.unarchive.requested", data, false, + AgentSessionCommands.mutationSource(namespace, scopedKey, "requested"), now); + jdbc.update("UPDATE ma_session SET status = 'CLOSED', gmt_modified = ?, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ?", now, tenantId, sessionId); + eventJournal.appendEvent(tenantId, sessionId, null, "session.unarchived", data, false, + AgentSessionCommands.mutationSource(namespace, scopedKey, "completed"), now); + return new SessionMutation(storeRows.requireSessionForUpdate(tenantId, sessionId), false); + } + + // D7-1: a Turn input or cancel command targets its Turn, never the + // Session, so no Session barrier above counts it: the Turn row is its + // barrier, and the Turn dispatcher, which holds the only lease that may + // act on the Turn, is its only deliverer. + static final String TURN_COMMAND_KINDS = "('SUBMIT_INPUT', 'CANCEL')"; + + // For these it is a published wait on a precondition of an operation + // whose acceptance is known: the lifecycle coordinator re-drives it, and + // keyed effect receipts keep the re-drive from repeating an effect. + static final String LIFECYCLE_WAIT_KINDS = + "('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE')"; + + Optional findTurnCommandOperation( + String tenantId, String sessionId, String operationId) { + return jdbc + .query( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " + + TURN_COMMAND_KINDS + + " AND operation_id = ?", + (result, row) -> { + if (!tenantId.equals(result.getString("tenant_id")) + || !sessionId.equals(result.getString("session_id"))) { + throw new IllegalStateException( + "Session operation identity does not match its index key"); + } + return operationMapper.mapRow(result, row); + }, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + operationId) + .stream() + .findFirst(); + } + + Optional replayOperation(String tenantId, String sessionId, + OperationKind kind, String actorDigest, String idempotencyKey) { + return jdbc + .query( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind = ? AND" + + " target_key = ? AND actor_digest = ? AND idempotency_digest = ?", + (result, row) -> { + if (!tenantId.equals(result.getString("tenant_id")) + || !sessionId.equals(result.getString("session_id")) + || !idempotencyKey.equals( + result.getString("idempotency_key"))) { + throw new IllegalStateException( + "Session operation identity does not match its index key"); + } + return operationMapper.mapRow(result, row); + }, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + kind.name(), + AgentStoreRows.sessionOperationTarget(sessionId), + actorDigest, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + idempotencyKey)) + .stream() + .findFirst(); + } + + Optional findSessionOperation(String tenantId, String sessionId, String operationId, boolean lock) { + return jdbc + .query( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND operation_id = ?" + + (lock ? " FOR UPDATE" : ""), + (result, row) -> { + if (!tenantId.equals(result.getString("tenant_id")) + || !sessionId.equals(result.getString("session_id"))) { + throw new IllegalStateException( + "Session operation identity does not match its index key"); + } + return operationMapper.mapRow(result, row); + }, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + operationId) + .stream() + .findFirst(); + } + + OperationAdmission beginChildClose(String tenantId, String parentSessionId, + String childSessionId, String childRunId, String actorDigest, String key, + String digest, boolean supported) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + storeRows.requireSessionForUpdate(tenantId, parentSessionId); + byte[] creator = + jdbc.queryForObject( + "SELECT creator_actor_key FROM ma_session WHERE tenant_id = ? AND" + + " session_id = ?", + byte[].class, + tenantId, + parentSessionId); + var lineage = + jdbc.queryForList( + "SELECT parent_session_id, parent_child_run_id, creator_actor_key FROM" + + " ma_session WHERE tenant_id = ? AND session_id = ? AND session_kind" + + " = 'PUBLIC' FOR UPDATE", + tenantId, + childSessionId); + if (lineage.size() != 1 + || !parentSessionId.equals(lineage.getFirst().get("parent_session_id")) + || !childRunId.equals(lineage.getFirst().get("parent_child_run_id")) + || !(lineage.getFirst().get("creator_actor_key") instanceof byte[] actor) + || actor.length == 0 + || !java.util.Arrays.equals(actor, creator)) { + throw new ManagedAgentException( + 404, + "session_not_found", + "The child Session lineage was not found."); + } + return beginLifecycle(tenantId, childSessionId, OperationKind.CLOSE, actorDigest, key, digest, + null, supported, storeRows.requireSession(tenantId, childSessionId).publicChildAgentsVersion() == 1 + ? WorkspaceToolProfiles.lifecycleProtocol(storeRows.requireSession(tenantId, childSessionId).toolProfile()) : 0, true, true); + } + + OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, 0, true); + } + + OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, protocolVersion, true); + } + + OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion, + boolean publicLifecycle) { + return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, + actorId, supported, protocolVersion, publicLifecycle, false); + } + + OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, + String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion, + boolean publicLifecycle, boolean trustedChildClose) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + // A resumed admission pins the new operation to the failed one's + // protocol (below), so the tenant lock keys on the prospect of + // that pin too — otherwise the resumed lifecycle admission's + // journal-idleness read would race the retention sweep. + if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion) + || latestFailedLifecycleOperation(tenantId, sessionId, kind) + .filter(failed -> WorkspaceToolProfiles.lifecycleProtocol( + failed.lifecycleProtocolVersion())) + .isPresent()) { + ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); + } + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() != null && !trustedChildClose) { + requireWorkspaceCreator(session, actorId); + } + Optional existing = replayOperation(tenantId, sessionId, + kind, actorDigest, idempotencyKey); + if (existing.isPresent()) { + if (!existing.get().requestDigest().equals(requestDigest)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new OperationAdmission(existing.get(), true); + } + if ("DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", + "The Session was not found."); + } + // A terminally FAILED operation leaves the Session in the pending + // status, which the workspace status gate and validateOperationStart + // refuse forever: the Session would be un-closable and un-deletable + // without database surgery (review round 9, R9-3; the workspace + // DELETE case surfaced in review round 13, R13-2). The permitted + // resume is recognized before those gates, and the failed + // operation's protocol is pinned up front — the service computes + // the caller's protocol from the Session's CURRENT status, and a + // Session parked in the pending status by the terminal fail no + // longer passes the lifecycle gate, so an unpinned resume would run + // as protocol 0 while the parked fence row is never rebound (review + // round 9 re-review, R9-3). The no-open-operation guard below still + // runs before the insert: a Session with a live operation cannot + // double-close. + var failedBefore = + kind != OperationKind.ACTION_RESPONSE + && kind != OperationKind.CWD_CHANGE + && pendingStatus(kind).equals(session.status()) + ? latestFailedLifecycleOperation(tenantId, sessionId, kind) + : Optional.empty(); + boolean resumed = failedBefore.isPresent(); + if (resumed) { + protocolVersion = failedBefore.get().lifecycleProtocolVersion(); + } + if (protocolVersion < 0 || protocolVersion > 2 + || protocolVersion != 0 && protocolVersion != WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile())) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); + } + if (publicLifecycle && WorkspaceToolProfiles.isShellOrMcp(session.toolProfile()) + && (WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) != 2 + || protocolVersion != 2 && (!List.of("CLOSED", "ARCHIVED").contains(session.status()) + || !sessionQueries.hasCompletedWorkspaceClose(tenantId, sessionId)))) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + if (sessionPolicies.hasPendingRenameDelivery(tenantId, sessionId)) { + throw new ManagedAgentException(409, "session_operation_active", + "The Session has a title delivery awaiting completion."); + } + if (session.workspace() != null) { + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + } + if (kind == OperationKind.DELETE && List.of("CLOSED", "ARCHIVED").contains(session.status())) { + protocolVersion = 0; + } + if (session.workspace() != null && !resumed) { + if (kind == OperationKind.CLOSE || kind == OperationKind.DELETE && "ACTIVE".equals(session.status()) && WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { + if (!supported || protocolVersion == 1 && !workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + if ("ACTIVE".equals(session.status())) { + validateOperationStart(session, kind); + } + } else { + if (kind == OperationKind.ARCHIVE) { + AgentSessionPolicies.requireSessionStatus(session.status(), "CLOSED"); + } else if (!List.of("CLOSED", "ARCHIVED").contains(session.status())) { + throw AgentSessionPolicies.sessionStateConflict(session.status()); + } + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + if (!sessionQueries.hasCompletedWorkspaceClose(tenantId, sessionId)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + } + } + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + String sessionStatusBefore = session.status(); + if (resumed) { + // The new operation carries the failed one's original + // pre-operation status, so its settle runs the steps the failed + // attempt was owed: an ACTIVE-before close asks the Harness to + // stop again, and a CLOSED-before delete still makes no Runtime + // calls. + sessionStatusBefore = failedBefore.get().sessionStatusBefore(); + } else { + validateOperationStart(session, kind); + } + if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { + WorkspaceLifecycleStore.requireIdleJournal(jdbc, objectMapper, tenantId, sessionId, true, protocolVersion == 2); + } + long now = storeRows.lifecycleDatabaseTime(); + String operationId = AgentStoreRows.publicId("op"); + Map data = Map.of("sessionId", sessionId, + "operationId", operationId); + // Java is the only authority an archive needs, so it completes here. + boolean archive = kind == OperationKind.ARCHIVE; + jdbc.update( + "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," + + " target_kind, target_key, target_id, phase, request_json," + + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," + + " actor_digest, idempotency_key, request_digest, state, admission_stage," + + " delivery_state, session_status_before, receipt_id, available_at," + + " gmt_create, gmt_modified, completed_at, lifecycle_protocol_version) VALUES" + + " (?, ?, ?, 'SESSION', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'JAVA_DURABLE'," + + " ?, ?, ?, ?, ?, ?, ?, ?)", + AgentStoreRows.sessionOperationKey(tenantId, sessionId, kind, operationId), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + sessionId, + archive ? "COMPLETED" : "PENDING", + storeRows.writeJson( + Map.of( + "schemaVersion", + 1, + "kind", + kind.name(), + "sessionId", + sessionId, + "sessionStatusBefore", + sessionStatusBefore, + "protocolVersion", + protocolVersion)), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + idempotencyKey), + tenantId, + sessionId, + operationId, + kind.name(), + actorDigest, + idempotencyKey, + requestDigest, + archive ? "COMPLETED" : "PENDING", + archive ? "CONFIRMED" : "PENDING", + sessionStatusBefore, + archive ? AgentStoreRows.publicId("rcpt") : null, + now, + now, + now, + archive ? now : null, + protocolVersion); + if (resumed && WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { + // failOperation kept the fence row and released the dead + // operation's claim on it, so the re-admitted operation rebinds + // the row rather than inserting a duplicate. The release only + // reaches a fence still LIFECYCLE_ONLY: once effects were saved + // the fence moved to DRAINING with the dead operation still + // named, and a rebind limited to the LIFECYCLE_ONLY shape + // matches nothing — every later claim check reads the fence as + // another operation's and refuses with + // workspace_lifecycle_claim_fenced (review round 13, R13-1). + // Both parked shapes rebind here: the claim-released + // LIFECYCLE_ONLY row, and the DRAINING row that still names the + // failed operation whose validated effects the resume inherits. + jdbc.update("UPDATE ma_session_head SET" + + " operation_id = ? WHERE tenant_key = ? AND" + + " harness_key = ? AND tenant_id = ? AND" + + " harness_session_id = ? AND (phase =" + + " 'LIFECYCLE_ONLY' AND operation_id IS NULL OR" + + " phase = 'DRAINING' AND operation_id = ?)", + operationId, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId, failedBefore.get().operationId()); + // The effects the failed operation validated stay valid for its + // resume: the receipt moves to the new operation with them, and + // verification reads the hook-execution namespace from the + // receipt's own operation id. + String carriedEffects = jdbc.queryForObject( + "SELECT lifecycle_effects_receipt_json FROM" + + " ma_operation WHERE tenant_id = ?" + + " AND session_id = ? AND operation_id = ?", + String.class, tenantId, sessionId, + failedBefore.get().operationId()); + if (carriedEffects != null) { + jdbc.update("UPDATE ma_operation SET" + + " lifecycle_effects_receipt_json = ? WHERE" + + " tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", + carriedEffects, tenantId, sessionId, operationId); + } + } else if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository.beginHarnessLifecycle(connection, tenantId, sessionId, operationId); + return null; + }); + if (protocolVersion == 2) { + // Admission fences ordinary renewals before the Harness receives its claim. + // Preserve a live writer once, without reviving an expired owner on retries. + jdbc.update("UPDATE ma_session_head SET writer_lease_until = GREATEST(writer_lease_until, (" + + ManagedSchemaTime.SQL_NOW + ") + ?), gmt_modified = " + ManagedSchemaTime.SQL_NOW + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?" + + " AND head_kind = 'MANAGED_SESSION' AND phase = 'LIFECYCLE_ONLY' AND operation_id = ? AND state = 'ACTIVE'" + + " AND writer_lease_until > (" + ManagedSchemaTime.SQL_NOW + ")", + lifecycleHandoffMillis, JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId, operationId); + } + } + jdbc.update("UPDATE ma_session SET status = ?," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + archive ? "ARCHIVED" : pendingStatus(kind), now, tenantId, + sessionId); + eventJournal.appendEvent(tenantId, sessionId, null, + archive ? completedEvent(kind) : requestedEvent(kind), data, + false, AgentEventJournal.operationSource(operationId, + archive ? "completed" : "requested"), now); + return new OperationAdmission(findOperation(tenantId, sessionId, + operationId).orElseThrow(), false); + } + + Optional findOperation(String tenantId, + String sessionId, String operationId) { + return findSessionOperation(tenantId, sessionId, operationId, false) + .or(() -> findTurnCommandOperation(tenantId, sessionId, + operationId)); + } + + List findDeliverableOperations(long now, + int limit) { + now = storeRows.lifecycleDatabaseTime(); + List targets = + new ArrayList<>( + jdbc.query( + // Every blocked lifecycle shape is + // re-driven. CLOSE, DELETE and CWD_CHANGE + // are the delivered kinds (an archive + // completes at admission): CLOSE and DELETE + // publish their writer-live and stale-boot + // waits, and a cwd change stays bounded by + // its own attempt budget in the coordinator + // (review round 2, R2-15). + "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" + + " operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE'," + + " 'CWD_CHANGE') AND state IN ('PENDING', 'RUNNING'," + + " 'RECOVERY_BLOCKED') AND delivery_state IN ('PENDING'," + + " 'BLOCKED') AND available_at <= ? AND EXISTS (SELECT 1 FROM" + + " ma_session private_scope WHERE private_scope.tenant_id =" + + " ma_operation.tenant_id AND private_scope.session_id =" + + " ma_operation.session_id AND private_scope.csi_guard =" + + " FALSE) ORDER BY available_at LIMIT ?", + operationTargetMapper, + now, + limit)); + if (targets.size() < limit) { + targets.addAll( + jdbc.query( + "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE') AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_until < " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY claim_until LIMIT ?", + operationTargetMapper, + limit - targets.size())); + } + return List.copyOf(targets); + } + + Optional claimOperation(String tenantId, + String sessionId, String operationId, String owner, + Duration leaseDuration) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + int updated = + jdbc.update( + "UPDATE ma_operation SET phase = 'RUNNING', state = 'RUNNING', delivery_state = 'LEASED', claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), claim_generation = claim_generation + 1, gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ? AND (state IN ('PENDING', 'RUNNING')" + + " OR state = 'RECOVERY_BLOCKED' AND operation_kind IN " + + LIFECYCLE_WAIT_KINDS + + ") AND ((delivery_state IN ('PENDING', 'BLOCKED')" + + " AND available_at <= ?) OR (delivery_state = 'LEASED' AND" + + " claim_until < " + ManagedSchemaTime.SQL_NOW + "))", + owner, + leaseDuration.toMillis(), + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + now); + if (updated == 1) { + syncLifecycleClaim(tenantId, sessionId, operationId); + } + return updated == 1 ? findOperation(tenantId, sessionId, operationId) + : Optional.empty(); + } + + boolean completeOperation(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + boolean harnessConfirmed) { + storeRows.requireLegacyMutation(tenantId, sessionId); + var target = findOperation(tenantId, sessionId, operationId).orElseThrow(); + if (WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion())) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + ManagedSessionStore.lockBounded(() -> ToolPublicationRetentionStore.lockTenant( + jdbc, tenantId, ManagedSessionStore.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + } + if (target.kind() == OperationKind.DELETE && !WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion())) { + ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); + } + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion()) && target.kind() == OperationKind.DELETE) { + ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); + } + OperationRecord operation = findSessionOperation(tenantId, sessionId, operationId, true) + .orElseThrow(() -> new IllegalStateException("Session operation is unavailable")); + Boolean leaseLive = + jdbc.queryForObject( + "SELECT claim_until > " + ManagedSchemaTime.SQL_NOW + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ? FOR UPDATE", + Boolean.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + if (!Boolean.TRUE.equals(leaseLive) + || !"RUNNING".equals(operation.state()) + || !"LEASED".equals(operation.deliveryState()) + || !owner.equals(operation.leaseOwner()) + || operation.claimGeneration() != claimGeneration) { + return false; + } + if (!pendingStatus(operation.kind()).equals(session.status())) { + throw new IllegalStateException("Session " + sessionId + " is " + + session.status() + " during its " + + operation.kind() + " operation"); + } + if (WorkspaceToolProfiles.lifecycleProtocol(operation.lifecycleProtocolVersion())) { + if (lifecycle == null) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_close_identity_unverified"); + } + lifecycle.verifyCompletion(operation); + } + long now = storeRows.lifecycleDatabaseTime(); + if (operation.kind() == OperationKind.DELETE) { + if (session.workspace() != null && !List.of("CLOSED", "ARCHIVED").contains(operation.sessionStatusBefore()) + && !WorkspaceToolProfiles.lifecycleProtocol(operation.lifecycleProtocolVersion())) { + throw new IllegalStateException("Workspace deletion requires a closed Session"); + } + ToolPublicationRetentionStore.retire(jdbc, tenantId, sessionId, operationId); + } + switch (operation.kind()) { + case CLOSE, ARCHIVE -> jdbc.update("UPDATE ma_session" + + " SET status = ?, harness_event_epoch = NULL," + + " harness_last_event_id = 0, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + operation.kind() == OperationKind.CLOSE ? "CLOSED" + : "ARCHIVED", now, tenantId, sessionId); + case DELETE -> jdbc.update("UPDATE ma_session SET" + + " status = 'DELETED', harness_boot_id = NULL," + + " harness_event_epoch = NULL," + + " harness_last_event_id = 0, deleted_at = ?," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + now, now, tenantId, sessionId); + } + jdbc.update( + "UPDATE ma_operation SET failure_code = NULL, phase = 'COMPLETED', state =" + + " 'COMPLETED', admission_stage = ?, delivery_state = 'CONFIRMED', receipt_id" + + " = ?, claim_owner = NULL, claim_until = NULL, gmt_modified = ?, completed_at =" + + " ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", + harnessConfirmed ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", + AgentStoreRows.publicId("rcpt"), + now, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + eventJournal.appendEvent(tenantId, sessionId, null, + completedEvent(operation.kind()), + Map.of("sessionId", sessionId, "operationId", operationId), + operation.kind() == OperationKind.DELETE, + AgentEventJournal.operationSource(operationId, "completed"), now); + return true; + } + + boolean failOperation(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + String failureCode) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + long now = storeRows.lifecycleDatabaseTime(); + // The same fencing as completeOperation, and deliberately no + // Session effect beyond the claim-mirror release below: the Session + // row keeps its pending status and no completion event is appended, + // because the settle never succeeded. The receipt + // the contract requires of every confirmed row certifies nothing — + // status failed and the failure code carry the outcome. + // delivery_state CONFIRMED keeps every recovery scan from re-driving + // the row. + int updated = jdbc.update("UPDATE ma_operation SET" + + " state = 'FAILED', phase = 'FAILED'," + + " delivery_state = 'CONFIRMED', failure_code = ?," + + " receipt_id = ?, claim_owner = NULL," + + " claim_until = NULL, gmt_modified = ?," + + " completed_at = ? WHERE scope_key = ? AND" + + " operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND" + + " session_id = ? AND operation_id = ? AND" + + " state = 'RUNNING' AND delivery_state = 'LEASED'" + + " AND claim_owner = ? AND claim_generation = ?" + + " AND claim_until > " + ManagedSchemaTime.SQL_NOW, + failureCode, AgentStoreRows.publicId("rcpt"), now, now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), tenantId, sessionId, + operationId, owner, claimGeneration); + if (updated == 1) { + // The terminal write also releases the lifecycle claim mirror a + // v1 admission raised: nothing will drive this operation again, + // so the fence row must not stay bound to a dead operation's id. + // The phase stays LIFECYCLE_ONLY, though — only the completion + // path flips it to DRAINING, gated on its effects verification, + // and isHarnessDraining (drainHarnessSession's sole precondition) + // reads the phase alone: writing DRAINING here would authorize + // draining a Session whose worker stop was never verified (review + // round 6, R5-5). retryOperation and blockLifecycleOperation + // deliberately only NULL the lease — a rescheduled operation is + // still in flight and must stay admission-closed to everyone + // else. + jdbc.update("UPDATE ma_session_head SET" + + " operation_id = NULL, claim_lease_until = NULL" + + " WHERE tenant_key = ? AND harness_key = ? AND" + + " tenant_id = ? AND harness_session_id = ? AND" + + " operation_id = ? AND phase = 'LIFECYCLE_ONLY'", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId, operationId); + } + return updated == 1; + } + + boolean renewLifecycleOperation(String tenantId, String sessionId, String operationId, + String owner, long generation, Duration duration) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + int updated = + jdbc.update( + "UPDATE ma_operation SET claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + duration.toMillis(), + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + owner, + generation); + if (updated == 1) { + syncLifecycleClaim(tenantId, sessionId, operationId); + } + return updated == 1; + } + + void syncLifecycleClaim(String tenantId, String sessionId, String operationId) { + var rows = + jdbc.queryForList( + "SELECT lifecycle_protocol_version, claim_generation, FLOOR(claim_until) AS claim_lease_until FROM ma_operation WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId); + if (!rows.isEmpty() + && rows.getFirst().get("lifecycle_protocol_version") instanceof Number version + && WorkspaceToolProfiles.lifecycleProtocol(version.intValue())) { + var row = rows.getFirst(); + jdbc.update( + "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_generation = ?, claim_lease_until = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND (phase = 'LIFECYCLE_ONLY' OR ? = 2 AND phase = 'DRAINING')", + row.get("claim_generation"), + row.get("claim_lease_until"), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId, + operationId, version.intValue()); + } + } + + void blockLifecycleOperation(String tenantId, String sessionId, String operationId, + String owner, long generation, String failureCode, long availableAt) { + blockLifecycleOperation(tenantId, sessionId, operationId, owner, + generation, failureCode, availableAt, false); + } + + void blockLifecycleOperation(String tenantId, String sessionId, String operationId, + String owner, long generation, String failureCode, long availableAt, + boolean budgetExempt) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + // The exempt count increments from itself, never from + // attempt_count: a wait refunds no already-charged attempt, so + // attempt_count - budget_exempt_attempt stays the count of attempts + // that could have made progress regardless of interleaving order. + int updated = + jdbc.update( + "UPDATE ma_operation SET phase = 'RECOVERY_BLOCKED', state =" + + " 'RECOVERY_BLOCKED', delivery_state = 'BLOCKED', failure_code = ?," + + " available_at = ?, claim_owner = NULL, claim_until = NULL," + + " gmt_modified = ?," + + (budgetExempt + ? " budget_exempt_attempt = budget_exempt_attempt + 1," + : "") + + " attempt_count = attempt_count + 1 WHERE scope_key =" + + " ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + failureCode, + Math.addExact(now, Math.max(0, availableAt - clock.millis())), + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + owner, + generation); + if (updated == 1) { + jdbc.update( + "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId, + operationId, + generation); + } + } + + void retryOperation(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + long availableAt) { + retryOperation(tenantId, sessionId, operationId, owner, + claimGeneration, availableAt, false); + } + + void retryOperation(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration, + long availableAt, boolean budgetExempt) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + storeRows.requireLegacyMutation(tenantId, sessionId); + long delay = Math.max(0, availableAt - clock.millis()); + long now = storeRows.lifecycleDatabaseTime(); + availableAt = Math.addExact(now, delay); + // The exempt count increments from itself, never from + // attempt_count: a wait refunds no already-charged attempt, so + // attempt_count - budget_exempt_attempt stays the count of attempts + // that could have made progress regardless of interleaving order. + int updated = + jdbc.update( + "UPDATE ma_operation SET delivery_state = 'PENDING', claim_owner = NULL," + + " claim_until = NULL," + + (budgetExempt + ? " budget_exempt_attempt = budget_exempt_attempt + 1," + : "") + + " attempt_count = attempt_count + 1, available_at" + + " = ?, gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + availableAt, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + owner, + claimGeneration); + if (updated == 1) { + jdbc.update( + "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId, + operationId, + claimGeneration); + } + } + + void markOperationAnswered(String tenantId, String sessionId, + String operationId, String owner, long claimGeneration) { + WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = storeRows.lifecycleDatabaseTime(); + // Watermark only: the row stays leased and no reschedule runs here — + // the catch block's own retryOperation (or a throw losing it) decides + // what happens next. The point is that the answered fact is durable + // before any recovery read can throw it away (review round 14, + // R14-3). + jdbc.update( + "UPDATE ma_operation SET budget_exempt_attempt =" + + " budget_exempt_attempt + 1, gmt_modified = ? WHERE scope_key = ? AND" + + " operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ? AND state = 'RUNNING' AND delivery_state =" + + " 'LEASED' AND claim_owner = ? AND claim_generation = ? AND" + + " claim_until > " + ManagedSchemaTime.SQL_NOW, + now, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + tenantId, + sessionId, + operationId, + owner, + claimGeneration); + } + + // One lifecycle change at a time: a pending rename or unarchive command + // blocks an operation, and an open operation blocks both commands. + /** The latest terminally failed lifecycle operation of the kind: the + * status the Session carried when it began and the protocol it ran + * under — both re-pinned by a resumed admission. */ + Optional latestFailedLifecycleOperation( + String tenantId, String sessionId, OperationKind kind) { + var rows = jdbc.query("SELECT operation_id, session_status_before," + + " lifecycle_protocol_version FROM" + + " ma_operation WHERE tenant_id = ?" + + " AND session_id = ? AND operation_kind = ? AND" + + " state = 'FAILED' ORDER BY gmt_modified DESC," + + " gmt_create DESC LIMIT 1", + (result, row) -> new FailedLifecycleOperation( + result.getString(1), result.getString(2), + result.getInt(3)), + tenantId, sessionId, kind.name()); + return rows.isEmpty() ? Optional.empty() + : Optional.of(rows.getFirst()); + } + + record FailedLifecycleOperation(String operationId, + String sessionStatusBefore, int lifecycleProtocolVersion) { + } + + void validateOperationStart(SessionRecord session, + OperationKind kind) { + String status = session.status(); + switch (kind) { + case CLOSE -> AgentSessionPolicies.requireSessionStatus(status, "ACTIVE"); + case ARCHIVE -> AgentSessionPolicies.requireSessionStatus(status, "CLOSED"); + case DELETE -> { + if (!List.of("ACTIVE", "CLOSED", "ARCHIVED") + .contains(status)) { + throw AgentSessionPolicies.sessionStateConflict(status); + } + } + } + if ("ACTIVE".equals(status) + && (hasLiveTurn(session.tenantId(), session.sessionId()) + || session.workspace() != null && sessionPolicies.hasDecidableAction(session))) { + throw new ManagedAgentException(409, "turn_active", + "The Session has an active Turn."); + } + } + + /** + * Only a permanently refused workflow leaves this lifecycle gate. + * Runtime and Harness parks still await authoritative settlement; + * closing them could discard a later result. Cancellation also waits. + */ + boolean hasLiveTurn(String tenantId, String sessionId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " ma_turn WHERE tenant_id = ? AND" + + " session_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')" + + " AND (status = 'CANCELLING' OR error_code IS NULL" + + " OR error_code <> '" + + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "')", + Integer.class, tenantId, sessionId); + return count != null && count > 0; + } + + // ARCHIVING remains only for an archive admitted before V17, which closes + // the Harness as archive used to. + static String pendingStatus(OperationKind kind) { + return switch (kind) { + case CLOSE -> "CLOSING"; + case ARCHIVE -> "ARCHIVING"; + case DELETE -> "DELETING"; + case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> + throw new IllegalArgumentException("Not a lifecycle operation"); + }; + } + + static String requestedEvent(OperationKind kind) { + return switch (kind) { + case CLOSE -> "session.close.requested"; + case ARCHIVE -> "session.archive.requested"; + case DELETE -> "session.delete.requested"; + case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> + throw new IllegalArgumentException("Not a lifecycle operation"); + }; + } + + static String completedEvent(OperationKind kind) { + return switch (kind) { + case CLOSE -> "session.closed"; + case ARCHIVE -> "session.archived"; + case DELETE -> "session.deleted"; + case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> + throw new IllegalArgumentException("Not a lifecycle operation"); + }; + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentMessageProjection.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentMessageProjection.java new file mode 100644 index 00000000000..b73b295ca74 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentMessageProjection.java @@ -0,0 +1,987 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.EventIdentity; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels; +import com.alibaba.qwen.code.managedagent.model.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ItemPartRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ItemRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.MaterializationResult; +import com.alibaba.qwen.code.managedagent.model.StoreModels.MaterializationTarget; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ReplayFloorTarget; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ReplayWindow; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; +import org.springframework.jdbc.core.RowMapper; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class AgentMessageProjection { + private final AgentEventJournal eventJournal; + private final AgentStoreRows storeRows; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final Clock clock; + + AgentMessageProjection(AgentEventJournal eventJournal, + AgentStoreRows storeRows, + JdbcTemplate jdbc, + ObjectMapper objectMapper, + Clock clock) { + this.eventJournal = eventJournal; + this.storeRows = storeRows; + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.clock = clock; + this.itemMapper = (result, row) -> + new ItemRow( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("item_id"), + result.getString("turn_id"), + result.getString("item_type"), + result.getString("item_role"), + result.getString("item_status"), + storeRows.readMap(result.getString("attributes_json")), + result.getLong("first_sequence"), + result.getLong("last_sequence"), + result.getLong("gmt_create"), + result.getLong("gmt_modified"), + result.getLong("revision"), + readParts( + result.getString("parts_json"), + result.getString("body_resource_key"), + result.getString("tenant_id"), + result.getString("session_id"))); + } + + static final TypeReference> PARTS_TYPE = new TypeReference<>() {}; + + static final long MESSAGE_PAGE_BYTES = 8L * 1024 * 1024; + + final RowMapper itemMapper; + + StoreModels.MessagePage findMessagePage(String tenantId, String sessionId, + String cursor, int limit, boolean backward) { + return findMessagePage(tenantId, sessionId, cursor, limit, backward, 0); + } + + StoreModels.MessagePage findMessagePage(String tenantId, String sessionId, + String cursor, int limit, boolean backward, long afterSequence) { + storeRows.requireSession(tenantId, sessionId); + ManagedLegacySessionGuard.requireLegacyMutation(Boolean.TRUE.equals(jdbc.queryForObject( + "SELECT csi_guard FROM ma_session WHERE tenant_id = ? AND session_id = ?", + Boolean.class, tenantId, sessionId))); + if (limit < 1 || limit > 100) { + throw new ManagedAgentException(400, "invalid_limit", + "Limit must be between 1 and 100."); + } + if (afterSequence < 0) { + throw new ManagedAgentException(400, "invalid_event_cursor", + "Item sequence must be non-negative."); + } + var progress = jdbc.queryForMap("SELECT covered_sequence, projection_version, last_sequence, replay_floor_sequence FROM ma_event_stream" + + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", + tenantId, sessionId); + long covered = ((Number) progress.get("covered_sequence")).longValue(); + int projection = ((Number) progress.get("projection_version")).intValue(); + if (covered < ((Number) progress.get("replay_floor_sequence")).longValue() + || (!backward && org.apache.commons.lang3.StringUtils.isBlank(cursor) + && covered < ((Number) progress.get("last_sequence")).longValue())) { + throw new ManagedAgentException(503, "message_projection_pending", + "Retry after pending messages have been projected."); + } + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + MessagePosition position = new MessagePosition(1, scope, backward, projection, covered, + backward ? covered + 1 : -1, ""); + if (org.apache.commons.lang3.StringUtils.isNotBlank(cursor)) { + position = messagePosition(cursor, scope, backward, covered, projection); + } + String comparison = backward ? "<" : ">"; + String order = backward ? " DESC" : " ASC"; + boolean numericBoundary = afterSequence > 0 && org.apache.commons.lang3.StringUtils.isBlank(cursor); + List pageArguments = new ArrayList<>(List.of(scope, tenantId, sessionId, position.through())); + if (numericBoundary) { + pageArguments.add(afterSequence); + } + pageArguments.addAll(List.of(position.sequence(), position.sequence(), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity(position.item()), limit + 1)); + List candidates = jdbc.query( + "SELECT m.item_id, m.first_sequence, OCTET_LENGTH(m.attributes_json) +" + + " OCTET_LENGTH(m.parts_json) + COALESCE(OCTET_LENGTH(r.inline_bytes), 0) AS byte_length" + + " FROM ma_message m LEFT JOIN ma_resource r ON r.resource_key = m.body_resource_key" + + " AND r.session_scope_key = ? AND r.tenant_id = m.tenant_id AND r.session_id = m.session_id" + + " WHERE m.tenant_id = ? AND m.session_id = ? AND m.first_sequence <= ?" + + (numericBoundary ? " AND m.first_sequence > ?" : "") + + " AND (m.first_sequence " + comparison + " ? OR (m.first_sequence = ? AND m.item_id " + + comparison + " ?)) ORDER BY m.first_sequence" + order + ", m.item_id" + order + " LIMIT ?", + (row, index) -> new MessageSize(row.getString("item_id"), row.getLong("first_sequence"), + row.getLong("byte_length")), + pageArguments.toArray()); + long bytes = 0; + List selected = new ArrayList<>(); + for (MessageSize candidate : candidates) { + if (selected.size() == limit || bytes + candidate.bytes() > MESSAGE_PAGE_BYTES) { + if (selected.isEmpty()) { + throw messagePageTooLarge(); + } + break; + } + if (candidate.bytes() < 0) { + throw new IllegalStateException("Message byte length is invalid."); + } + bytes += candidate.bytes(); + selected.add(candidate); + } + if (selected.isEmpty()) { + return new StoreModels.MessagePage(List.of(), List.of(), false, null, covered, position.through()); + } + List arguments = new ArrayList<>(List.of(tenantId, sessionId)); + selected.forEach(item -> arguments.add( + ManagedSchemaKeys.identity(item.id()))); + // Bodies are fetched only after the metadata-only lookahead has selected a bounded page. + List rows = withParts(jdbc.query( + "SELECT " + ManagedSchemaProjection.MESSAGE.columns() + " FROM ma_message WHERE tenant_id = ?" + + " AND session_id = ? AND item_id IN (" + AgentStoreRows.placeholders(selected.size()) + + ") ORDER BY first_sequence ASC, item_id ASC", itemMapper, arguments.toArray())); + if (rows.size() != selected.size() || rows.stream().anyMatch(item -> item.lastSequence() > covered)) { + throw new IllegalStateException("Message projection differs from its progress."); + } + List events = backward ? messageTurnEvents(tenantId, sessionId, rows, covered, bytes) : List.of(); + boolean more = selected.size() < candidates.size(); + MessageSize last = selected.getLast(); + String next = more ? messageCursor(new MessagePosition(1, scope, backward, projection, + position.through(), last.sequence(), last.id())) : null; + return new StoreModels.MessagePage(rows, events, more, next, covered, position.through()); + } + + List messageTurnEvents(String tenantId, String sessionId, + List items, long covered, long itemBytes) { + List turns = items.stream().map(ItemRecord::turnId).distinct().toList(); + List arguments = new ArrayList<>(List.of(tenantId, sessionId, covered)); + turns.forEach(turn -> arguments.add( + ManagedSchemaKeys.identity(turn))); + arguments.add(tenantId); + arguments.add(sessionId); + var sizes = jdbc.query("SELECT e.sequence_id, OCTET_LENGTH(e.data_json) AS byte_length FROM ma_public_event e" + + " JOIN (SELECT turn_id, event_type, MAX(sequence_id) AS seq FROM ma_public_event" + + " WHERE tenant_id = ? AND session_id = ? AND sequence_id <= ?" + + " AND stream_kind = 'SESSION' AND stream_id = X'' AND turn_id IN (" + + AgentStoreRows.placeholders(turns.size()) + ") AND event_type IN ('turn.started', 'turn.completed'," + + " 'turn.failed', 'turn.cancelled', 'turn.cancel.requested', 'environment.provisioning'," + + " 'environment.ready', 'environment.failed') GROUP BY turn_id, event_type) latest" + + " ON e.turn_id = latest.turn_id AND e.event_type = latest.event_type AND e.sequence_id = latest.seq" + + " WHERE e.tenant_id = ? AND e.session_id = ? AND e.stream_kind = 'SESSION' AND e.stream_id = X''", + (row, index) -> new EventSize(row.getLong("sequence_id"), row.getLong("byte_length")), arguments.toArray()); + if (sizes.stream().mapToLong(EventSize::bytes).sum() + itemBytes > MESSAGE_PAGE_BYTES) { + throw messagePageTooLarge(); + } + if (sizes.isEmpty()) { + return List.of(); + } + List eventArguments = new ArrayList<>(List.of(tenantId, sessionId)); + sizes.forEach(event -> eventArguments.add(event.sequence())); + return jdbc.query("SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event" + + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''" + + " AND sequence_id IN (" + AgentStoreRows.placeholders(sizes.size()) + ") ORDER BY sequence_id ASC", + eventJournal.eventMapper, eventArguments.toArray()); + } + + MessagePosition messagePosition(String cursor, String scope, boolean backward, + long covered, int projection) { + try { + if (cursor.length() > 4096) { + throw new IllegalArgumentException(); + } + MessagePosition position = objectMapper.readValue( + java.util.Base64.getUrlDecoder().decode(cursor), MessagePosition.class); + if (position.version() != 1 || !scope.equals(position.scope()) || position.backward() != backward + || position.projection() < 1 || position.through() < 0 || position.sequence() < 0 + || position.sequence() > position.through() || position.item() == null + || position.item().isEmpty() || position.item().length() > 512) { + throw new IllegalArgumentException(); + } + if (position.projection() != projection) { + throw new ManagedAgentException(409, "message_projection_changed", + "Reload message history after its projection changed."); + } + if (position.through() > covered) { + throw new IllegalArgumentException(); + } + return position; + } catch (java.io.IOException | IllegalArgumentException invalid) { + throw new ManagedAgentException(400, "invalid_message_cursor", + "The message cursor is invalid for this Session and direction."); + } + } + + String messageCursor(MessagePosition position) { + try { + return java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(objectMapper.writeValueAsBytes(position)); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Message cursor serialization failed.", failure); + } + } + + static ManagedAgentException messagePageTooLarge() { + return new ManagedAgentException(413, "message_page_too_large", + "The message page exceeds its byte budget."); + } + + record MessagePosition(int version, String scope, boolean backward, int projection, + long through, long sequence, String item) {} + + record MessageSize(String id, long sequence, long bytes) {} + + record EventSize(long sequence, long bytes) {} + + /** The message projection coverage of each Session, in one read. */ + Map findMessageCoveredSequences(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(sessionIds.size() + 1); + arguments.add(tenantId); + arguments.addAll(sessionIds); + Map result = new HashMap<>(sessionIds.size() * 2); + RowCallbackHandler reader = row -> result.put( + row.getString("session_id"), row.getLong("covered_sequence")); + jdbc.query( + "SELECT s.session_id, s.covered_sequence FROM ma_event_stream s" + + " WHERE s.stream_kind = 'SESSION' AND" + + " s.stream_id = X'' AND s.tenant_id = ? AND s.session_id IN (" + + AgentStoreRows.placeholders(sessionIds.size()) + + ")", + reader, + arguments.toArray()); + return result; + } + + ReplayWindow findReplayWindow(String tenantId, String sessionId) { + List rows = + jdbc.query( + "SELECT s.replay_floor_sequence, s.covered_sequence FROM ma_event_stream s" + + " WHERE s.tenant_id = ? AND s.session_id = ?" + + " AND s.stream_kind = 'SESSION' AND s.stream_id = X''", + (result, row) -> + new ReplayWindow( + result.getLong("replay_floor_sequence"), + result.getLong("covered_sequence")), + tenantId, + sessionId); + if (rows.isEmpty()) { + throw new ManagedAgentException(404, "session_not_found", + "The Session was not found."); + } + return rows.getFirst(); + } + + List findReplayFloorTargets(int limit) { + return jdbc.query( + "SELECT s.tenant_id, s.session_id FROM ma_event_stream s" + + " WHERE s.stream_kind = 'SESSION' AND s.stream_id = X''" + + " AND s.covered_sequence > s.replay_floor_sequence" + + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE" + + " private_scope.tenant_id = s.tenant_id AND private_scope.session_id = s.session_id" + + " AND private_scope.csi_guard = FALSE)" + + " AND NOT EXISTS (SELECT 1 FROM ma_turn t JOIN ma_public_event e ON" + + " e.tenant_id = t.tenant_id AND e.session_id = t.session_id AND e.turn_id = t.turn_id" + + " WHERE t.tenant_id = s.tenant_id AND t.session_id = s.session_id" + + " AND t.status IN ('ACCEPTED', 'RUNNING', 'CANCELLING')" + + " AND e.stream_kind = 'SESSION' AND e.stream_id = X''" + + " AND e.event_type = 'turn.accepted' AND e.sequence_id <= s.replay_floor_sequence + 1)" + + " ORDER BY s.gmt_modified ASC LIMIT ?", + (row, index) -> new ReplayFloorTarget(row.getString("tenant_id"), row.getString("session_id")), + limit); + } + + /** Advances only through readable message coverage, retaining active-Turn evidence. */ + ReplayWindow advanceReplayFloor(String tenantId, String sessionId, + long floorSequence) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + ReplayWindow window = findReplayWindow(tenantId, sessionId); + long floor = Math.min(floorSequence, window.snapshotThroughSequence()); + Long activeStart = + jdbc.queryForObject( + "SELECT MIN(e.sequence_id) FROM ma_public_event e JOIN ma_turn t ON" + + " t.tenant_id = e.tenant_id AND t.session_id = e.session_id AND" + + " t.turn_id = e.turn_id WHERE e.tenant_id = ? AND e.session_id = ?" + + " AND e.stream_kind = 'SESSION' AND e.stream_id = X'' AND" + + " e.event_type = 'turn.accepted' AND t.status IN ('ACCEPTED'," + + " 'RUNNING', 'CANCELLING')", + Long.class, + tenantId, + sessionId); + if (activeStart != null) { + floor = Math.min(floor, Math.max(0, activeStart - 1)); + } + if (floor <= window.floorSequence()) { + return window; + } + jdbc.update( + "UPDATE ma_event_stream SET replay_floor_sequence = ? WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", + floor, + tenantId, + sessionId); + return new ReplayWindow(floor, window.snapshotThroughSequence()); + } + + List findMaterializationTargets(int limit) { + return jdbc.query( + "SELECT s.tenant_id, s.session_id FROM ma_session s JOIN ma_event_stream p ON p.tenant_id = s.tenant_id AND p.session_id = s.session_id AND p.stream_kind = 'SESSION' AND p.stream_id = X'' WHERE s.session_kind = 'PUBLIC' AND s.csi_guard = FALSE AND s.retired_at IS NULL AND s.status <> 'DELETED' AND p.projection_pending = TRUE ORDER BY p.gmt_modified ASC LIMIT ?", + (result, row) -> + new MaterializationTarget( + result.getString("tenant_id"), result.getString("session_id")), + limit); + } + + MaterializationResult materializeNextBatch(String tenantId, + String sessionId, int limit) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + Long covered = jdbc.queryForObject( + "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" + + " UPDATE", + Long.class, tenantId, sessionId); + if (covered == null) { + throw new IllegalStateException( + "Message projection progress is unavailable"); + } + List events = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" + + " = X'' AND tenant_id = ? AND session_id = ? AND sequence_id > ?" + + " ORDER BY sequence_id ASC LIMIT ?", + eventJournal.eventMapper, + tenantId, + sessionId, + covered, + limit); + if (events.isEmpty()) { + return new MaterializationResult(false, covered); + } + long expected = covered + 1; + Map> projectedParts = new LinkedHashMap<>(); + for (EventRecord event : events) { + if (event.sequence() != expected) { + // A gap is permanent: appendEvent allocates a sequence under + // the session row lock this method already holds, so no + // missing sequence can still appear. Skipping ahead once + // beats throwing on every scan and wedging the projection. + AgentStoreRows.LOG.error("Message projection skips a permanent event gap" + + " tenant={} session={} missingSequences={}-{}" + + " nextEvent={}", + tenantId, sessionId, expected, event.sequence() - 1, + event.sequence()); + // The skipped range may have carried a turn's terminal + // event, whose settle is the only write that moves its + // items off in_progress. Settle those turns' items here, or + // they would be pinned forever — but only for a turn that is + // provably over: its terminal event is in the journal (its + // own materialization settled it already, making this a + // no-op guard) or its row is not live. A turn whose row is + // still live (recordHarnessEvents flips it in the same + // transaction that appends the terminal event, so a live row + // means the terminal event can still arrive) is settled by + // its own event or by turn recovery's turn.failed — never + // here. The remaining guards mirror settleTurnItems (never + // re-settle a completed item, never push last_sequence + // backwards). + jdbc.update("UPDATE ma_message SET item_status =" + + " 'failed', last_sequence = CASE WHEN" + + " last_sequence < ? THEN ? ELSE" + + " last_sequence END, gmt_modified = ?," + + " revision = revision + 1 WHERE tenant_id" + + " = ? AND session_id = ? AND item_status" + + " = 'in_progress' AND last_sequence < ?" + + " AND NOT EXISTS (SELECT 1 FROM" + + " ma_public_event terminal WHERE" + + " terminal.stream_kind = 'SESSION' AND" + + " terminal.stream_id = X'' AND" + + " terminal.tenant_id =" + + " ma_message.tenant_id AND" + + " terminal.session_id =" + + " ma_message.session_id AND" + + " terminal.turn_id =" + + " ma_message.turn_id AND" + + " terminal.event_type IN" + + " ('turn.completed', 'turn.failed'," + + " 'turn.cancelled'))" + + " AND NOT EXISTS (SELECT 1 FROM" + + " ma_turn live WHERE" + + " live.tenant_id =" + + " ma_message.tenant_id AND" + + " live.session_id =" + + " ma_message.session_id AND" + + " live.turn_id =" + + " ma_message.turn_id AND" + + " live.status IN ('ACCEPTED', 'RUNNING'," + + " 'CANCELLING'))", + event.sequence() - 1, event.sequence() - 1, + clock.millis(), tenantId, sessionId, + event.sequence()); + } + materializeEvent(event, projectedParts); + expected = event.sequence() + 1; + } + flushParts(tenantId, sessionId, projectedParts); + long nextCovered = events.get(events.size() - 1).sequence(); + long now = clock.millis(); + jdbc.update( + "UPDATE ma_event_stream SET covered_sequence = ?, gmt_modified = ?" + + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", + nextCovered, + now, + tenantId, + sessionId); + return new MaterializationResult(true, nextCovered); + } + + void deferMaterializationTarget(String tenantId, + String sessionId) { + storeRows.requireLegacyMutation(tenantId, sessionId); + jdbc.update("UPDATE ma_event_stream SET gmt_modified = ? WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", + clock.millis(), tenantId, sessionId); + } + + void rebuildTurnText(String tenantId, String sessionId, String turnId, long now) { + Long covered = + jdbc.queryForObject( + "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" + + " UPDATE", + Long.class, + tenantId, + sessionId); + Map> parts = new LinkedHashMap<>(); + jdbc.query( + "SELECT item_id, parts_json, body_resource_key FROM ma_message WHERE tenant_id = ?" + + " AND session_id = ? AND turn_id = ? AND item_role = 'assistant' AND" + + " item_type = 'message' FOR UPDATE", + (org.springframework.jdbc.core.RowCallbackHandler) + row -> { + var retained = + new ArrayList<>( + readParts( + row.getString("parts_json"), + row.getString("body_resource_key"), + tenantId, + sessionId)); + retained.removeIf( + part -> + "output_text".equals(part.type()) + || "reasoning".equals(part.type())); + parts.put(row.getString("item_id"), retained); + }, + tenantId, + sessionId, + turnId); + List events = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE tenant_id = ? AND session_id = ? AND" + + " stream_kind = 'SESSION' AND stream_id = X'' AND turn_id = ? AND" + + " sequence_id <= ? AND event_type IN ('item.output_text.delta'," + + " 'item.reasoning.delta', 'turn.completed', 'turn.failed'," + + " 'turn.cancelled') ORDER BY sequence_id", + eventJournal.eventMapper, + tenantId, + sessionId, + turnId, + covered); + EventRecord terminal = null; + for (EventRecord event : events) { + if (List.of("turn.completed", "turn.failed", "turn.cancelled").contains(event.type())) { + terminal = event; + } else { + materializeText( + event, + "item.reasoning.delta".equals(event.type()) ? "reasoning" : "output_text", + parts); + } + } + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + var iterator = parts.entrySet().iterator(); + while (iterator.hasNext()) { + var entry = iterator.next(); + List retained = entry.getValue(); + if (retained.isEmpty()) { + ManagedProjectionResourceCollector.release(jdbc, scope, "MESSAGE", + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(scope, entry.getKey())); + jdbc.update( + "DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" + + " 'MESSAGE' AND owner_key = ?", + scope, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + scope, entry.getKey())); + jdbc.update( + "DELETE FROM ma_message WHERE tenant_id = ? AND session_id = ? AND item_id" + + " = ?", + tenantId, + sessionId, + entry.getKey()); + iterator.remove(); + continue; + } + long first = + retained.stream().mapToLong(ItemPartRecord::firstSequence).min().orElseThrow(); + long last = + retained.stream().mapToLong(ItemPartRecord::lastSequence).max().orElseThrow(); + long created = + retained.stream().mapToLong(ItemPartRecord::createdAt).min().orElseThrow(); + long updated = + retained.stream().mapToLong(ItemPartRecord::updatedAt).max().orElseThrow(); + String status = "in_progress"; + if (terminal != null) { + last = Math.max(last, terminal.sequence()); + updated = Math.max(updated, terminal.createdAt()); + status = switch (terminal.type()) { + case "turn.completed" -> "completed"; + case "turn.cancelled" -> "cancelled"; + default -> "failed"; + }; + } + jdbc.update( + "UPDATE ma_message SET first_sequence = ?, last_sequence = ?, gmt_create = ?," + + " gmt_modified = ?, item_status = ?, revision = revision + 1 WHERE" + + " tenant_id = ? AND session_id = ? AND item_id = ?", + first, + last, + created, + updated, + status, + tenantId, + sessionId, + entry.getKey()); + } + flushParts(tenantId, sessionId, parts); + jdbc.update( + "UPDATE ma_event_stream SET projection_version = projection_version + 1," + + " gmt_modified = ? WHERE tenant_id = ? AND session_id = ?" + + " AND stream_kind = 'SESSION' AND stream_id = X''", + now, + tenantId, + sessionId); + } + + void materializeEvent(EventRecord event, Map> projectedParts) { + switch (event.type()) { + case "turn.accepted", "item.input.added" -> materializeInput(event, projectedParts); + case "item.output_text.delta" -> materializeText(event, + "output_text", projectedParts); + case "item.reasoning.delta" -> materializeText(event, + "reasoning", projectedParts); + case "item.tool_call.updated", "item.tool_result.updated" -> materializeTool(event); + case "turn.completed", "turn.failed", "turn.cancelled" -> + settleTurnItems(event); + default -> { + return; + } + } + } + + void materializeInput(EventRecord event, Map> projectedParts) { + List> input = inputData(event.data().get("input")); + if (input.isEmpty()) { + input = storeRows.requireTurn(event.tenantId(), event.sessionId(), + event.turnId()).input(); + } + String itemId = string(event.data().get("itemId")); + if (itemId == null) { + itemId = StoreModels.inputItemId(event.turnId()); + } + upsertItem(event, itemId, "message", "user", "completed", + input.stream().anyMatch(block -> "image".equals(block.get("type")) || "resource".equals(block.get("type"))) + ? Map.of("inputAttachments", input) : Map.of()); + for (int index = 0; index < input.size(); index++) { + Map block = input.get(index); + String text = string(block.get("text")); + if (text == null) { + text = workflowLaunchSummary(block); + } + if (text == null) { + continue; + } + replacePart(event, itemId, + "part_" + ("item.input.added".equals(event.type()) ? itemId : event.turnId()) + "_input_" + index, + "input_text", text, projectedParts); + } + } + + /** + * A workflow child's first turn carries one {@code workflow_launch} block and no text (#13803 + * K1): the message names the launch pin exactly as the daemon journal does ({@code + * workflowLaunchSummaryText} in hosted-workflow-turn.ts), so both projections of the turn + * agree. The block itself is never given a text key — the daemon parser requires its closed key + * set. + */ + static String workflowLaunchSummary(Map block) { + if (!"workflow_launch".equals(block.get("type")) + || !(block.get("definition") instanceof Map pin)) { + return null; + } + String id = string(pin.get("definitionId")); + String digest = string(pin.get("definitionDigest")); + if (id == null || digest == null + || !(pin.get("definitionRevision") instanceof Number revision)) { + return null; + } + return ""; + } + + void materializeText(EventRecord event, String partType, Map> projectedParts) { + String text = string(event.data().get("text")); + if (text == null || text.isEmpty()) { + return; + } + String itemId = string(event.data().get("itemId")); + if (itemId == null) { + itemId = EventIdentity.assistantItemId(event.turnId()); + } + // The previous part of the same series is the one with the greatest + // last_sequence, whatever events sit between the two deltas — an + // announcement row must not split an assistant message in two + // (#13300 R3-3, symmetric with the write-side identity lookup). + List parts = projectedParts.computeIfAbsent(itemId, + key -> new ArrayList<>(partsForItem(event.tenantId(), event.sessionId(), key))); + String partId = event.contentPartId() != null ? event.contentPartId() : parts.stream() + .filter(part -> partType.equals(part.type())) + .map(ItemPartRecord::partId).findFirst().orElseGet(() -> + EventIdentity.textPartId(event.turnId(), partType, event.sequence())); + upsertItem(event, itemId, "message", "assistant", "in_progress", + Map.of()); + appendPart(event, itemId, partId, partType, text, projectedParts); + } + + void materializeTool(EventRecord event) { + String itemId = EventIdentity.toolItemId(event.turnId(), + event.sequence(), event.data()); + String sourceStatus = string(event.data().get("status")); + // Protocol tokens must fold with Locale.ROOT: under a tr_TR default + // locale "FAILED" becomes "faıled" and misses these arms. + String status = switch (sourceStatus == null ? "" + : sourceStatus.toLowerCase(Locale.ROOT)) { + case "completed", "success" -> "completed"; + case "failed" -> "failed"; + case "cancelled" -> "cancelled"; + default -> "in_progress"; + }; + Map attributes = existingAttributes(event, itemId); + if (attributes.get("result") instanceof Map previous) { + if (!(event.data().get("result") instanceof Map next) + || number(next.get("projection_revision")) + <= number(previous.get("projection_revision"))) { + return; + } + } + attributes.putAll(event.data()); + attributes.remove("itemId"); + upsertItem(event, itemId, "tool_call", "assistant", status, + Map.copyOf(attributes)); + } + + static long number(Object value) { + return value instanceof Number numeric ? numeric.longValue() : 0; + } + + Map existingAttributes(EventRecord event, + String itemId) { + List rows = jdbc.query("SELECT attributes_json FROM" + + " ma_message WHERE tenant_id = ? AND" + + " session_id = ? AND item_id = ?", + (result, row) -> result.getString("attributes_json"), + event.tenantId(), event.sessionId(), itemId); + return rows.isEmpty() ? new LinkedHashMap<>() + : new LinkedHashMap<>(storeRows.readMap(rows.get(0))); + } + + void settleTurnItems(EventRecord event) { + String status = switch (event.type()) { + case "turn.completed" -> "completed"; + case "turn.cancelled" -> "cancelled"; + default -> "failed"; + }; + jdbc.update("UPDATE ma_message SET item_status = ?," + + " last_sequence = CASE WHEN last_sequence < ?" + + " THEN ? ELSE last_sequence END, gmt_modified = ?," + + " revision = revision + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND item_status" + + " = 'in_progress'", + status, event.sequence(), event.sequence(), + event.createdAt(), event.tenantId(), event.sessionId(), + event.turnId()); + } + + void upsertItem(EventRecord event, String itemId, String type, + String role, String status, Map attributes) { + int updated = jdbc.update("UPDATE ma_message SET" + + " item_status = ?, attributes_json = ?," + + " last_sequence = ?, gmt_modified = ?, revision =" + + " revision + 1 WHERE tenant_id = ? AND session_id" + + " = ? AND item_id = ?", + status, storeRows.writeJson(attributes), event.sequence(), + event.createdAt(), event.tenantId(), event.sessionId(), + itemId); + if (updated == 0) { + jdbc.update("INSERT INTO ma_message (tenant_id," + + " session_id, item_id, turn_id, item_type," + + " item_role, item_status, attributes_json, parts_json," + + " first_sequence, last_sequence, gmt_create," + + " gmt_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]'," + + " ?, ?, ?, ?)", + event.tenantId(), event.sessionId(), itemId, + event.turnId(), type, role, status, + storeRows.writeJson(attributes), event.sequence(), event.sequence(), + event.createdAt(), event.createdAt()); + } + } + + void replacePart(EventRecord event, String itemId, + String partId, String type, String text, Map> projectedParts) { + changePart(event, itemId, partId, type, text, false, projectedParts); + } + + void appendPart(EventRecord event, String itemId, + String partId, String type, String text, Map> projectedParts) { + changePart(event, itemId, partId, type, text, true, projectedParts); + } + + void changePart(EventRecord event, String itemId, String partId, + String type, String text, boolean append, Map> projectedParts) { + List parts = projectedParts.computeIfAbsent(itemId, + key -> new ArrayList<>(partsForItem(event.tenantId(), event.sessionId(), key))); + int index = -1; + for (int current = 0; current < parts.size(); current++) { + if (partId.equals(parts.get(current).partId())) { + index = current; + break; + } + } + ItemPartRecord old = index < 0 ? null : parts.get(index); + ItemPartRecord next = new ItemPartRecord(partId, type, + append && old != null ? old.text() + text : text, + old == null ? event.sequence() : old.firstSequence(), event.sequence(), + old == null ? event.createdAt() : old.createdAt(), event.createdAt(), + old == null ? 1 : old.revision() + 1); + if (index < 0) { + parts.add(next); + } else { + parts.set(index, next); + } + } + + void flushParts( + String tenantId, String sessionId, Map> projectedParts) { + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + for (var entry : projectedParts.entrySet()) { + String item = entry.getKey(); + byte[] bytes = + storeRows.writeJson(entry.getValue()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + String owner = + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + scope, item); + Map previous = + jdbc.queryForMap( + "SELECT revision, body_resource_key FROM ma_message WHERE tenant_id = ?" + + " AND session_id = ? AND item_id = ? FOR UPDATE", + tenantId, + sessionId, + item); + long revision = ((Number) previous.get("revision")).longValue(); + if (previous.get("body_resource_key") != null) { + ManagedProjectionResourceCollector.release(jdbc, scope, "MESSAGE", owner); + } + jdbc.update( + "DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" + + " 'MESSAGE' AND owner_key = ?", + scope, + owner); + String resource = null; + if (bytes.length > 64 * 1024) { + String digest = WorkspaceEvidenceResources.digest(bytes); + String id = "message:" + owner + ":" + revision + ":" + digest; + resource = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, id); + try { + jdbc.update( + "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, 'managed-message-parts', 1, ?, ?, 'MYSQL_INLINE', ?, 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", + resource, + scope, + tenantId, + sessionId, + id, + bytes.length, + digest, + bytes); + } catch (DuplicateKeyException duplicate) { + List matching = + jdbc.query( + "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE resource_key = ? FOR UPDATE", + (row, index) -> + scope.equals(row.getString("session_scope_key")) + && tenantId.equals(row.getString("tenant_id")) + && sessionId.equals(row.getString("session_id")) + && id.equals(row.getString("resource_id")) + && "managed-message-parts" + .equals(row.getString("kind")) + && row.getInt("schema_version") == 1 + && row.getLong("byte_length") == bytes.length + && digest.equals(row.getString("sha256")) + && java.util.Arrays.equals( + bytes, row.getBytes("inline_bytes")) + && "MYSQL_INLINE" + .equals(row.getString("storage_kind")) + && "REFERENCED".equals(row.getString("state")) + && row.getString("deletion_operation_key") + == null + && row.getString("publication_id") == null + && row.getString("object_key") == null + && row.getString("object_version_id") == null + && row.getString("encryption_key_id") == null, + resource); + if (matching.size() != 1 || !matching.getFirst()) { + throw new IllegalStateException( + "Immutable message body identity differs.", duplicate); + } + } + jdbc.update( + "INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key, tenant_id, session_id, resource_id, gmt_create) VALUES (?, 'MESSAGE', ?, ?, ?, ?, ?, ?, " + ManagedSchemaTime.SQL_NOW + ")", + scope, + owner, + revision, + resource, + tenantId, + sessionId, + id); + } + int changed = + jdbc.update( + "UPDATE ma_message SET parts_json = ?, body_resource_key = ?" + + " WHERE tenant_id = ? AND session_id = ? AND item_id = ?", + resource == null + ? new String(bytes, java.nio.charset.StandardCharsets.UTF_8) + : "[]", + resource, + tenantId, + sessionId, + item); + if (changed != 1) { + throw new IllegalStateException("Message disappeared during locked projection."); + } + } + } + + List partsForItem(String tenantId, String sessionId, String itemId) { + List> rows = + jdbc.query( + "SELECT parts_json, body_resource_key FROM ma_message WHERE tenant_id = ?" + + " AND session_id = ? AND item_id = ? FOR UPDATE", + (row, index) -> readParts(row.getString(1), row.getString(2), tenantId, sessionId), + tenantId, + sessionId, + itemId); + return rows.isEmpty() ? List.of() : rows.getFirst(); + } + + List readParts(String value, String resourceKey, String tenantId, String sessionId) { + if (resourceKey == null) { + return readParts(value); + } + List rows = + jdbc.query( + "SELECT inline_bytes, byte_length, sha256 FROM ma_resource WHERE" + + " resource_key = ? AND session_scope_key = ? AND tenant_id = ? AND" + + " session_id = ? AND kind = ? AND schema_version = 1 AND state =" + + " 'REFERENCED' AND deletion_operation_key IS NULL", + (row, i) -> { + byte[] bytes = row.getBytes("inline_bytes"); + if (bytes == null + || bytes.length != row.getLong("byte_length") + || !WorkspaceEvidenceResources.digest(bytes) + .equals(row.getString("sha256"))) { + throw new IllegalStateException( + "Message body resource digest differs."); + } + return bytes; + }, + resourceKey, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + tenantId, + sessionId, + "managed-message-parts"); + if (rows.size() != 1) { + throw new IllegalStateException("Message body resource is unavailable."); + } + return readParts(new String(rows.getFirst(), java.nio.charset.StandardCharsets.UTF_8)); + } + + List readParts(String value) { + try { + return value == null ? List.of() : objectMapper.readValue(value, PARTS_TYPE); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Stored message parts are invalid.", failure); + } + } + + List allItems(String tenantId, String sessionId) { + return withParts(jdbc.query("SELECT " + ManagedSchemaProjection.MESSAGE.columns() + " FROM ma_message WHERE" + + " tenant_id = ? AND session_id = ? ORDER BY" + + " first_sequence ASC", + itemMapper, tenantId, sessionId)); + } + + List withParts(List rows) { + return rows.stream().map(ItemRow::toRecord).toList(); + } + + static String string(Object value) { + return value instanceof String ? (String) value : null; + } + + static List> inputData(Object value) { + if (!(value instanceof List values)) { + return List.of(); + } + List> input = new ArrayList<>(); + for (Object item : values) { + if (!(item instanceof Map raw)) { + continue; + } + Map block = new LinkedHashMap<>(); + raw.forEach((key, entry) -> { + if (key instanceof String name) { + block.put(name, entry); + } + }); + input.add(Map.copyOf(block)); + } + return List.copyOf(input); + } + + record ItemRow(String tenantId, String sessionId, String itemId, + String turnId, String type, String role, String status, + Map attributes, long firstSequence, + long lastSequence, long createdAt, long updatedAt, + long revision, List content) { + private ItemRecord toRecord() { + return new ItemRecord(tenantId, sessionId, itemId, turnId, type, + role, status, attributes, firstSequence, lastSequence, + createdAt, updatedAt, revision, content); + } + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java new file mode 100644 index 00000000000..edb6f20920b --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionAdmissions.java @@ -0,0 +1,850 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.catalog.AgentDefinitionExtensions; +import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.contracts.persistence.WorkspaceCsiRegistration; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionCreationCommand; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelOption; +import com.alibaba.qwen.code.managedagent.model.StoreModels; +import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.ResolvedBinding; +import com.alibaba.qwen.code.managedagent.settings.ManagedAgentSettings; +import com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile; +import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; +import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +final class AgentSessionAdmissions { + private final AgentEventJournal eventJournal; + private final AgentSessionCommands sessionCommands; + private final AgentSessionPolicies sessionPolicies; + private final AgentStoreRows storeRows; + private final ManagedExtensionRecordStore configRecords; + private final JdbcTemplate jdbc; + private final Clock clock; + private final String approvalMode; + private final ManagedWorkspaceRegistry workspaces; + private final String agentRevision; + private final List modelCatalog; + private final ManagedAgentDefinitionStore definitions; + private final ManagedSessionSkills creationSkills; + private final SessionWorktreeStore sessionWorktrees; + private final com.alibaba.qwen.code.managedagent.contracts.extensions.ExtensionPackages extensionPackages; + private final boolean workspaceFilesEnabled; + private final boolean workspaceShellEnabled; + private final boolean workspaceChildAgentsEnabled; + private final boolean managedExtensionExecutionEnabled; + private final List workspaceMounts; + private final ResidentWorkspaces residentWorkspaces; + private final WorkspaceCreationRoots createdRoots; + private final JournalEventTransport journalEventTransport; + private final boolean journalEventTransportEnabled; + + AgentSessionAdmissions(AgentEventJournal eventJournal, + AgentSessionCommands sessionCommands, + AgentSessionPolicies sessionPolicies, + AgentStoreRows storeRows, + ManagedExtensionRecordStore configRecords, + JdbcTemplate jdbc, + Clock clock, + String approvalMode, + ManagedWorkspaceRegistry workspaces, + String agentRevision, + List modelCatalog, + ManagedAgentDefinitionStore definitions, + ManagedSessionSkills creationSkills, + SessionWorktreeStore sessionWorktrees, + com.alibaba.qwen.code.managedagent.contracts.extensions.ExtensionPackages extensionPackages, + boolean workspaceFilesEnabled, + boolean workspaceShellEnabled, + boolean workspaceChildAgentsEnabled, + boolean managedExtensionExecutionEnabled, + List workspaceMounts, + ResidentWorkspaces residentWorkspaces, + WorkspaceCreationRoots createdRoots, + JournalEventTransport journalEventTransport, + boolean journalEventTransportEnabled) { + this.eventJournal = eventJournal; + this.sessionCommands = sessionCommands; + this.sessionPolicies = sessionPolicies; + this.storeRows = storeRows; + this.configRecords = configRecords; + this.jdbc = jdbc; + this.clock = clock; + this.approvalMode = approvalMode; + this.workspaces = workspaces; + this.agentRevision = agentRevision; + this.modelCatalog = modelCatalog; + this.definitions = definitions; + this.creationSkills = creationSkills; + this.sessionWorktrees = sessionWorktrees; + this.extensionPackages = extensionPackages; + this.workspaceFilesEnabled = workspaceFilesEnabled; + this.workspaceShellEnabled = workspaceShellEnabled; + this.workspaceChildAgentsEnabled = workspaceChildAgentsEnabled; + this.managedExtensionExecutionEnabled = managedExtensionExecutionEnabled; + this.workspaceMounts = workspaceMounts; + this.residentWorkspaces = residentWorkspaces; + this.createdRoots = createdRoots; + this.journalEventTransport = journalEventTransport; + this.journalEventTransportEnabled = journalEventTransportEnabled; + } + + Admission insertSessionCommand(String tenantId, String actorId, + String operation, String idempotencyKey, String requestDigest, + String agentId, String requestedRevision, String title, + List> input, String payloadDigest) { + AgentSessionPolicies.AgentPin pin = sessionPolicies.resolveAgentPin(tenantId, agentId, requestedRevision, + false); + requireCreationScope(tenantId, idempotencyKey, false); + return insertSession(tenantId, operation, idempotencyKey, + requestDigest, agentId, title, input, payloadDigest, + null, actorId, pin); + } + + Admission insertWorkspaceSessionCommand(SessionCreationCommand command) { + String tenantId = command.tenantId(); + String actorId = command.actorId(); + String idempotencyKey = command.idempotencyKey(); + String requestDigest = command.requestDigest(); + String agentId = command.agentId(); + String requestedRevision = command.requestedRevision(); + String title = command.title(); + List> input = command.input(); + String payloadDigest = command.payloadDigest(); + WorkspaceSelection selection = command.selection(); + String requestedApprovalMode = command.requestedApprovalMode(); + boolean approvalSelectionSupported = command.approvalSelectionSupported(); + Boolean requestedPlanMode = command.requestedPlanMode(); + boolean planModeSupported = command.planModeSupported(); + List> skills = command.skills(); + boolean publicChildrenSupported = command.publicChildrenSupported(); + String modelId = command.modelId(); + List extensionPins = + command.extensionPins(); + Set extensionLifecycleProfiles = command.extensionLifecycleProfiles(); + SessionWorktreeStore.Plan worktree = command.worktree(); + if (!input.isEmpty() && !workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + // InnoDB's first consistent read must follow the migration admission lock. + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + List existing = findWorkspaceCommand(tenantId, + actorId, idempotencyKey); + if (!existing.isEmpty()) { + return replayWorkspaceCommand(tenantId, actorId, + requestDigest, existing.getFirst(), false); + } + if (modelId != null && (!"qwen-code".equals(agentId) || !workspaceFilesEnabled + || modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId)))) { + throw new ManagedAgentException(400, "unsupported_model", + "The model is not published for this Agent."); + } + AgentSessionPolicies.AgentPin pin = AgentSessionPolicies.selectApprovalMode(sessionPolicies.resolveAgentPin(tenantId, agentId, + requestedRevision, true), requestedApprovalMode, approvalSelectionSupported); + boolean planMode = Boolean.TRUE.equals(requestedPlanMode); + if (planMode && (!planModeSupported || !approvalSelectionSupported + || pin.hooks() || pin.mcp() || !(WorkspaceToolProfiles.FILES.equals(pin.toolProfile()) + || WorkspaceToolProfiles.isPublicShell(pin.toolProfile())))) { + throw new ManagedAgentException(409, "unsupported_feature", "Plan mode is unavailable"); + } + if (!input.isEmpty() && WorkspaceToolProfiles.isPublicShell(pin.toolProfile()) + && !workspaceShellEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + requireCreationScope(tenantId, idempotencyKey, true); + ResolvedBinding workspace = workspaces.resolveForCreation( + tenantId, actorId, selection); + if (extensionPins != null) { + if (extensionPackages == null) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + if ((!WorkspaceToolProfiles.SHELL.equals(pin.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(pin.toolProfile())) + || !extensionLifecycleProfiles.contains(pin.toolProfile())) { + throw new ManagedAgentException(409, "unsupported_feature", + "Extension configuration requires a negotiated Shell or MCP Session lifecycle profile."); + } + extensionPackages.requireSelection(tenantId, actorId, workspace.binding().getWorkspaceId(), extensionPins); + } + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId()); + boolean sshWorkspace = sessionPolicies.isSshWorkspace(workspace.binding()); + if (sshWorkspace) { + AgentSessionPolicies.requireSshToolProfile(pin.toolProfile()); + if (pin.mcp()) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + } + boolean resident = residentWorkspaces.find(workspace.binding()) != null; + if (resident) { + if (!workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + residentWorkspaces.requireCreation(jdbc, workspace.binding(), pin.toolProfile()); + } else if (!sshWorkspace) { + WorkspaceStorageKindGuard.requireLocalAlias(jdbc, tenantId, workspace.binding().getStorageId()); + } + if (!resident && !input.isEmpty() + && (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef()) + || !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef()) + || !sshWorkspace && !configuredStorage(tenantId, workspace.binding().getStorageId()))) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + List> merged = new ArrayList<>(); + if (!skills.isEmpty() && pin.digest() != null) { + merged.addAll(AgentDefinitionExtensions.skills(storeRows.readMap(definitions.find(tenantId, agentId, + Long.parseLong(pin.revision())).orElseThrow().definitionJson()).get("skills"), true)); + } + merged.addAll(skills); + var effectiveSkills = AgentDefinitionExtensions.sessionSkills(merged); + if (worktree != null && sessionWorktrees == null) { + throw new ManagedAgentException(409, "worktree_unavailable", "Session worktrees are unavailable."); + } + ResolvedBinding target = worktree == null ? workspace : worktree.target(workspace); + Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey, + requestDigest, agentId, title, input, payloadDigest, + target, actorId, pin, null, planMode, modelId); + if (worktree != null) { + sessionWorktrees.admit(tenantId, actorId, admission.sessionId(), worktree, workspace); + } + if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && (!sshWorkspace || managedExtensionExecutionEnabled) + && (managedExtensionExecutionEnabled || "qwen-code".equals(agentId) && pin.digest() == null) + && WorkspaceToolProfiles.SHELL.equals(pin.toolProfile())) { + jdbc.update("UPDATE ma_session SET public_child_agents_version=1 WHERE tenant_id=? AND session_id=?", + tenantId, admission.sessionId()); + } + if (!skills.isEmpty()) { + creationSkills.save(tenantId, admission.sessionId(), effectiveSkills); + } + if (extensionPins != null) { + creationSkills.saveInitialSelection(tenantId, admission.sessionId(), extensionPins); + } + return admission; + } + + boolean configuredStorage(String tenant, String storage) { + return workspaceMounts.stream().anyMatch(mount -> tenant.equals(mount.tenantId()) + && storage.equals(mount.storageId())) || createdRoots.resolve(tenant, storage, false) != null; + } + + /** Trusted internal creation; public requests and definitions cannot select this policy. */ + Admission insertInternalTeamLeadSessionCommand(String tenantId, + String actorId, String idempotencyKey, String requestDigest, + String title, WorkspaceSelection selection) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + List existing = findWorkspaceCommand(tenantId, + actorId, idempotencyKey); + if (!existing.isEmpty()) { + return replayWorkspaceCommand(tenantId, actorId, + requestDigest, existing.getFirst(), true); + } + if (!workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + requireCreationScope(tenantId, idempotencyKey, true); + ResolvedBinding workspace = workspaces.resolveForCreation(tenantId, actorId, selection); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId()); + if (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef()) + || !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef()) + || !configuredStorage(tenantId, workspace.binding().getStorageId())) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + return insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest, + "qwen-code", title, List.of(), null, workspace, actorId, + new AgentSessionPolicies.AgentPin(agentRevision, null, approvalMode, WorkspaceToolProfiles.INTERNAL_TEAM_LEAD, false, false)); + } + + Optional findWorkspaceCreateReplay(String tenantId, + String actorId, String idempotencyKey, String requestDigest) { + List existing = findWorkspaceCommand(tenantId, + actorId, idempotencyKey); + if (existing.isEmpty()) { + return Optional.empty(); + } + // The pre-gate probe re-runs the sibling's re-checks — a replay must + // not outlive the Workspace binding or the caller's read grant. + return Optional.of(replayWorkspaceCommand(tenantId, actorId, + requestDigest, existing.getFirst(), false)); + } + + Admission replayWorkspaceSessionCommand(String tenantId, + String actorId, String idempotencyKey, String requestDigest) { + List existing = findWorkspaceCommand(tenantId, + actorId, idempotencyKey); + if (existing.isEmpty()) { + throw new ManagedAgentException(409, + "idempotency_conflict", "The creation command is missing."); + } + return replayWorkspaceCommand(tenantId, actorId, requestDigest, + existing.getFirst(), false); + } + + Admission insertCsiSessionCommand( + WorkspaceCsiRegistration registration, + String actorId, + String idempotencyKey, + String requestDigest, + String requestedRevision, + String title, + WorkspaceSelection selection) { + requireCsiJournalIsolation(); + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + throw new IllegalStateException("Private CSI CREATE requires a transaction"); + } + String tenantId = registration.tenantId(); + requireCreationScope(tenantId, idempotencyKey, true); + List existing = findWorkspaceCommand(tenantId, actorId, idempotencyKey); + if (!existing.isEmpty()) { + Admission replay = + replayWorkspaceCommand( + tenantId, actorId, requestDigest, existing.getFirst(), false); + requireCsiRequest(registration, replay.sessionId()); + return replay; + } + AgentSessionPolicies.AgentPin pin = sessionPolicies.resolveAgentPin(tenantId, "qwen-code", requestedRevision, true); + ResolvedBinding workspace = workspaces.resolveForCreation(tenantId, actorId, selection); + if (!registration.storageId().equals(workspace.binding().getStorageId()) + || !CsiFilesRetirementProfile.CONFIG_REF.equals(workspace.configRef()) + || !CsiFilesRetirementProfile.POLICY_REF.equals(workspace.policyRef())) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + return insertSession( + tenantId, + "CREATE_SESSION", + idempotencyKey, + requestDigest, + "qwen-code", + title, + List.of(), + null, + workspace, + actorId, + pin, + registration); + } + + void requireCsiJournalIsolation() { + if (journalEventTransportEnabled || journalEventTransport != null) { + throw new ManagedAgentException(409, "csi_journal_transport_unsupported", + "The private CSI lane requires an isolated journal without an event transport."); + } + } + + RuntimeProvisionRequest requireCsiRequest( + WorkspaceCsiRegistration registration, String sessionId) { + requireCsiJournalIsolation(); + SessionRecord session = storeRows.requireSessionForUpdate(registration.tenantId(), sessionId); + if (!CsiFilesRetirementProfile.PROFILE.equals(session.toolProfile()) + || session.workspace() == null + || !"qwen-code".equals(session.agentId()) + || !CsiFilesRetirementProfile.CONTEXT_CONFIG_REF.equals( + session.workspace().getContextConfigRef()) + || !registration.storageId().equals(session.workspace().getStorageId())) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + RuntimeProvisionRequest request = + CsiFilesRetirementProfile.request( + session.workspace(), registration.mountRoot(), session.sessionId()); + String saved = + jdbc.queryForObject( + "SELECT runtime_request_key FROM ma_session" + + " WHERE tenant_id = ? AND session_id = ? FOR UPDATE", + String.class, + session.tenantId(), + session.sessionId()); + if (!request.requestKey().equals(saved)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + return request; + } + + static String childActorOf(String parentSessionId) { + return "child:" + parentSessionId; + } + + Admission insertChildSessionCommand(String tenantId, + String parentSessionId, String idempotencyKey, + String requestDigest, String title, List> input, + String payloadDigest, StoreModels.SessionLineage lineage) { + return insertChildSession(tenantId, parentSessionId, idempotencyKey, + requestDigest, title, input, payloadDigest, lineage, null, null); + } + + Admission insertChildSessionCommand(String tenantId, + String parentSessionId, String idempotencyKey, + String requestDigest, String title, List> input, + String payloadDigest, StoreModels.SessionLineage lineage, + String childCwdRelative) { + if (childCwdRelative == null) { + throw new IllegalArgumentException("An isolated child needs its Workspace directory"); + } + return insertChildSession(tenantId, parentSessionId, idempotencyKey, + requestDigest, title, input, payloadDigest, lineage, + childCwdRelative, null); + } + + Admission insertPublicChildSessionCommand(String tenantId, String parentSessionId, + String idempotencyKey, String requestDigest, String title, List> input, + String payloadDigest, StoreModels.SessionLineage lineage, com.fasterxml.jackson.databind.JsonNode snapshot) { + return insertChildSession(tenantId, parentSessionId, idempotencyKey, requestDigest, + title, input, payloadDigest, lineage, null, snapshot); + } + + Admission insertChildSession(String tenantId, + String parentSessionId, String idempotencyKey, + String requestDigest, String title, List> input, + String payloadDigest, StoreModels.SessionLineage lineage, + String childCwdRelative, com.fasterxml.jackson.databind.JsonNode snapshot) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + List existing = findWorkspaceCommand(tenantId, + childActorOf(parentSessionId), idempotencyKey); + if (existing.isEmpty()) { + WorkspaceMigrationAdmission.requireSessionOpen(jdbc, tenantId, parentSessionId); + } + SessionRecord parent = storeRows.requireSessionForUpdate(tenantId, + parentSessionId); + if (parent.workspace() == null || residentWorkspaces.find(parent.workspace()) != null || !"ACTIVE".equals(parent.status()) + || CsiFilesRetirementProfile.PROFILE.equals(parent.toolProfile())) { + throw new ManagedAgentException(409, + "child_parent_unavailable", + "The parent Session cannot admit a child."); + } + if (parent.publicChildAgentsVersion() == 1) { + if (snapshot == null || !snapshot.path("version").isIntegralNumber() || !Set.of(1, 2).contains(snapshot.path("version").asInt()) || lineage.depth() != 1 + || childCwdRelative != null || snapshot.size() != (snapshot.path("version").asInt() == 2 ? 9 : 8) + || !snapshot.path("sourcePromptId").isTextual() || snapshot.path("sourcePromptId").asText().isBlank() + || snapshot.path("sourcePromptId").asText().length() > 144 + || !Set.of("default", "auto-edit", "yolo").contains(snapshot.path("approvalMode").asText()) + || !snapshot.path("approvalRevision").isIntegralNumber() || snapshot.path("approvalRevision").asLong(-1) < 0 + || !snapshot.path("approvalRevision").canConvertToLong() || snapshot.path("approvalRevision").asLong() > 9_007_199_254_740_991L + || !snapshot.path("modelId").isTextual() || snapshot.path("modelId").asText().isBlank() + || snapshot.path("modelId").asText().getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 512 + || !snapshot.path("modelRevision").isIntegralNumber() || snapshot.path("modelRevision").asLong(-1) < 0 + || !snapshot.path("modelRevision").canConvertToLong() || snapshot.path("modelRevision").asLong() > 9_007_199_254_740_991L + || !snapshot.has("skillsRef") || snapshot.path("modelRoute").size() != (snapshot.path("modelRoute").has("origin") ? 4 : 3) + || !snapshot.path("modelRoute").path("authType").isTextual() + || !snapshot.path("modelRoute").path("authType").asText().matches("[a-z][a-z0-9_-]{0,63}") + || !snapshot.path("modelRoute").path("endpointDigest").isTextual() + || !snapshot.path("modelRoute").path("endpointDigest").asText().matches("[a-f0-9]{64}") + || !snapshot.path("modelRoute").path("providerPin").isTextual() + || !snapshot.path("modelRoute").path("providerPin").asText().matches("[a-f0-9]{64}")) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The public child launch snapshot is invalid"); + } + if (snapshot.path("version").asInt() == 2) { + if (configRecords == null) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); + } + ManagedExtensionAgentLaunch.require(jdbc, configRecords, parent, snapshot); + } + if (snapshot.path("modelRoute").has("origin")) { + var origin = snapshot.path("modelRoute").get("origin"); + if (origin.size() != 4 || !origin.path("modelId").isTextual() || origin.path("modelId").asText().isBlank() + || origin.path("modelId").asText().getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 512 + || !origin.path("authType").isTextual() + || !origin.path("authType").asText().matches("[a-z][a-z0-9_-]{0,63}") + || !origin.path("endpointDigest").isTextual() + || !origin.path("endpointDigest").asText().matches("[a-f0-9]{64}") + || !origin.path("providerPin").isTextual() + || !origin.path("providerPin").asText().matches("[a-f0-9]{64}")) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The public child model origin is invalid"); + } + } + } else if (snapshot != null) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The parent has no public child admission"); + } + storeRows.requireLegacyMutation(tenantId, parentSessionId); + if (!input.isEmpty() && !workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + if (!existing.isEmpty()) { + WorkspaceCommand command = existing.getFirst(); + if (!command.requestDigest().equals(requestDigest)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new Admission(command.sessionId(), command.turnId(), true, + false); + } + // The terminal truth fence: the relay can give up on a stalled + // creation and commit the run's terminal verdict (creation_failed + // among them), then retire — while a lease-losing worker that + // paused before this call is about to mint the Session anyway. + // The row lock serializes that verdict against this read, so a + // settled record refuses the mint inside its own transaction — + // addressed by the parent's launch row's own primary key, which + // is the same key the idempotent admission derives, so the + // locking read never becomes a cross-tenant table scan under + // REPEATABLE READ. + String fenceScopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, + parentSessionId); + String fenceRecordKey = ManagedExtensionProjection.recordKey( + parentSessionId, "child_run", lineage.parentChildRunId()); + List runState = jdbc.query("SELECT task_state FROM" + + " ma_session_record" + + " WHERE session_scope_key = ? AND record_key = ?" + + " FOR UPDATE", + (result, row) -> result.getString(1), fenceScopeKey, + fenceRecordKey); + if (!runState.isEmpty() + && ("completed".equals(runState.getFirst()) + || "failed".equals(runState.getFirst()) + || "cancelled".equals(runState.getFirst()))) { + throw new ManagedAgentException(409, "child_run_settled", + "The child run already settled; creation owes no more Session."); + } + // A child bound to a snapshot reads with the read-only profile + // whatever its parent's is (#13753 I3): the mode comes from the row + // this transaction locks, so no caller can bind a snapshot copy to + // a profile that writes. Only the profile differs from the parent. + String toolProfile = null; + if (childCwdRelative != null + && ChildWorkspaceStore.SNAPSHOT.equals(requireReadyChildWorkspace( + tenantId, parentSessionId, lineage.parentChildRunId(), + parent.workspace(), childCwdRelative))) { + toolProfile = WorkspaceToolProfiles.READ; + } + long now = clock.millis(); + String sessionId = UUID.randomUUID().toString(); + String turnId = input.isEmpty() ? null : AgentStoreRows.publicId("turn"); + String promptId = input.isEmpty() ? null : UUID.randomUUID().toString(); + jdbc.update("INSERT INTO ma_session (tenant_id," + + " session_id, agent_id, agent_revision," + + " agent_definition_digest, title," + + " status, gmt_create, gmt_modified, workspace_id," + + " workspace_generation, workspace_storage_id," + + " cwd_relative, context_config_ref," + + " context_revision, workspace_config_ref," + + " workspace_policy_ref, tool_profile," + + " creator_actor_key, approval_mode," + + " parent_session_id, root_session_id," + + " parent_child_run_id, child_depth)" + + " SELECT tenant_id, ?, agent_id," + + " agent_revision, agent_definition_digest," + + " ?, 'ACTIVE', ?, ?," + + " workspace_id, workspace_generation," + + " workspace_storage_id, " + + (childCwdRelative == null ? "cwd_relative" : "?") + + ", context_config_ref, context_revision," + + " workspace_config_ref, workspace_policy_ref, " + + (toolProfile == null ? "tool_profile" : "?") + + ", creator_actor_key, approval_mode," + + " ?, ?, ?, ?" + + " FROM ma_session" + + " WHERE tenant_id = ? AND session_id = ?", + childInsertArgs(sessionId, title, now, childCwdRelative, + toolProfile, lineage, tenantId, parentSessionId)); + jdbc.update("INSERT INTO ma_event_stream" + + " (tenant_id, session_id, stream_kind, stream_id," + + " covered_sequence, gmt_modified) VALUES" + + " (?, ?, 'SESSION', '', 0, ?)", + tenantId, sessionId, now); + if (snapshot != null) { + byte[] owner = jdbc.queryForObject("SELECT COALESCE(owner_actor_key, creator_actor_key) FROM ma_session WHERE tenant_id=? AND session_id=?", byte[].class, tenantId, parentSessionId); + jdbc.update("UPDATE ma_session SET public_child_agents_version=1, owner_actor_key=?, approval_mode=?, selected_approval_mode=NULL, approval_revision=0, model_id=?, model_revision=1 WHERE tenant_id=? AND session_id=?", + owner, snapshot.path("approvalMode").asText(), snapshot.path("modelId").asText(), tenantId, sessionId); + } + if (turnId != null) { + sessionCommands.insertTurn(tenantId, sessionId, turnId, promptId, input, + payloadDigest, now); + } + ManagedReceipts.insertCreation(jdbc, tenantId, + com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, childActorOf(parentSessionId)), + idempotencyKey, requestDigest, sessionId, turnId, now); + var inheritedSkills = snapshot == null ? creationSkills.snapshot(tenantId, parentSessionId) + : creationSkills.fromLaunchReference(tenantId, parentSessionId, snapshot.path("skillsRef")); + if (inheritedSkills != null) { + creationSkills.save(tenantId, sessionId, inheritedSkills); + } + eventJournal.appendEvent(tenantId, sessionId, null, "session.created", + Map.of("sessionId", sessionId), false, null, now); + if (turnId != null) { + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.accepted", + AgentEventJournal.acceptedData(turnId, input), false, null, now); + } + return new Admission(sessionId, turnId, false, true); + } + + static Object[] childInsertArgs(String sessionId, String title, + long now, String childCwdRelative, String toolProfile, + StoreModels.SessionLineage lineage, String tenantId, + String parentSessionId) { + List args = new ArrayList<>(List.of(sessionId, title, now, now)); + if (childCwdRelative != null) { + args.add(childCwdRelative); + } + if (toolProfile != null) { + args.add(toolProfile); + } + args.addAll(List.of(lineage.parentSessionId(), lineage.rootSessionId(), + lineage.parentChildRunId(), lineage.depth(), tenantId, + parentSessionId)); + return args.toArray(); + } + + /** + * Decision 13 of the isolation slice: an isolated child binds only to a ready, unfinished child + * Workspace prepared from the parent's current Workspace and storage. The locking read + * serializes the creation against a finish request, which locks the same row. Answers + * the row's mode (#13753 I3), which its operation's request fixed when it was admitted. + */ + String requireReadyChildWorkspace(String tenantId, + String parentSessionId, String childRunId, ContextBinding parent, + String childCwdRelative) { + List ready = + jdbc.query( + "SELECT child_state AS state, finish_request, parent_workspace_id AS" + + " workspace_id, workspace_generation, storage_id, child_cwd_relative" + + " FROM ma_workspace WHERE workspace_kind = 'CHILD_GIT' AND" + + " parent_session_id = ? AND child_run_id = ? AND tenant_id = ? FOR" + + " UPDATE", + (row, index) -> + "ready".equals(row.getString("state")) + && row.getString("finish_request") == null + && parent.getWorkspaceId() + .equals(row.getString("workspace_id")) + && parent.getWorkspaceGeneration() + == row.getLong("workspace_generation") + && parent.getStorageId().equals(row.getString("storage_id")) + && childCwdRelative.equals( + row.getString("child_cwd_relative")), + parentSessionId, + childRunId, + tenantId); + if (ready.size() != 1 || !ready.getFirst()) { + throw new ManagedAgentException(409, "child_workspace_not_ready", + "The child run's Workspace is not ready to bind a child Session."); + } + // The request is written once, when the row is admitted, so the + // locked row's mode needs no lock of its own. + List requests = jdbc.queryForList("SELECT o.request_json FROM ma_workspace w" + + " JOIN ma_operation o ON o.tenant_id = w.tenant_id" + + " AND o.operation_id = w.child_workspace_id AND o.operation_kind = 'CHILD_WORKSPACE'" + + " WHERE w.workspace_kind = 'CHILD_GIT' AND w.parent_session_id = ?" + + " AND w.child_run_id = ? AND w.tenant_id = ?", + String.class, parentSessionId, childRunId, tenantId); + if (requests.size() != 1) { + throw new ManagedAgentException(409, "child_workspace_not_ready", + "The child run's Workspace is not ready to bind a child Session."); + } + return ChildWorkspaceStore.workspaceMode(requests.getFirst()); + } + + Admission replayChildSessionCommand(String tenantId, + String parentSessionId, String idempotencyKey, + String requestDigest) { + SessionRecord parent = storeRows.requireSessionForUpdate(tenantId, parentSessionId); + if (CsiFilesRetirementProfile.PROFILE.equals(parent.toolProfile())) { + throw new ManagedAgentException( + 409, + "child_parent_unavailable", + "The parent Session cannot admit a child."); + } + storeRows.requireLegacyMutation(tenantId, parentSessionId); + List existing = findWorkspaceCommand(tenantId, + childActorOf(parentSessionId), idempotencyKey); + if (existing.isEmpty()) { + throw new ManagedAgentException(409, + "idempotency_conflict", "The creation command is missing."); + } + WorkspaceCommand command = existing.getFirst(); + if (!command.requestDigest().equals(requestDigest)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + return new Admission(command.sessionId(), command.turnId(), true, + false); + } + + Admission replayWorkspaceCommand(String tenantId, String actorId, + String requestDigest, WorkspaceCommand command, boolean internalTeamLead) { + SessionRecord session = storeRows.requireSession(tenantId, command.sessionId()); + if (!command.requestDigest().equals(requestDigest) + || internalTeamLead != WorkspaceToolProfiles.INTERNAL_TEAM_LEAD.equals(session.toolProfile()) + || internalTeamLead && (!"qwen-code".equals(session.agentId()) + || session.agentDefinitionDigest() != null)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + ContextBinding bound = session.workspace(); + if (bound == null) { + throw new ManagedAgentException(409, + "workspace_unavailable", "Workspace binding is missing."); + } + if (!workspaces.canRead(tenantId, actorId, bound.getWorkspaceId())) { + throw new ManagedAgentException(404, + "workspace_not_found", "Workspace not found."); + } + return new Admission(command.sessionId(), command.turnId(), true, + false); + } + + // H2 truncates bare BINARY casts; the suffix also prevents NUL-padding aliases. + List findWorkspaceCommand(String tenantId, + String actorId, String idempotencyKey) { + ManagedReceipts.Receipt row = ManagedReceipts.creation(jdbc, tenantId, + com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId), idempotencyKey); + return row == null + ? List.of() + : List.of( + new WorkspaceCommand( + row.requestDigest(), row.sessionId(), row.turnId())); + } + + record WorkspaceCommand(String requestDigest, String sessionId, + String turnId) { + } + + void requireCreationScope(String tenantId, String idempotencyKey, boolean workspaceBound) { + var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_CREATE_MODE; + var scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.TENANT; + try { + ManagedReceipts.insert(jdbc, namespace, tenantId, scope, tenantId, idempotencyKey, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(Boolean.toString(workspaceBound)), + null, null, null, null, "RESERVED", null, workspaceBound, clock.millis()); + } catch (DuplicateKeyException existing) { + // The independently persisted creation-mode claim cannot be overwritten by replay. + } + ManagedReceipts.Receipt existing = ManagedReceipts.find(jdbc, namespace, tenantId, scope, + tenantId, idempotencyKey, true); + if (existing == null + || !Boolean.valueOf(workspaceBound).equals(existing.workspaceBound())) { + throw new ManagedAgentException(409, "idempotency_conflict", + "The idempotency key was reused with different content."); + } + } + + Admission insertSession(String tenantId, String operation, + String idempotencyKey, String requestDigest, String agentId, + String title, List> input, + String payloadDigest, ResolvedBinding resolved, + String actorId, AgentSessionPolicies.AgentPin pin) { + return insertSession( + tenantId, + operation, + idempotencyKey, + requestDigest, + agentId, + title, + input, + payloadDigest, + resolved, + actorId, + pin, + null); + } + + Admission insertSession( + String tenantId, + String operation, + String idempotencyKey, + String requestDigest, + String agentId, + String title, + List> input, + String payloadDigest, + ResolvedBinding resolved, + String actorId, + AgentSessionPolicies.AgentPin pin, + WorkspaceCsiRegistration registration) { + return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title, + input, payloadDigest, resolved, actorId, pin, registration, false); + } + + Admission insertSession(String tenantId, String operation, String idempotencyKey, + String requestDigest, String agentId, String title, List> input, + String payloadDigest, ResolvedBinding resolved, String actorId, AgentSessionPolicies.AgentPin pin, + WorkspaceCsiRegistration registration, boolean planMode) { + return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title, + input, payloadDigest, resolved, actorId, pin, registration, planMode, null); + } + + Admission insertSession(String tenantId, String operation, String idempotencyKey, + String requestDigest, String agentId, String title, List> input, + String payloadDigest, ResolvedBinding resolved, String actorId, AgentSessionPolicies.AgentPin pin, + WorkspaceCsiRegistration registration, boolean planMode, String modelId) { + ContextBinding workspace = resolved == null ? null + : resolved.binding(); + long now = clock.millis(); + String sessionId = UUID.randomUUID().toString(); + String runtimeRequestKey = + registration == null + ? null + : CsiFilesRetirementProfile.request( + workspace, registration.mountRoot(), sessionId) + .requestKey(); + String turnId = input.isEmpty() ? null : AgentStoreRows.publicId("turn"); + String promptId = input.isEmpty() ? null + : UUID.randomUUID().toString(); + byte[] actorKey = actorId == null ? null + : com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId); + jdbc.update("INSERT INTO ma_session (tenant_id," + + " session_id, agent_id, agent_revision," + + " agent_definition_digest, title," + + " status, gmt_create, gmt_modified, workspace_id," + + " workspace_generation, workspace_storage_id," + + " cwd_relative, context_config_ref," + + " context_revision, workspace_config_ref," + + " workspace_policy_ref, tool_profile," + + " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode," + + " plan_enabled, selected_approval_mode, approval_revision, model_id, model_revision) VALUES" + + " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?," + + " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId, sessionId, agentId, pin.revision(), pin.digest(), + title, now, now, + workspace == null ? null : workspace.getWorkspaceId(), + workspace == null ? null : workspace.getWorkspaceGeneration(), + workspace == null ? null : workspace.getStorageId(), + workspace == null ? null : workspace.getCwdRelative(), + workspace == null ? null : workspace.getContextConfigRef(), + workspace == null ? null : workspace.getContextRevision(), + resolved == null ? null : resolved.configRef(), + resolved == null ? null : resolved.policyRef(), + registration != null + ? CsiFilesRetirementProfile.PROFILE + : workspace == null ? null : pin.toolProfile(), + actorKey, actorKey, + runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo", + planMode, planMode ? pin.approvalMode() : null, planMode ? 1 : 0, modelId, modelId == null ? 0 : 1); + jdbc.update("INSERT INTO ma_event_stream" + + " (tenant_id, session_id, stream_kind, stream_id," + + " covered_sequence, gmt_modified) VALUES" + + " (?, ?, 'SESSION', '', 0, ?)", + tenantId, sessionId, now); + if (turnId != null) { + sessionCommands.insertTurn(tenantId, sessionId, turnId, promptId, input, + payloadDigest, now); + } + if (resolved == null) { + sessionCommands.insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, now); + } else { + ManagedReceipts.insertCreation(jdbc, tenantId, actorKey, + idempotencyKey, requestDigest, sessionId, turnId, now); + } + eventJournal.appendEvent(tenantId, sessionId, null, "session.created", + Map.of("sessionId", sessionId), false, null, now); + if (turnId != null) { + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.accepted", + AgentEventJournal.acceptedData(turnId, input), false, null, now); + } + return new Admission(sessionId, turnId, false, true); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionCommands.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionCommands.java new file mode 100644 index 00000000000..b6c2c9b9c77 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionCommands.java @@ -0,0 +1,1083 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.Admission; +import com.alibaba.qwen.code.managedagent.model.StoreModels.CommandRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.RenameDelivery; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionMutationCommand; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionMutationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +final class AgentSessionCommands { + private final AgentEventJournal eventJournal; + private final AgentSessionPolicies sessionPolicies; + private final AgentStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles; + private final JdbcTemplate jdbc; + private final Clock clock; + private final ManagedWorkspaceRegistry workspaces; + private final boolean workspaceFilesEnabled; + private final boolean workspaceShellEnabled; + + AgentSessionCommands(AgentEventJournal eventJournal, + AgentSessionPolicies sessionPolicies, + AgentStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles, + JdbcTemplate jdbc, + Clock clock, + ManagedWorkspaceRegistry workspaces, + boolean workspaceFilesEnabled, + boolean workspaceShellEnabled) { + this.eventJournal = eventJournal; + this.sessionPolicies = sessionPolicies; + this.storeRows = storeRows; + this.worktreeFiles = worktreeFiles; + this.jdbc = jdbc; + this.clock = clock; + this.workspaces = workspaces; + this.workspaceFilesEnabled = workspaceFilesEnabled; + this.workspaceShellEnabled = workspaceShellEnabled; + } + + // A bound Session may be renamed under the Workspace-files opt-in + // (the service checks the caller's Workspace role and the Session's + // creator-keyed execution facts); unarchive stays gated. + boolean boundRenameAllowed(SessionMutationKind kind) { + return kind == SessionMutationKind.RENAME && workspaceFilesEnabled; + } + + Admission insertTurnCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + List> input, String payloadDigest) { + return insertTurnCommand(tenantId, operation, idempotencyKey, + requestDigest, sessionId, input, payloadDigest, null); + } + + // The actor-carrying variant records the caller on the command + // receipt, so the receipt's replay answers the actor who recorded it — + // an OPERATOR's own retry of his own key — and not only the creator. + Admission insertTurnCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + List> input, String payloadDigest, + String actorId) { + // A per-turn admission: a long holder of the tenant's placement row + // answers tenant_guard_contended within the hot-path bound instead + // of parking the submit past the caller's budget (#13800). + ManagedSessionStore.lockBounded(() -> WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId, + ManagedSessionStore.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + sessionPolicies.requirePublicChildImmutable(session); + // A bound Session's later Turn needs the same deployment opt-in as + // its initial one; the service admits any caller holding OPERATOR + // on the bound Workspace while the Session's creator-keyed + // execution facts hold. + if (session.workspace() != null && !workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + // The replay probe must not lock: a FOR UPDATE read that misses + // next-key-locks a gap of the tenant-shared command index, and + // concurrent admissions holding such gap locks deadlock when they + // all try to insert into the same gap. The PRIMARY KEY deduplicates + // racing inserts instead, and the service replays on + // DuplicateKeyException. + Optional existing = findCommand(tenantId, operation, + idempotencyKey); + if (existing.isPresent()) { + return replayCommand(tenantId, operation, idempotencyKey, + requestDigest); + } + if (WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + if (session.workspace() != null) { + if (sessionPolicies.isSshWorkspace(session.workspace())) { + AgentSessionPolicies.requireSshToolProfile(session.toolProfile()); + if (sessionPolicies.hasMcpServers(session)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + } + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + } + if (!"ACTIVE".equals(session.status())) { + throw new ManagedAgentException(409, "session_not_active", + "The Session does not accept new Turns."); + } + SessionWorktreeStore.requireReady(worktreeFiles, jdbc, tenantId, sessionId); + ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); + if (sessionPolicies.hasActiveTurn(tenantId, sessionId)) { + throw new ManagedAgentException(409, "turn_active", + "The Session already has an active Turn."); + } + record QueuedInput(String messageId, String state, String turnId, String actorId, String inputJson) { } + var queuedInputs = jdbc.query("SELECT message_id, state, turn_id, actor_id, input_json FROM ma_user_message" + + " WHERE tenant_id = ? AND session_id = ? AND state IN ('queued', 'claimed', 'recovery_blocked')" + + " ORDER BY sequence_number LIMIT 1", + (row, index) -> new QueuedInput(row.getString(1), row.getString(2), row.getString(3), row.getString(4), row.getString(5)), tenantId, sessionId); + if (!queuedInputs.isEmpty()) { + var first = queuedInputs.getFirst(); + if (!"claimed".equals(first.state()) || first.turnId() != null + || !("user-queue:" + first.messageId()).equals(idempotencyKey) || !Objects.equals(actorId, first.actorId()) + || !storeRows.readInput(first.inputJson()).equals(input)) { + throw new ManagedAgentException(409, "user_message_queue_pending", "Send new input through the Session user message queue."); + } + } + // The W2 handshake: an open execution operation on a bound Session + // is the busy barrier the settlement re-verifies at commit; a + // later Turn must not slide in underneath it. Narrower than + // requireNoOpenOperation on purpose: a stuck display mutation + // (PENDING rename command) or an in-flight ACTION_RESPONSE would + // otherwise wedge every Turn admission without any recovery scan + // reading those tables — while the operation-ledger barriers that + // intentionally cover them stay untouched. + if (session.workspace() != null + && sessionPolicies.hasOpenExecutionOperation(tenantId, sessionId)) { + throw new ManagedAgentException(409, + "session_context_busy", + "The Session has an open operation."); + } + long now = clock.millis(); + String turnId = AgentStoreRows.publicId("turn"); + insertTurn(tenantId, sessionId, turnId, + UUID.randomUUID().toString(), input, payloadDigest, now); + byte[] actorKey = commandActorKey(tenantId, actorId); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, "COMPLETED", null, actorKey, now); + String operationId = insertTurnCommandOperation(tenantId, sessionId, + turnId, OperationKind.SUBMIT_INPUT, operation, idempotencyKey, + requestDigest, actorKey, session.status(), now); + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.accepted", + AgentEventJournal.acceptedData(turnId, input), false, null, now); + return new Admission(sessionId, turnId, false, true, operationId); + } + + Admission insertCancelCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId) { + return insertCancelCommand(tenantId, operation, idempotencyKey, + requestDigest, sessionId, turnId, null); + } + + // The actor-carrying twin of insertTurnCommand's overload. + Admission insertCancelCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String actorId) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() != null && !workspaceFilesEnabled) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + TurnRecord turn = storeRows.requireTurn(tenantId, sessionId, turnId); + long now = clock.millis(); + byte[] actorKey = commandActorKey(tenantId, actorId); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, "COMPLETED", null, actorKey, now); + String operationId = insertTurnCommandOperation(tenantId, sessionId, + turnId, OperationKind.CANCEL, operation, idempotencyKey, + requestDigest, actorKey, session.status(), now); + boolean commandEffect = AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status()) + && !"CANCELLING".equals(turn.status()); + if (commandEffect) { + // D7-2: a user cancel is due at once, never behind the Runtime + // recovery park's recheck window. + int updated = jdbc.update("UPDATE ma_turn SET status =" + + " 'CANCELLING', retry_after = NULL, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING')", + now, tenantId, sessionId, turnId); + commandEffect = updated == 1; + if (commandEffect) { + eventJournal.appendEvent(tenantId, sessionId, turnId, + "turn.cancel.requested", Map.of("turnId", turnId), + false, null, now); + } + } + if (!commandEffect) { + // The Turn read before insertCommand can be a stale snapshot + // while another transaction already made it terminal. Re-read + // it FOR UPDATE and settle this new cancel from the current + // row; a CANCELLING Turn still carries it with the earlier one. + TurnRecord current = storeRows.requireTurnForUpdate(tenantId, sessionId, + turnId); + boolean cancelled = "CANCELLED".equals(current.status()); + if (cancelled || !AgentStoreRows.ACTIVE_TURN_STATES.contains(current.status())) { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.CANCEL, + cancelled ? "COMPLETED" : "FAILED", + cancelled && current.harnessEventEpoch() != null + ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", + cancelled ? null : "turn_already_settled", now); + } + } + return new Admission(sessionId, turnId, false, commandEffect, + operationId); + } + + SessionMutationCommand beginSessionRename(String tenantId, String key, + String digest, String sessionId, String title) { + return beginSessionRename(tenantId, key, digest, sessionId, title, + null); + } + + SessionMutationCommand beginSessionRename(String tenantId, String key, + String digest, String sessionId, String title, String actorId) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + // Tenant and Session locks serialize admission without gap-locking missing index entries. + Optional previous = findCommand(tenantId, "RENAME_SESSION", key, false); + if (previous.isPresent() + && sessionId.equals(previous.get().sessionId())) { + requireRenameReplayOwner(session, previous.get(), actorId); + } + var retained = + jdbc.queryForList( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_kind = 'RENAME' AND target_key" + + " = ? AND idempotency_digest = ? AND tenant_id = ? AND session_id = ?" + + " ORDER BY target_revision DESC LIMIT 1 FOR UPDATE", + AgentStoreRows.renameTarget(tenantId, sessionId), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + key), + tenantId, + sessionId); + if (!retained.isEmpty()) { + Map row = retained.getFirst(); + if (!key.equals(row.get("idempotency_key")) || !digest.equals(row.get("request_digest")) + || !title.equals(storeRows.readMap((String) row.get("request_json")).get("title"))) { + throw new ManagedAgentException( + 409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + if ("SUPERSEDED".equals(row.get("state"))) { + throw new ManagedAgentException( + 409, + "session_mutation_superseded", + "A later Session title attempt has been admitted."); + } + // A parked delivery awaits the reconciler and is never re-sent. + if ("RECOVERY_BLOCKED".equals(row.get("state"))) { + throw new ManagedAgentException( + 409, + "operation_outcome_unknown", + "The original title delivery is awaiting reconciliation."); + } + } + // A delivery the reconciler proved never landed is FAILED: the key + // retries under a new revision, as any failed title key does. + if (!retained.isEmpty() && !"FAILED".equals(retained.getFirst().get("state"))) { + Map row = retained.getFirst(); + return new SessionMutationCommand( + sessionId, + "COMPLETED".equals(row.get("state")) ? "COMPLETED" : "PENDING", + true, + ((Number) row.get("target_revision")).longValue()); + } + SessionMutationCommand command = + beginSessionMutation( + tenantId, + "RENAME_SESSION", + key, + digest, + sessionId, + SessionMutationKind.RENAME, + actorId); + if ("COMPLETED".equals(command.status())) { + return command; + } + Long revision = + jdbc.queryForObject( + "SELECT title_revision FROM ma_session WHERE tenant_id = ?" + + " AND session_id = ? AND session_kind = 'PUBLIC'", + Long.class, + tenantId, + sessionId); + long next = Math.addExact(revision, 1); + long now = clock.millis(); + jdbc.update( + "UPDATE ma_session SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", title_revision = ? WHERE tenant_id = ? AND session_id = ?", + next, + tenantId, + sessionId); + jdbc.update( + "UPDATE ma_operation SET state = 'SUPERSEDED', delivery_state = 'COMPLETED', phase" + + " = 'SUPERSEDED', gmt_modified = ? WHERE operation_kind = 'RENAME' AND" + + " target_key = ? AND target_revision < ? AND state IN ('PENDING', 'RUNNING'," + + " 'RECOVERY_BLOCKED')", + now, + AgentStoreRows.renameTarget(tenantId, sessionId), + next); + jdbc.update( + "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," + + " operation_id, operation_kind, tenant_id, session_id, target_kind," + + " target_key, target_revision, idempotency_key, idempotency_digest," + + " request_digest, request_json, state, phase, delivery_state, available_at," + + " gmt_create, gmt_modified) VALUES (?, ?, ?, ?, 'RENAME', ?, ?, 'SESSION', ?," + + " ?, ?, ?, ?, ?, 'PENDING', 'ADMITTED', 'PENDING', ?, ?, ?)", + renameKey(tenantId, sessionId, next), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + "rename:" + next, + tenantId, + sessionId, + AgentStoreRows.renameTarget(tenantId, sessionId), + next, + key, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(key), + digest, + storeRows.writeJson(Map.of("title", title)), + now, + now, + now); + return new SessionMutationCommand(sessionId, "PENDING", command.replayed(), next); + } + + // The discovery arm of the replay's recorder/creator rule: once this + // method finds an existing RENAME command receipt under the Session + // lock, the request may only reuse it when the caller is its recorder + // (the receipt's actor) or the Session's creator. The alternatives — + // claiming a still-claimable delivery for the recorder, or failing a + // digest comparison against a different title — would hand the + // recorder's pending work to the racer or name the live key before + // authorization. + // A legacy Session has no per-actor mutation gate at all; its key's + // outcome stays answerable through the same contract as the pre-read + // probe. A tombstoned Session answers the reads' 404 instead of the + // live-Session refusal. + void requireRenameReplayOwner(SessionRecord session, + CommandRecord previous, String actorId) { + if (!previous.admitsReplayBy(actorId) && session.workspace() != null + && !workspaces.createdSession(session.tenantId(), actorId, + session.sessionId())) { + if ("DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", + "The Session was not found."); + } + throw new ManagedAgentException(409, "workspace_unavailable", + "Hosted Workspace execution is not available."); + } + } + + SessionRecord completeSessionRename(String tenantId, String key, String sessionId, + String title, String bootId, long revision) { + return settleRename(new RenameDelivery(tenantId, sessionId, key, title, revision, 0), null, bootId); + } + + SessionRecord completeSessionRename(RenameDelivery delivery, String owner, String bootId) { + return settleRename(delivery, owner, bootId); + } + + SessionRecord settleRename(RenameDelivery delivery, String owner, String bootId) { + String tenantId = delivery.tenantId(); + String sessionId = delivery.sessionId(); + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + var rows = + jdbc.queryForList( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_key = ? AND operation_kind =" + + " 'RENAME' AND tenant_id = ? AND session_id = ? FOR UPDATE", + renameKey(tenantId, sessionId, delivery.revision()), + tenantId, + sessionId); + Long currentRevision = + jdbc.queryForObject( + "SELECT title_revision FROM ma_session WHERE tenant_id = ?" + + " AND session_id = ?", + Long.class, + tenantId, + sessionId); + if (rows.size() != 1 + || currentRevision != delivery.revision() + || !delivery.idempotencyKey().equals(rows.getFirst().get("idempotency_key")) + || !delivery.title() + .equals( + storeRows.readMap((String) rows.getFirst().get("request_json")) + .get("title"))) { + throw new ManagedAgentException( + 409, + "session_mutation_superseded", + "A later Session title attempt has been admitted."); + } + Map operation = rows.getFirst(); + if (owner == null + ? !"PENDING".equals(operation.get("state")) + : !owner.equals(operation.get("claim_owner")) + || delivery.claimGeneration() + != ((Number) operation.get("claim_generation")).longValue() + || !List.of("RUNNING", "RECOVERY_BLOCKED") + .contains(operation.get("state"))) { + throw new ManagedAgentException( + 409, + "operation_claim_lost", + "The title delivery claim was lost."); + } + jdbc.update( + "UPDATE ma_request_receipt SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", state = 'PENDING' WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND state = 'FAILED'", + ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", delivery.idempotencyKey())); + SessionRecord session = + completeSessionMutation( + tenantId, + "RENAME_SESSION", + delivery.idempotencyKey(), + sessionId, + SessionMutationKind.RENAME, + delivery.title(), + bootId); + // A parked delivery answered late by its own claim was confirmed by + // the Harness after all. + boolean parked = !"RUNNING".equals(operation.get("state")) + && !"PENDING".equals(operation.get("state")); + jdbc.update( + "UPDATE ma_operation SET state = 'COMPLETED', phase = 'COMPLETED', delivery_state =" + + " 'COMPLETED', claim_owner = NULL, claim_until = NULL, gmt_modified = ?," + + " completed_at = ?" + + (parked ? ", admission_stage = 'HARNESS_CONFIRMED', failure_code = NULL" : "") + + " WHERE operation_key = ?", + clock.millis(), + clock.millis(), + renameKey(tenantId, sessionId, delivery.revision())); + return session; + } + + void abandonSessionRename(String tenantId, String key, String sessionId, long revision, String owner) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_operation WHERE operation_key = ? AND operation_kind = 'RENAME' AND state = 'RUNNING' AND claim_owner = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + Integer.class, + renameKey(tenantId, sessionId, revision), + owner) + == 1) { + abandonSessionMutation(tenantId, "RENAME_SESSION", key, sessionId); + } + } + + List deliverableRenames(long now) { + // A submitted delivery whose claim expired parks for the reconciler. + String unanswered = "(state = 'RUNNING' AND phase = 'SUBMITTED' AND claim_until <= " + + ManagedSchemaTime.SQL_NOW + ")"; + for (Map row : jdbc.queryForList( + "SELECT operation_key, tenant_id, session_id, idempotency_key FROM ma_operation WHERE operation_kind = 'RENAME' AND " + unanswered + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) LIMIT 100")) { + parkRename(row, unanswered); + } + jdbc.update( + "UPDATE ma_operation SET state = 'PENDING', delivery_state = 'PENDING', phase = 'ADMITTED', available_at = ?, claim_owner = NULL, claim_until = NULL, gmt_modified = ? WHERE operation_kind = 'RENAME' AND state = 'RUNNING' AND phase = 'PREPARING' AND claim_until <= " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) LIMIT 100", + now, + now); + return jdbc.query( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_kind = 'RENAME' AND delivery_state = 'PENDING' AND state = 'PENDING' AND available_at <= ? AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY available_at LIMIT 100", + this::renameDelivery, + now); + } + + Optional claimRename(RenameDelivery delivery, String owner, Duration lease) { + WorkspaceMigrationAdmission.lockTenant(jdbc, delivery.tenantId()); + storeRows.requireLegacySessionForUpdate(delivery.tenantId(), delivery.sessionId()); + long now = clock.millis(); + int claimed = + jdbc.update( + "UPDATE ma_operation SET state = 'RUNNING', delivery_state = 'LEASED', phase = 'PREPARING', claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), claim_generation = claim_generation + 1, attempt_count = attempt_count + 1, gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND ((state = 'PENDING' AND available_at <= ?) OR (state = 'RUNNING' AND phase = 'PREPARING' AND claim_until <= " + ManagedSchemaTime.SQL_NOW + "))", + owner, + lease.toMillis(), + now, + renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()), + now); + if (claimed == 0) { + return Optional.empty(); + } + return jdbc + .query( + "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_key = ? AND operation_kind =" + + " 'RENAME'", + this::renameDelivery, + renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision())) + .stream() + .findFirst(); + } + + void markRenameSubmitted(RenameDelivery delivery, String owner) { + int changed = + jdbc.update( + "UPDATE ma_operation SET phase = 'SUBMITTED', gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND state = 'RUNNING' AND phase = 'PREPARING' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", + clock.millis(), + renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()), + owner, + delivery.claimGeneration()); + if (changed != 1) { + throw new ManagedAgentException( + 409, + "operation_claim_lost", + "The title delivery claim was lost."); + } + } + + void retryRename(RenameDelivery delivery, String owner, long availableAt) { + WorkspaceMigrationAdmission.lockTenant(jdbc, delivery.tenantId()); + storeRows.requireLegacyMutation(delivery.tenantId(), delivery.sessionId()); + String key = renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()); + parkRename(Map.of("operation_key", key, "tenant_id", delivery.tenantId(), + "session_id", delivery.sessionId(), "idempotency_key", delivery.idempotencyKey()), + "state = 'RUNNING' AND phase = 'SUBMITTED' AND claim_owner = ? AND claim_generation = ?", + owner, delivery.claimGeneration()); + jdbc.update( + "UPDATE ma_operation SET state = 'PENDING', delivery_state = 'PENDING', phase =" + + " 'ADMITTED', available_at = ?, claim_owner = NULL, claim_until = NULL," + + " gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND" + + " state = 'RUNNING' AND phase = 'PREPARING' AND claim_owner = ? AND" + + " claim_generation = ?", + availableAt, + clock.millis(), + key, + owner, + delivery.claimGeneration()); + } + + // Parks one delivery whose submission went unanswered, if it still + // matches the guard. Its receipt retires as an abandoned delivery's + // does, so the parked title holds neither later titles nor the Session + // lifecycle hostage; the claim's own late reply and the reconciler both + // complete a retired receipt. + void parkRename(Map row, String guard, Object... guardArguments) { + List arguments = new ArrayList<>(List.of(storeRows.lifecycleDatabaseTime(), clock.millis(), + row.get("operation_key"))); + arguments.addAll(List.of(guardArguments)); + if (jdbc.update("UPDATE ma_operation SET " + PARK_RENAME + ", available_at = ?, gmt_modified = ?" + + " WHERE operation_key = ? AND operation_kind = 'RENAME' AND " + guard, + arguments.toArray()) != 1) { + return; + } + String tenantId = (String) row.get("tenant_id"); + jdbc.update( + "UPDATE ma_request_receipt SET state = 'FAILED', gmt_modified = ? WHERE receipt_key = ?" + + " AND namespace = 'SESSION_COMMAND' AND session_id = ? AND state = 'PENDING'", + clock.millis(), + ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", (String) row.get("idempotency_key")), + row.get("session_id")); + } + + RenameDelivery renameDelivery(java.sql.ResultSet row, int index) throws java.sql.SQLException { + return new RenameDelivery( + row.getString("tenant_id"), + row.getString("session_id"), + row.getString("idempotency_key"), + (String) storeRows.readMap(row.getString("request_json")).get("title"), + row.getLong("target_revision"), + row.getInt("attempt_count"), + row.getLong("claim_generation")); + } + + static String renameKey(String tenant, String session, long revision) { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract + .OperationKind.RENAME, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), + AgentStoreRows.renameTarget(tenant, session), + "rename:" + revision); + } + + SessionMutationCommand beginSessionMutation(String tenantId, + String operation, String idempotencyKey, String requestDigest, + String sessionId, SessionMutationKind kind) { + return beginSessionMutation(tenantId, operation, idempotencyKey, + requestDigest, sessionId, kind, null); + } + + // The actor-carrying twin of insertTurnCommand's overload. + SessionMutationCommand beginSessionMutation(String tenantId, + String operation, String idempotencyKey, String requestDigest, + String sessionId, SessionMutationKind kind, String actorId) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() != null && !boundRenameAllowed(kind)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + Optional existing = findCommand(tenantId, operation, + idempotencyKey, kind != SessionMutationKind.RENAME); + if (existing.isPresent()) { + CommandRecord command = existing.get(); + if (!command.requestDigest().equals(requestDigest) + || !command.sessionId().equals(sessionId)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + if ("FAILED".equals(command.status())) { + if (session.workspace() != null) { + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + } + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + validateMutationStatus(session, kind); + jdbc.update( + "UPDATE ma_request_receipt SET state = 'PENDING', mutation_attempt_sequence" + + " = ?, gmt_modified = ? WHERE receipt_key = ? AND namespace =" + + " 'SESSION_COMMAND'", + session.lastSequence(), + clock.millis(), + ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); + return new SessionMutationCommand(sessionId, "PENDING", true); + } + return new SessionMutationCommand(sessionId, command.status(), + true); + } + if (session.workspace() != null) { + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + } + sessionPolicies.requireNoOpenOperation(tenantId, sessionId); + validateMutationStatus(session, kind); + long now = clock.millis(); + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, null, "PENDING", session.status(), + commandActorKey(tenantId, actorId), now); + jdbc.update("UPDATE ma_request_receipt SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + + ", mutation_attempt_sequence" + + " = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'", + session.lastSequence(), ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); + // Older retired commands may have left their requested event behind. + String requestedSource = mutationSource(operation, idempotencyKey, + "requested"); + if (!eventJournal.hasSourceEvent(tenantId, sessionId, requestedSource)) { + eventJournal.appendEvent(tenantId, sessionId, null, + mutationEvent(kind, "requested"), + Map.of("sessionId", sessionId), false, + requestedSource, now); + } + return new SessionMutationCommand(sessionId, "PENDING", false); + } + + SessionRecord completeSessionMutation(String tenantId, + String operation, String idempotencyKey, String sessionId, + SessionMutationKind kind, String title, String harnessBootId) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() != null && !boundRenameAllowed(kind)) { + throw AgentSessionPolicies.workspaceExecutionUnavailable(); + } + CommandRecord command = findCommand(tenantId, operation, + idempotencyKey, true).orElseThrow(() -> + new IllegalStateException( + "Session mutation command is unavailable")); + if (!command.sessionId().equals(sessionId)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key belongs to another Session."); + } + if ("COMPLETED".equals(command.status())) { + return session; + } + if (!"PENDING".equals(command.status()) + && !"FAILED".equals(command.status())) { + throw new IllegalStateException( + "Session mutation command has an unknown status"); + } + // A retired receipt still completes for a sibling that entered the + // Harness before the retirement, but never over a later mutation: + // while it was FAILED another key could begin and complete, and + // completing this one now would revert that newer outcome. + if ("FAILED".equals(command.status()) && supersededByLaterMutation( + tenantId, sessionId, operation, idempotencyKey, kind)) { + throw new ManagedAgentException(409, + "session_mutation_superseded", + "A later change to the Session completed after this" + + " request was retired."); + } + if (session.workspace() != null) { + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + } + validateMutationStatus(session, kind); + long now = clock.millis(); + Map data = Map.of("sessionId", sessionId); + switch (kind) { + case RENAME -> { + jdbc.update("UPDATE ma_session SET title = ?," + + " harness_boot_id =" + + " COALESCE(harness_boot_id, ?)," + + " gmt_modified = ?, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ?", + title, harnessBootId, now, tenantId, sessionId); + data = Map.of("sessionId", sessionId, + "metadata", Map.of("title", title)); + } + // An archived Session was closed first, so it stays closed. + case UNARCHIVE -> jdbc.update("UPDATE ma_session SET" + + " status = 'CLOSED', gmt_modified = ?, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + now, tenantId, sessionId); + } + jdbc.update( + "UPDATE ma_request_receipt SET state = 'COMPLETED', gmt_modified = ? WHERE" + + " receipt_key = ? AND namespace = 'SESSION_COMMAND'", + now, + ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); + eventJournal.appendEvent(tenantId, sessionId, null, + mutationEvent(kind, "completed"), data, false, + mutationSource(operation, idempotencyKey, + "completed"), now); + return storeRows.requireSessionForUpdate(tenantId, sessionId); + } + + void abandonSessionMutation(String tenantId, String operation, + String idempotencyKey, String sessionId) { + storeRows.requireLegacyMutation(tenantId, sessionId); + // Keep the digest and receipt for replay and concurrent completion. + // A completed outcome must never be overwritten by a failing sibling. + jdbc.update("UPDATE ma_request_receipt SET state = 'FAILED'," + + " gmt_modified = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'" + + " AND session_id = ? AND state = 'PENDING'", + clock.millis(), ManagedReceipts.commandKey(tenantId, operation, idempotencyKey), sessionId); + } + + // A Turn CANCEL parked cancel_unconfirmed is in ACCEPTANCE_UNKNOWN_KINDS + // (decision 11): the reconciler settles it from CancelEvidence, which + // reads the original prompt's retained durable terminal event, and the + // row is never claimed or delivered again. + /** Turn input whose submission no Harness answered (D7-2). */ + static final String SUBMIT_INPUT_UNCONFIRMED = "submit_input_unconfirmed"; + + /** A Turn cancel whose effect on an unobserved execution no Harness + * answered (D7-2). */ + static final String CANCEL_UNCONFIRMED = "cancel_unconfirmed"; + + /** A Session title whose submitted delivery no Harness answered (D7-2). */ + static final String SESSION_TITLE_UNCONFIRMED = "session_title_unconfirmed"; + + // A RENAME parks with the same columns either way: acceptance unknown, + // never re-sent, settled only by the reconciler or by the same claim's + // own late reply. Its claim fields stay, so that late reply still settles. + static final String PARK_RENAME = "state = 'RECOVERY_BLOCKED'," + + " phase = 'RECOVERY_BLOCKED', admission_stage = 'JAVA_DURABLE'," + + " delivery_state = 'BLOCKED', failure_code = '" + + SESSION_TITLE_UNCONFIRMED + "'"; + + String insertTurnCommandOperation(String tenantId, + String sessionId, String turnId, OperationKind kind, + String command, String idempotencyKey, String requestDigest, + byte[] actorKey, String sessionStatus, long now) { + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + String target = turnOperationTarget(turnId); + String operationId = AgentStoreRows.publicId("op"); + String operationKey = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.OperationKind.valueOf(kind.name()), + scope, target, operationId); + jdbc.update( + "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," + + " target_kind, target_key, target_id, phase, request_json," + + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," + + " actor_key, idempotency_key, request_digest, state, admission_stage," + + " delivery_state, session_status_before, available_at, gmt_create," + + " gmt_modified) VALUES (?, ?, ?, 'TURN', ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?," + + " ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?)", + operationKey, + scope, + scope, + target, + turnId, + storeRows.writeJson(Map.of("schemaVersion", 1, "kind", kind.name(), + "sessionId", sessionId, "turnId", turnId)), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + idempotencyKey), + tenantId, + sessionId, + operationId, + kind.name(), + actorKey, + idempotencyKey, + requestDigest, + sessionStatus, + now, + now, + now); + ManagedReceipts.linkCommandOperation(jdbc, tenantId, command, + idempotencyKey, operationKey); + return operationId; + } + + static String turnOperationTarget(String turnId) { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(turnId); + } + + /** + * Settles the open input and cancel operations of a Turn from the Turn's + * own committed facts, in the transaction that commits them. Input is + * accepted once the Turn holds a Harness event epoch, the fact its + * dispatcher records when a Harness admits the Turn; until then a + * cancelled or failed Turn ends the input undelivered. A cancel + * completes when the Turn is cancelled — by the Harness, or by Java + * before any Harness admitted it — and fails turn_already_settled when + * the Turn ended otherwise. One whose admitted Turn failed with + * managed_runtime_recovery_blocked parks recovery_blocked with + * cancel_unconfirmed: the cancel's effect on the unobserved execution + * is itself unknown. A Turn that has not ended leaves its cancels + * pending, so cancels of one Turn coalesce. + */ + void settleTurnCommands(String tenantId, String sessionId, + String turnId, long now) { + // Current reads only: every caller holds the Session row, but its + // snapshot may predate that lock and miss a cancel committed since. + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + boolean admitted = turn.harnessEventEpoch() != null; + if (admitted) { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.SUBMIT_INPUT, "COMPLETED", "HARNESS_CONFIRMED", + null, now); + } else if ("CANCELLED".equals(turn.status())) { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.SUBMIT_INPUT, "CANCELLED", "JAVA_DURABLE", + null, now); + } else if ("FAILED".equals(turn.status()) + && turn.submissionAttempted()) { + // A null epoch does not prove non-admission (R10-4): the submit + // may have been admitted and its reply lost. The input parks + // until the Session journal proves which (D7-2). + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.SUBMIT_INPUT, "RECOVERY_BLOCKED", + "JAVA_DURABLE", SUBMIT_INPUT_UNCONFIRMED, now); + } else if ("FAILED".equals(turn.status())) { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.SUBMIT_INPUT, "FAILED", "JAVA_DURABLE", + turn.errorCode(), now); + } + if ("CANCELLED".equals(turn.status())) { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.CANCEL, "COMPLETED", + admitted ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", null, now); + } else if (!AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status())) { + if ((admitted || turn.submissionAttempted()) + && "managed_runtime_recovery_blocked".equals( + turn.errorCode())) { + // Under the Session row lock, every cancel still pending + // here predates the failure; a cancel admitted afterwards + // settles itself in insertCancelCommand. A submission mark + // is enough: a null epoch does not prove non-admission + // (R10-4). + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.CANCEL, "RECOVERY_BLOCKED", + "JAVA_DURABLE", CANCEL_UNCONFIRMED, now); + } else { + settleTurnCommand(tenantId, sessionId, turnId, + OperationKind.CANCEL, "FAILED", "JAVA_DURABLE", + "turn_already_settled", now); + } + } + } + + // Only a completed operation is delivered and carries a receipt; a + // failed or cancelled one stays java_durable and blocked, so it is never + // delivered again. A parked submit_input or Turn cancel is due for the + // reconciler at once (decision 11). + void settleTurnCommand(String tenantId, String sessionId, + String turnId, OperationKind kind, String state, + String admissionStage, String failureCode, long now) { + boolean completed = "COMPLETED".equals(state); + boolean parked = "RECOVERY_BLOCKED".equals(state); + // The identity index confines this locking scan to the Turn's own + // operations. Left to the optimizer, a long settled history steers it + // to a (kind, state) index whose range holds every Session's pending + // rows, and two Sessions' settles then deadlock. H2 skips the hint. + for (String operationKey : jdbc.queryForList("SELECT operation_key" + + " FROM ma_operation /*! FORCE INDEX (uq_ma_operation_identity) */" + + " WHERE scope_key = ? AND" + + " operation_kind = ? AND target_key = ? AND state =" + + " 'PENDING' FOR UPDATE", + String.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + kind.name(), turnOperationTarget(turnId))) { + jdbc.update("UPDATE ma_operation SET state = ?, phase = ?," + + " admission_stage = ?, delivery_state = ?," + + " receipt_id = ?, failure_code = ?," + + " available_at = ?, gmt_modified = ?," + + " completed_at = ? WHERE" + + " operation_key = ? AND state = 'PENDING'", + state, state, admissionStage, + completed ? "CONFIRMED" : "BLOCKED", + completed ? AgentStoreRows.publicId("rcpt") : null, failureCode, now, + now, parked ? null : now, operationKey); + } + } + + Admission replayCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest) { + ManagedReceipts.Receipt command = ManagedReceipts.command(jdbc, + tenantId, operation, idempotencyKey, false); + if (command == null) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key is already in use."); + } + if (!command.requestDigest().equals(requestDigest)) { + throw new ManagedAgentException(409, + "idempotency_conflict", + "The idempotency key was reused with different content."); + } + String operationId = command.resultOperationKey() == null ? null + : jdbc.queryForObject("SELECT operation_id FROM ma_operation" + + " WHERE operation_key = ?", String.class, + command.resultOperationKey()); + return new Admission(command.sessionId(), command.turnId(), true, + false, operationId); + } + + Optional findCommand(String tenantId, + String operation, String idempotencyKey) { + return findCommand(tenantId, operation, idempotencyKey, false); + } + + Optional findCommand(String tenantId, + String operation, String idempotencyKey, boolean forUpdate) { + ManagedReceipts.Receipt row = ManagedReceipts.command(jdbc, tenantId, operation, idempotencyKey, forUpdate); + if (row == null) { + return Optional.empty(); + } + return Optional.of( + new CommandRecord( + tenantId, + operation, + idempotencyKey, + row.requestDigest(), row.sessionId(), row.turnId(), row.state(), + row.sessionStatusBefore(), row.actorKey(), row.createdAt(), row.updatedAt())); + } + + void insertTurn(String tenantId, String sessionId, + String turnId, String promptId, List> input, + String payloadDigest, long now) { + jdbc.update("INSERT INTO ma_turn (tenant_id, session_id," + + " turn_id, prompt_id, input_json, payload_digest," + + " status, gmt_create, gmt_modified, model_id, model_revision, approval_mode, approval_revision, plan_enabled, permission_rules_json) SELECT" + + " ?, ?, ?, ?, ?, ?, 'ACCEPTED', ?, ?, model_id," + + " CASE WHEN model_id IS NULL THEN NULL ELSE model_revision END," + + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" + + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" + + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," + + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" + + " THEN COALESCE(selected_approval_mode, approval_mode) ELSE NULL END," + + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" + + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" + + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," + + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" + + " THEN approval_revision ELSE NULL END," + + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" + + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" + + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," + + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" + + " THEN plan_enabled ELSE NULL END," + + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" + + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" + + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," + + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" + + " THEN permission_rules_json ELSE NULL END" + + " FROM ma_session WHERE tenant_id = ? AND session_id = ?", + tenantId, sessionId, turnId, promptId, storeRows.writeJson(input), + payloadDigest, now, now, "qwen-code".getBytes(StandardCharsets.UTF_8), + "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), + "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), + "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), + "qwen-code".getBytes(StandardCharsets.UTF_8), tenantId, sessionId); + } + + void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, long now) { + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, "COMPLETED", now); + } + + void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String status, long now) { + insertCommand(tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, status, null, null, now); + } + + void insertCommand(String tenantId, String operation, + String idempotencyKey, String requestDigest, String sessionId, + String turnId, String status, String sessionStatusBefore, + byte[] actorKey, long now) { + ManagedReceipts.insertCommand(jdbc, tenantId, operation, idempotencyKey, requestDigest, + sessionId, turnId, status, sessionStatusBefore, actorKey, now); + } + + // null for an actor id the registry key cannot encode: ManagedReceipts + // stamps the receipt UNATTRIBUTED, which answers to the creator gate + // alone. + static byte[] commandActorKey(String tenantId, String actorId) { + if (actorId == null) { + return null; + } + try { + return com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId); + } catch (IllegalArgumentException error) { + return null; + } + } + + // A mutation leaves the status unchanged until it completes. + static void validateMutationStatus(SessionRecord session, + SessionMutationKind kind) { + AgentSessionPolicies.requireSessionStatus(session.status(), + kind == SessionMutationKind.RENAME ? "ACTIVE" : "ARCHIVED"); + } + + static String mutationEvent(SessionMutationKind kind, + String phase) { + if ("completed".equals(phase)) { + return kind == SessionMutationKind.RENAME ? "session.updated" + : "session.unarchived"; + } + return "session." + + (kind == SessionMutationKind.RENAME ? "update" : "unarchive") + + "." + phase; + } + + // The Session lock orders each new attempt against committed events. + // Legacy receipts fall back to their original requested event; a + // re-drive refreshes the boundary without appending another event. + boolean supersededByLaterMutation(String tenantId, + String sessionId, String operation, String idempotencyKey, + SessionMutationKind kind) { + List attempts = + jdbc.queryForList( + "SELECT COALESCE( c.mutation_attempt_sequence, e.sequence_id) FROM" + + " ma_request_receipt c LEFT JOIN ma_public_event e ON e.tenant_id =" + + " c.tenant_id AND e.session_id = c.session_id AND e.stream_kind =" + + " 'SESSION' AND e.stream_id = X'' AND e.source_key = ? WHERE" + + " c.namespace = 'SESSION_COMMAND' AND c.receipt_key = ? AND" + + " c.session_id = ?", + Long.class, + mutationSource(operation, idempotencyKey, "requested"), + ManagedReceipts.commandKey(tenantId, operation, idempotencyKey), + sessionId); + if (attempts.isEmpty() || attempts.getFirst() == null) { + return false; + } + Integer later = + jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" + + " sequence_id > ? AND event_type = ? AND source_key LIKE 'control:%'", + Integer.class, + tenantId, + sessionId, + attempts.getFirst(), + mutationEvent(kind, "completed")); + return later != null && later > 0; + } + + static String mutationSource(String operation, + String idempotencyKey, String phase) { + return "control:" + operation + ":" + idempotencyKey + ":" + phase; + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java new file mode 100644 index 00000000000..1aee1881789 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionPolicies.java @@ -0,0 +1,784 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.catalog.AgentDefinitionExtensions; +import com.alibaba.qwen.code.managedagent.contracts.persistence.AgentDefinitionCompiler; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedConfigInstall; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellApprovalChangeResult; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelChangeResult; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelOption; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelSelection; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellSessionOptions; +import com.alibaba.qwen.code.managedagent.model.StoreModels; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +final class AgentSessionPolicies { + private final AgentEventJournal eventJournal; + private final AgentSessionQueries sessionQueries; + private final AgentStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles; + private final HarnessRules harnessRules; + private final ManagedExtensionRecordStore configRecords; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final Clock clock; + private final String approvalMode; + private final ManagedWorkspaceRegistry workspaces; + private final String agentRevision; + private final List definitionToolProfiles; + private final List definitionModels; + private final List modelCatalog; + private final ManagedAgentDefinitionStore definitions; + private final ManagedSessionSkills creationSkills; + private final boolean workspaceFilesEnabled; + private final boolean workspaceShellEnabled; + private final ResidentWorkspaces residentWorkspaces; + + AgentSessionPolicies(AgentEventJournal eventJournal, + AgentSessionQueries sessionQueries, + AgentStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles, + HarnessRules harnessRules, + ManagedExtensionRecordStore configRecords, + JdbcTemplate jdbc, + ObjectMapper objectMapper, + Clock clock, + String approvalMode, + ManagedWorkspaceRegistry workspaces, + String agentRevision, + List definitionToolProfiles, + List definitionModels, + List modelCatalog, + ManagedAgentDefinitionStore definitions, + ManagedSessionSkills creationSkills, + boolean workspaceFilesEnabled, + boolean workspaceShellEnabled, + ResidentWorkspaces residentWorkspaces) { + this.eventJournal = eventJournal; + this.sessionQueries = sessionQueries; + this.storeRows = storeRows; + this.worktreeFiles = worktreeFiles; + this.harnessRules = harnessRules; + this.configRecords = configRecords; + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.clock = clock; + this.approvalMode = approvalMode; + this.workspaces = workspaces; + this.agentRevision = agentRevision; + this.definitionToolProfiles = definitionToolProfiles; + this.definitionModels = definitionModels; + this.modelCatalog = modelCatalog; + this.definitions = definitions; + this.creationSkills = creationSkills; + this.workspaceFilesEnabled = workspaceFilesEnabled; + this.workspaceShellEnabled = workspaceShellEnabled; + this.residentWorkspaces = residentWorkspaces; + } + + static final Pattern DEFINITION_REVISION = Pattern.compile( + "^[1-9][0-9]{0,17}$"); + + static ManagedAgentException workspaceExecutionUnavailable() { + return new ManagedAgentException(409, + "workspace_unavailable", + "Hosted Workspace execution is not available."); + } + + /** + * The execution identity a new Session pins (D8b): for the built-in {@code qwen-code} agent the + * deployment revision with no definition digest; for a stored definition the resolved revision, + * its digest and the approval mode and tool profile compiled from its content (D8c-1). + */ + record AgentPin(String revision, String digest, + String approvalMode, String toolProfile, boolean hooks, boolean mcp) { + } + + boolean isSshWorkspace(ContextBinding binding) { + return jdbc.queryForObject("SELECT COUNT(*) FROM ma_storage WHERE alias_key = ? AND storage_kind = 'SSH_LOCAL'", + Long.class, JdbcRuntimeBindingRepository.storageAliasKey(binding.getTenantId(), binding.getStorageId())) == 1; + } + + static void requireSshToolProfile(String profile) { + if (!WorkspaceToolProfiles.FILES.equals(profile) && !WorkspaceToolProfiles.SHELL.equals(profile)) { + throw workspaceExecutionUnavailable(); + } + } + + WebShellSessionOptions workspaceSessionOptions( + String tenantId, String agentId) { + AgentPin pin = resolveAgentPin(tenantId, agentId, null, true); + return new WebShellSessionOptions( + pin.approvalMode(), availableApprovalModes(pin), + !pin.hooks() && !pin.mcp() && (WorkspaceToolProfiles.FILES.equals(pin.toolProfile()) + || WorkspaceToolProfiles.isPublicShell(pin.toolProfile())), false); + } + + static List availableApprovalModes(AgentPin pin) { + return pin.digest() != null ? List.of(pin.approvalMode()) + : WorkspaceToolProfiles.isPublicShell(pin.toolProfile()) + ? List.of("default", "auto-edit") + : List.of("default", "auto-edit", "yolo"); + } + + boolean supportsPlanMode(SessionRecord session) { + return (WorkspaceToolProfiles.FILES.equals(session.toolProfile()) + || WorkspaceToolProfiles.isPublicShell(session.toolProfile())) + && WorkspaceToolProfiles.supportsApprovalSelection(session) + && sessionQueries.findChildLineage(session.tenantId(), session.sessionId()) == null + && !hasMcpServers(session) + && (session.agentDefinitionDigest() == null + || !definitions.pinnedContent(session).containsKey("hook_catalog")); + } + + static AgentPin selectApprovalMode(AgentPin pin, String requested, boolean supported) { + if (requested == null) { + return pin; + } + if (!availableApprovalModes(pin).contains(requested) + && !(supported && pin.digest() == null && "yolo".equals(requested) + && WorkspaceToolProfiles.isPublicShell(pin.toolProfile()))) { + throw new ManagedAgentException(400, "unsupported_approval_mode", + "The selected approval mode is not available for this Agent."); + } + return new AgentPin(pin.revision(), pin.digest(), requested, pin.toolProfile(), pin.hooks(), pin.mcp()); + } + + // Called only for a new admission; a retry has already replayed and + // never resolves a definition again. + AgentPin resolveAgentPin(String tenantId, String agentId, + String requested, boolean workspaceBound) { + if ("qwen-code".equals(agentId)) { + if (requested != null && !requested.equals(agentRevision)) { + throw new ManagedAgentException(400, + "unsupported_feature", + "Only the current agent revision can be selected."); + } + String toolProfile = workspaceBound + ? (workspaceShellEnabled ? WorkspaceToolProfiles.SHELL + : WorkspaceToolProfiles.FILES) + : null; + return new AgentPin(agentRevision, null, approvalMode, toolProfile, false, false); + } + if (definitions == null) { + throw com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore.notFound(); + } + ManagedAgentDefinitionStore.DefinitionRevision revision = + resolveDefinitionRevision(tenantId, agentId, requested); + AgentDefinitionCompiler.Compiled compiled = + AgentDefinitionCompiler.compile(revision.definitionJson(), + workspaceBound, approvalMode, definitionToolProfiles, + definitionModels); + Map content = storeRows.readMap(revision.definitionJson()); + return new AgentPin(Long.toString(revision.revision()), + revision.digest(), compiled.approvalMode(), + compiled.toolProfile(), content.containsKey("hook_catalog"), + !AgentDefinitionExtensions.mcpServers(content.get("mcp_servers"), compiled.toolProfile()).isEmpty()); + } + + boolean hasMcpServers(SessionRecord session) { + if (WorkspaceToolProfiles.READ.equals(session.toolProfile())) { + return false; + } + boolean pinned = session.agentDefinitionDigest() != null && !AgentDefinitionExtensions.mcpServers( + definitions.pinnedContent(session).get("mcp_servers"), session.toolProfile()).isEmpty(); + return ManagedMcpSettings.find(jdbc, session.tenantId(), session.sessionId()) != null || pinned; + } + + SessionRecord requireMcpManagement(String tenantId, String actorId, String sessionId, String operationId) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || "DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + if (!access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(403, "session_operation_forbidden", + "Only a Workspace operator may manage MCP servers."); + } + if (!WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(session.toolProfile()) + || !workspaceFilesEnabled || !workspaceShellEnabled && WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) + || isSshWorkspace(session.workspace()) || residentWorkspaces.find(session.workspace()) != null + || sessionQueries.findChildLineage(tenantId, sessionId) != null) { + throw new ManagedAgentException(409, "unsupported_feature", "MCP management is unavailable."); + } + requireSessionStatus(session.status(), "ACTIVE"); + requireCwdChangeRegistryFacts(session); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + boolean replay = operationId != null && !jdbc.queryForList("SELECT 1 FROM ma_session_journal_tx" + + " WHERE tenant_id=? AND session_id=? AND command_id=? AND operation='manageMcp' LIMIT 1", + tenantId, sessionId, operationId).isEmpty(); + if (operationId != null && !replay) { + ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); + requireMcpManagementIdle(session); + } + return session; + } + + void requireMcpSettingsCommit(String tenantId, String workspaceId, String sessionId) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || !workspaceId.equals(session.workspace().getWorkspaceId()) + || !WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(session.toolProfile()) + || sessionQueries.findChildLineage(tenantId, sessionId) != null) { + throw new ManagedAgentException(409, "unsupported_feature", "MCP management is unavailable."); + } + requireSessionStatus(session.status(), "ACTIVE"); + requireMcpManagementIdle(session); + } + + SessionRecord requireExtensionManagement(String tenantId, String actorId, String sessionId, String operationId, boolean mutation) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || "DELETED".equals(session.status())) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); + } + if (mutation && !access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(403, + "session_operation_forbidden", "Only a Workspace operator may apply extensions."); + } + var lineage = sessionQueries.findChildLineage(tenantId, sessionId); + JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + boolean same = ManagedConfigInstall.isPackageConfig(current) && operationId != null + && operationId.equals(current.path("operationId").asText()); + if (lineage != null && mutation && (!same || !childExtensionBootstrapCommand(session, lineage).equals(current.path("command"))) + || !workspaceFilesEnabled + || !Set.of(WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP, WorkspaceToolProfiles.FILES, WorkspaceToolProfiles.READ) + .contains(session.toolProfile())) { + throw new ManagedAgentException(409, "unsupported_feature", "Extension management is unavailable."); + } + if (mutation) { + requireSessionStatus(session.status(), "ACTIVE"); + requireCwdChangeRegistryFacts(session); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + if (!same) { + ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); + requireMcpManagementIdle(session); + } + } + return session; + } + + void requireConfigurationCommit(String tenantId, String workspaceId, String sessionId, JsonNode body) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null || !workspaceId.equals(session.workspace().getWorkspaceId())) { + throw new ManagedAgentException(409, "unsupported_feature", "Extension management is unavailable."); + } + requireSessionStatus(session.status(), "ACTIVE"); + var lineage = sessionQueries.findChildLineage(tenantId, sessionId); + if (lineage != null) { + requireChildExtensionBootstrap(session, lineage, body); + return; + } + Map initial = creationSkills.initialSelection(tenantId, sessionId); + if (initial != null) { + if (initial.get("operationId").equals(body.path("command").path("operationId").asText())) { + boolean beforeModel = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, + tenantId, sessionId) <= 1 + && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", + tenantId, sessionId).isEmpty(); + if (!beforeModel || !isBootstrapRevision(body) + || !objectMapper.valueToTree(initial).equals(body.path("command"))) { + throw new ManagedAgentException(409, "extension_initialization_conflict", "Initial extension selection conflicts with its creation receipt."); + } + return; + } + JsonNode configuration = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + if (configuration == null || !Set.of("active", "failed").contains(configuration.path("phase").asText())) { + throw new ManagedAgentException(409, "extension_initialization_required", "Initial extension configuration must settle first."); + } + } + // Every phase observes the same locked Session row as Turn admission. + // A precommit stage may lose its idle window; it cannot commit over a new Turn. + requireMcpManagementIdle(session); + } + + void requireChildExtensionBootstrap(SessionRecord child, StoreModels.SessionLineage lineage, JsonNode body) { + JsonNode command = childExtensionBootstrapCommand(child, lineage); + boolean onlyInitialTurn = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, + child.tenantId(), child.sessionId()) <= 1 + && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", + child.tenantId(), child.sessionId()).isEmpty(); + if (!onlyInitialTurn || !isBootstrapRevision(body) || !command.equals(body.path("command"))) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Child extension bootstrap conflicts with its launch snapshot"); + } + } + + static boolean isBootstrapRevision(JsonNode body) { + return "apply".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 0 + && body.path("configRevision").asLong(-1) == 1 + || "recoverPrevious".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 1 + && body.path("configRevision").asLong(-1) == 2; + } + + JsonNode childExtensionBootstrapCommand(SessionRecord child, StoreModels.SessionLineage lineage) { + if (configRecords == null) { + throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); + } + var run = configRecords.findRecord(child.tenantId(), lineage.parentSessionId(), "child_run", lineage.parentChildRunId()).orElseThrow(); + var envelope = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), run.required("inputRef")); + var launch = envelope.path("publicLaunch"); + if (launch.path("version").asInt() != 2) { + throw new ManagedAgentException(409, "unsupported_feature", "Child extension updates are unavailable"); + } + var ref = launch.required("extensionAgent").required("bundleRef"); + var bundle = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), ref); + var pins = objectMapper.createArrayNode(); + for (var entry : bundle.required("packages")) { + pins.add(entry.required("pin")); + } + String operation = ManagedConfigInstall.derivedOperationId("managed-extension-child:", child.sessionId() + ":" + ref.required("digest").asText()); + return objectMapper.createObjectNode().put("operationId", operation) + .put("actorKey", DigestUtils.sha256Hex("managed-extension-child:" + child.sessionId())) + .put("expectedRevision", 0).set("extensions", pins); + } + + void requireMcpManagementIdle(SessionRecord session) { + String tenantId = session.tenantId(); + String sessionId = session.sessionId(); + // The journal may already have a consistent-read snapshot; these reads must observe the + // Plan/Turn admission that won the same Session row before this settings commit. + if (Boolean.TRUE.equals(jdbc.queryForObject("SELECT plan_enabled FROM ma_session WHERE tenant_id=? AND session_id=? FOR UPDATE", + Boolean.class, tenantId, sessionId))) { + throw new ManagedAgentException(409, "unsupported_feature", "Disable Plan mode before managing MCP servers."); + } + if (!jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=?" + + " AND status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') LIMIT 1 FOR UPDATE", tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval."); + } + if (!jdbc.queryForList("SELECT record_id FROM ma_session_record WHERE record_kind='DOMAIN'" + + " AND session_scope_key=? AND tenant_id=? AND session_id=? AND domain='child_run'" + + " AND settled_at IS NULL LIMIT 1 FOR UPDATE", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(409, "session_context_busy", "The Session has an unsettled child run."); + } + long now = storeRows.lifecycleDatabaseTime(); + for (String options : jdbc.queryForList("SELECT options_json FROM ma_session_record WHERE session_scope_key=?" + + " AND record_kind='ACTION' AND state='requested' AND tenant_id=? AND session_id=? FOR UPDATE", + String.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), tenantId, sessionId)) { + Object expiresAt = storeRows.readMap(options).get("expiresAt"); + if (expiresAt instanceof Number expiration && now < expiration.longValue()) { + throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval."); + } + } + if (!jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE scope_key=? AND target_key=?" + + " AND operation_kind IN " + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND (state IN ('PENDING', 'RUNNING', 'UNKNOWN') OR state='RECOVERY_BLOCKED' AND operation_kind <> 'TASK_CANCEL')" + + " LIMIT 1 FOR UPDATE", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), AgentStoreRows.sessionOperationTarget(sessionId)).isEmpty() + || !jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE operation_kind='RENAME'" + + " AND target_key=? AND tenant_id=? AND session_id=? AND state IN ('PENDING', 'RUNNING') LIMIT 1 FOR UPDATE", + AgentStoreRows.renameTarget(tenantId, sessionId), tenantId, sessionId).isEmpty() + || !jdbc.queryForList("SELECT receipt_key FROM ma_request_receipt WHERE namespace='SESSION_COMMAND'" + + " AND tenant_key=? AND tenant_id=? AND session_id=? AND state='PENDING' LIMIT 1 FOR UPDATE", + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(tenantId), tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(409, "session_context_busy", "The Session has an active operation."); + } + } + + ManagedAgentDefinitionStore.DefinitionRevision resolveDefinitionRevision( + String tenantId, String agentId, String requested) { + if (requested == null) { + return definitions.latestForUpdate(tenantId, agentId) + .orElseThrow(com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore::notFound); + } + if (!DEFINITION_REVISION.matcher(requested).matches()) { + throw com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore.notFound(); + } + return definitions + .find(tenantId, agentId, Long.parseLong(requested)) + .orElseThrow(com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore::notFound); + } + + // A parked title (RECOVERY_BLOCKED) is not + // pending: nothing sends it again, and the reconciler settles it in any + // Session status, so it never holds the lifecycle (D7-2). + boolean hasPendingRenameDelivery(String tenantId, String sessionId) { + return jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_operation WHERE operation_kind = 'RENAME' AND" + + " target_key = ? AND tenant_id = ? AND session_id = ? AND state IN" + + " ('PENDING', 'RUNNING')", + Integer.class, + AgentStoreRows.renameTarget(tenantId, sessionId), + tenantId, + sessionId) + > 0; + } + + boolean workspaceFilesEnabled() { + return workspaceFilesEnabled; + } + + boolean workspaceShellEnabled() { + return workspaceShellEnabled; + } + + void requireCwdChangeRegistryFacts(SessionRecord session) { + SessionWorktreeStore.requireReady(worktreeFiles, jdbc, session.tenantId(), session.sessionId()); + if (!sessionQueries.hasExecutionRegistryFacts(session.tenantId(), + session.sessionId())) { + throw workspaceExecutionUnavailable(); + } + } + + boolean hasOpenOperation(String tenantId, String sessionId) { + return hasOpenOperation(tenantId, sessionId, null); + } + + // Hook owners can outlive their Turn with an immutable worker context. + // Only confirmed release permits changing that context's cwd/revision. + boolean hasRetainedRuntimeSession(String tenantId, String sessionId) { + return !jdbc.queryForList("SELECT runtime_session_id FROM ma_runtime_session" + + " WHERE tenant_id = ? AND harness_session_id = ?" + + " AND session_state <> 'RELEASED' LIMIT 1", tenantId, sessionId).isEmpty(); + } + + // The bound later-Turn barrier: context-changing operations only — + // pending command rows, permission-action operations and task cancels + // (which stop one task and change no Session context) are excluded, + // matching the recovery scan's own population. + boolean hasOpenExecutionOperation(String tenantId, + String sessionId) { + if (ManagedConfigStore.blocked(jdbc, tenantId, sessionId)) { + return true; + } + Integer operations = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " ma_operation WHERE scope_key = ? AND" + + " target_key = ? AND operation_kind IN" + + " ('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE', 'CONTEXT_COMPRESSION') AND state IN" + + " ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED', 'UNKNOWN')", + Integer.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), AgentStoreRows.sessionOperationTarget(sessionId)); + return operations != null && operations > 0; + } + + boolean hasOpenOperation(String tenantId, String sessionId, + String excludingOperationId) { + if (ManagedConfigStore.blocked(jdbc, tenantId, sessionId)) { + return true; + } + Integer commands = + jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_request_receipt WHERE namespace =" + + " 'SESSION_COMMAND' AND tenant_key = ? AND tenant_id = ? AND" + + " session_id = ? AND state = 'PENDING'", + Integer.class, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + tenantId), + tenantId, + sessionId); + // A recovery_blocked task cancel is parked, not open (H4f): its + // acceptance is reconciled from the task's committed record and + // it must never hold the Session's lifecycle hostage meanwhile. + Integer operations = + jdbc.queryForObject( + "SELECT COUNT(*) FROM" + + " ma_operation WHERE scope_key = ? AND operation_kind IN " + + AgentStoreRows.SESSION_OPERATION_KINDS + + " AND target_key = ? AND (state IN ('PENDING', 'RUNNING'," + + " 'UNKNOWN') OR state = 'RECOVERY_BLOCKED' AND operation_kind <>" + + " 'TASK_CANCEL') AND operation_id <> ?", + Integer.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys + .identity( + excludingOperationId == null ? "" : excludingOperationId)); + return (commands != null && commands > 0) + || (operations != null && operations > 0); + } + + WebShellModelChangeResult changeSessionModel(String tenantId, String actorId, + String sessionId, String key, String digest, String modelId, long expectedRevision, + boolean supported) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null) { + throw new ManagedAgentException(400, "unsupported_feature", + "Model selection requires a Workspace Session"); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found"); + } + String scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys + .key("SET_MODEL", sessionId, actorId); + var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_COMMAND; + var scopeKind = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.ACTOR; + ManagedReceipts.Receipt replay = ManagedReceipts.find(jdbc, namespace, tenantId, + scopeKind, scope, key, false); + if (replay != null) { + if (!digest.equals(replay.requestDigest()) + || !sessionId.equals(replay.sessionId()) + || !"SET_MODEL".equals(replay.operation()) + || !java.util.Arrays.equals(actorId.getBytes(StandardCharsets.UTF_8), + replay.actorKey())) { + throw new ManagedAgentException(409, "idempotency_conflict", + "The idempotency key was reused with different content"); + } + Map result = storeRows.readMap(replay.resultJson()); + return new WebShellModelChangeResult(sessionId, new WebShellModelSelection( + (String) result.get("modelId"), ((Number) result.get("revision")).longValue()), true); + } + if (!access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(403, "session_operation_forbidden", + "Only a Workspace operator may change the model"); + } + if (!supported || modelCatalog.isEmpty() || !workspaceFilesEnabled) { + throw new ManagedAgentException(409, "unsupported_feature", "Model selection is unavailable"); + } + if (modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId))) { + throw new ManagedAgentException(400, "unsupported_model", "The model is not published by this host"); + } + requirePolicyChange(session, false); + long current = jdbc.queryForObject("SELECT model_revision FROM ma_session WHERE tenant_id = ?" + + " AND session_id = ?", Long.class, tenantId, sessionId); + if (current != expectedRevision || current >= 9_007_199_254_740_991L) { + throw new ManagedAgentException(409, "model_selection_conflict", "The model revision changed"); + } + long revision = current + 1; + long now = clock.millis(); + jdbc.update("UPDATE ma_session SET model_id = ?, model_revision = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND model_revision = ?", + modelId, revision, now, tenantId, sessionId, current); + ManagedReceipts.insert(jdbc, namespace, tenantId, scopeKind, scope, key, digest, + "SET_MODEL", actorId.getBytes(StandardCharsets.UTF_8), sessionId, null, + "COMPLETED", session.status(), true, now); + jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?", + storeRows.writeJson(Map.of("modelId", modelId, "revision", revision)), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.receiptKey( + namespace, tenantId, scopeKind, scope, key)); + String sessionScope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + String observation = ManagedExtensionProjection.recordKey(sessionId, "hosted_context_usage", "latest"); + jdbc.update("DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" + + " 'SESSION_RECORD' AND owner_key = ?", sessionScope, observation); + jdbc.update("DELETE FROM ma_session_record WHERE session_scope_key = ? AND record_key = ?", + sessionScope, observation); + eventJournal.appendEvent(tenantId, sessionId, null, "session.model.changed", + Map.of("modelId", modelId, "revision", revision), false, null, now); + return new WebShellModelChangeResult(sessionId, new WebShellModelSelection(modelId, revision), false); + } + + WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, + String sessionId, String key, String digest, String mode, long expectedRevision, + boolean supported, boolean agentDefinitionSupported) { + return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, + supported, agentDefinitionSupported, null, false); + } + + WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, + String sessionId, String key, String digest, String mode, long expectedRevision, + boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported) { + return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, + supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, false); + } + + WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, + String sessionId, String key, String digest, String mode, long expectedRevision, + boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported, + boolean liveSupported) { + return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, + supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, liveSupported, null, false, false); + } + + WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, + String sessionId, String key, String digest, String mode, long expectedRevision, + boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported, + boolean liveSupported, Map> requestedRules, boolean rulesSupported, boolean autoSupported) { + WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (session.workspace() == null) { + throw new ManagedAgentException(400, "unsupported_feature", + "Approval selection requires a Workspace Session"); + } + WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); + if (!access.canRead()) { + throw new ManagedAgentException(404, "session_not_found", "The Session was not found"); + } + if (!workspaces.isSessionOwner(tenantId, actorId, sessionId) + || !access.atLeast(WorkspaceAccess.OPERATOR)) { + throw new ManagedAgentException(403, "session_operation_forbidden", + "Only the Session owner with OPERATOR access may change approval policy"); + } + String scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys + .key("SET_APPROVAL", sessionId, actorId); + var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_COMMAND; + var scopeKind = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.ACTOR; + ManagedReceipts.Receipt replay = ManagedReceipts.find(jdbc, namespace, tenantId, + scopeKind, scope, key, false); + if (replay != null) { + if (!digest.equals(replay.requestDigest()) + || !sessionId.equals(replay.sessionId()) + || !"SET_APPROVAL".equals(replay.operation()) + || !java.util.Arrays.equals(actorId.getBytes(StandardCharsets.UTF_8), + replay.actorKey())) { + throw new ManagedAgentException(409, "idempotency_conflict", + "The idempotency key was reused with different content"); + } + Map result = storeRows.readMap(replay.resultJson()); + return new WebShellApprovalChangeResult(sessionId, (String) result.get("mode"), + ((Number) result.get("revision")).longValue(), true, Boolean.TRUE.equals(result.get("planMode")), + result.get("permissionRules") instanceof Map rules ? harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(rules)) : null); + } + if (!supported || session.agentDefinitionDigest() != null && !agentDefinitionSupported + || !workspaceFilesEnabled || !WorkspaceToolProfiles.supportsApprovalSelection(session) + || WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) { + throw new ManagedAgentException(409, "unsupported_feature", "Approval selection is unavailable"); + } + if (!WorkspaceToolProfiles.supportsApprovalMode(mode) || "auto".equals(mode) && !autoSupported) { + throw new ManagedAgentException(400, "unsupported_approval_mode", "The approval mode is unavailable"); + } + var currentPolicy = jdbc.queryForMap("SELECT approval_revision, permission_rules_json FROM ma_session WHERE tenant_id=? AND session_id=?", + tenantId, sessionId); + Map> rules = requestedRules == null ? storeRows.readPermissionRules((String) currentPolicy.get("permission_rules_json")) + : harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(requestedRules)); + if ((requestedRules != null || rules != null) && !rulesSupported) { + throw new ManagedAgentException(409, "unsupported_feature", "Permission rules are unavailable"); + } + boolean planMode = requestedPlanMode == null + ? Boolean.TRUE.equals(jdbc.queryForObject("SELECT plan_enabled FROM ma_session WHERE tenant_id=? AND session_id=?", + Boolean.class, tenantId, sessionId)) : requestedPlanMode; + if (planMode && (!planModeSupported || !supportsPlanMode(session))) { + throw new ManagedAgentException(409, "unsupported_feature", "Plan mode is unavailable"); + } + if (requestedRules != null && requestedPlanMode == null) { + if (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session)) { + throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval"); + } + if (hasOpenOperation(tenantId, sessionId)) { + throw new ManagedAgentException(409, "session_context_busy", "The Session requires settlement"); + } + } + requirePolicyChange(session, requestedPlanMode == null && (liveSupported || requestedRules != null)); + long current = ((Number) currentPolicy.get("approval_revision")).longValue(); + if (current != expectedRevision || current >= 9_007_199_254_740_991L + || planMode && current >= 9_007_199_254_740_990L) { + throw new ManagedAgentException(409, "approval_selection_conflict", "The approval revision changed"); + } + long revision = current + 1; + long now = clock.millis(); + jdbc.update("UPDATE ma_session SET selected_approval_mode = ?, approval_revision = ?, plan_enabled = ?, permission_rules_json = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND approval_revision = ?", + mode, revision, planMode, rules == null ? null : storeRows.writeJson(rules), now, tenantId, sessionId, current); + ManagedReceipts.insert(jdbc, namespace, tenantId, scopeKind, scope, key, digest, + "SET_APPROVAL", actorId.getBytes(StandardCharsets.UTF_8), sessionId, null, + "COMPLETED", session.status(), true, now); + Map result = new LinkedHashMap<>(Map.of("mode", mode, "revision", revision, "planMode", planMode)); + if (rules != null) { + result.put("permissionRules", rules); + } + jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?", + storeRows.writeJson(result), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.receiptKey( + namespace, tenantId, scopeKind, scope, key)); + eventJournal.appendEvent(tenantId, sessionId, null, "session.approval.changed", + result, false, null, now); + return new WebShellApprovalChangeResult(sessionId, mode, revision, false, planMode, rules); + } + + void requirePublicChildImmutable(SessionRecord session) { + if (session.publicChildAgentsVersion() == 1 && sessionQueries.findChildLineage(session.tenantId(), session.sessionId()) != null) { + throw new ManagedAgentException(409, "unsupported_feature", "Public child Sessions cannot change their delegated execution"); + } + } + + void requirePolicyChange(SessionRecord session, boolean live) { + requirePublicChildImmutable(session); + String tenantId = session.tenantId(); + String sessionId = session.sessionId(); + WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); + requireSessionStatus(session.status(), "ACTIVE"); + requireCwdChangeRegistryFacts(session); + if (!live && (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session))) { + throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval"); + } + if ((live ? hasOpenExecutionOperation(tenantId, sessionId) : hasOpenOperation(tenantId, sessionId)) + || hasPendingRenameDelivery(tenantId, sessionId) + || !live && hasRetainedRuntimeSession(tenantId, sessionId) + || !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?" + + " AND (status IN ('RECOVERY_BLOCKED', 'CANCELLING') OR status IN ('ACCEPTED', 'RUNNING')" + + " AND error_code IN (?, ?, ?)) LIMIT 1", tenantId, sessionId, + AgentStateStore.RUNTIME_RECOVERY_BLOCKED, AgentStateStore.WORKFLOW_RECOVERY_BLOCKED, + AgentStateStore.HARNESS_RECOVERY_BLOCKED).isEmpty() + || !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ?" + + " AND session_id = ? AND head_kind = 'MANAGED_SESSION' AND" + + " (state <> 'ACTIVE' OR recovery_status <> 'READY')", tenantId, sessionId).isEmpty()) { + throw new ManagedAgentException(409, "session_context_busy", "The Session requires settlement"); + } + } + + void requireNoOpenOperation(String tenantId, String sessionId) { + if (hasOpenOperation(tenantId, sessionId)) { + throw new ManagedAgentException(409, + "session_operation_active", + "The Session already has a lifecycle operation in progress."); + } + } + + boolean hasDecidableAction(SessionRecord session) { + long now = storeRows.lifecycleDatabaseTime(); + for (String options : jdbc.queryForList( + "SELECT options_json FROM ma_session_record WHERE session_scope_key = ? AND" + + " record_kind = 'ACTION' AND state = 'requested' AND tenant_id = ?" + + " AND session_id = ?", String.class, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(session.tenantId(), session.sessionId()), + session.tenantId(), session.sessionId())) { + try { + if (now < objectMapper.readTree(options).path("expiresAt").asLong()) { + return true; + } + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored Action options are invalid", error); + } + } + return false; + } + + static void requireSessionStatus(String actual, + String expected) { + if (!expected.equals(actual)) { + throw sessionStateConflict(actual); + } + } + + static ManagedAgentException sessionStateConflict(String status) { + return new ManagedAgentException(409, + "session_state_conflict", + "The Session is " + status.toLowerCase(Locale.ROOT) + + " and cannot perform this operation."); + } + + boolean hasActiveTurn(String tenantId, String sessionId) { + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" + + " ma_turn WHERE tenant_id = ? AND" + + " session_id = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", + Integer.class, tenantId, sessionId); + return count != null && count > 0; + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionQueries.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionQueries.java new file mode 100644 index 00000000000..cd90a3d1717 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentSessionQueries.java @@ -0,0 +1,551 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellApprovalSelection; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellLifecycleOperation; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelContext; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelOption; +import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelSelection; +import com.alibaba.qwen.code.managedagent.model.StoreModels; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionPage; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnPage; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnSummary; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import org.apache.commons.lang3.StringUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowCallbackHandler; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +final class AgentSessionQueries { + private final AgentStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles; + private final JdbcTemplate jdbc; + private final List modelCatalog; + + AgentSessionQueries(AgentStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles, + JdbcTemplate jdbc, + List modelCatalog) { + this.storeRows = storeRows; + this.worktreeFiles = worktreeFiles; + this.jdbc = jdbc; + this.modelCatalog = modelCatalog; + } + + static final String TURN_SUMMARY_COLUMNS = "session_id," + + " turn_id, status, gmt_create, completed_at, error_code"; + + // findLatestTurns joins ma_public_event, so the same projection + // must be table-qualified there. + static final String QUALIFIED_TURN_SUMMARY_COLUMNS = + "turn_record." + String.join(", turn_record.", + TURN_SUMMARY_COLUMNS.split(", ")); + + SessionWorktreeStore.Descriptor findSessionWorktree(String tenantId, String sessionId) { + return SessionWorktreeStore.descriptor(worktreeFiles, jdbc, tenantId, sessionId); + } + + Map findSessionWorktrees(String tenantId, List sessionIds) { + return SessionWorktreeStore.descriptors(worktreeFiles, jdbc, tenantId, sessionIds); + } + + String findChildWorkspaceCwd(String tenantId, + String parentSessionId, String childRunId) { + List rows = jdbc.queryForList( + "SELECT child_cwd_relative FROM ma_workspace WHERE workspace_kind =" + + " 'CHILD_GIT' AND tenant_id = ? AND parent_session_id = ? AND" + + " child_run_id = ?", + String.class, tenantId, parentSessionId, childRunId); + return rows.isEmpty() ? null : rows.getFirst(); + } + + StoreModels.SessionLineage findChildLineage(String tenantId, + String sessionId) { + // The mapper yields null for a root row, and Stream.findFirst() + // throws on a null element — fetch and read the one row directly. + List rows = jdbc.query( + "SELECT parent_session_id, root_session_id," + + " parent_child_run_id, child_depth" + + " FROM ma_session" + + " WHERE tenant_id = ? AND session_id = ?", + (result, row) -> { + String parent = result.getString("parent_session_id"); + if (parent == null) { + return null; + } + return new StoreModels.SessionLineage(parent, + result.getString("root_session_id"), + result.getString("parent_child_run_id"), + result.getInt("child_depth")); + }, tenantId, sessionId); + return rows.isEmpty() ? null : rows.get(0); + } + + boolean hasCompletedWorkspaceClose(String tenantId, String sessionId) { + return completedWorkspaceCloses(tenantId, List.of(sessionId)) + .contains(sessionId); + } + + /** The given Sessions with a completed workspace close, in one read. */ + Set completedWorkspaceCloses(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Set.of(); + } + List arguments = new ArrayList<>(sessionIds.size() + 1); + arguments.add(tenantId); + sessionIds.forEach(id -> arguments.add(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, id))); + return new HashSet<>(jdbc.queryForList("SELECT session_id FROM" + + " ma_operation WHERE tenant_id = ?" + + " AND scope_key IN (" + AgentStoreRows.placeholders(sessionIds.size()) + + ") AND operation_kind = 'CLOSE' AND state = 'COMPLETED'" + + " AND receipt_id IS NOT NULL", + String.class, arguments.toArray())); + } + + // The Registry still backs the binding exactly, its state is ACTIVE and + // the creation actor's grants survive — the passive-attachment subset + // WorkspaceExecutionStore.authorizePassiveAttachment gates execution on; + // the frozen profile and agent checks stay the next turn's + // acquire-time gate. Admission of every family that executes under the + // creator's authority (later Turns and the cwd change alike) re-checks + // the same facts so a widened caller cannot be certified for a run + // that can only fail. + boolean hasExecutionRegistryFacts(String tenantId, + String sessionId) { + return sessionsWithExecutionRegistryFacts(tenantId, + List.of(sessionId)).contains(sessionId); + } + + /** The batch twin of {@link #hasExecutionRegistryFacts}. */ + Set sessionsWithExecutionRegistryFacts(String tenantId, + java.util.Collection sessionIds) { + if (sessionIds.isEmpty()) { + return Set.of(); + } + Map facts = new HashMap<>(); + Set pending = new HashSet<>(sessionIds); + while (!pending.isEmpty()) { + List arguments = new ArrayList<>(); + arguments.add(tenantId); + arguments.addAll(pending); + List rows = + jdbc.query( + "SELECT s.session_id, s.parent_session_id, s.workspace_id," + + " s.workspace_generation, s.workspace_storage_id, CASE WHEN" + + " r.workspace_id IS NOT NULL AND a.actor_id IS NOT NULL THEN TRUE" + + " ELSE FALSE END AS creator_can_execute FROM ma_session s LEFT" + + " JOIN ma_workspace r ON r.tenant_id = s.tenant_id AND" + + " r.workspace_id = s.workspace_id AND r.workspace_generation =" + + " s.workspace_generation AND r.storage_id =" + + " s.workspace_storage_id AND r.state = 'ACTIVE' LEFT JOIN" + + " ma_workspace_access a ON a.tenant_id = r.tenant_id AND" + + " a.workspace_id = r.workspace_id AND a.actor_id =" + + " s.creator_actor_key AND a.role IN ('OPERATOR', 'OWNER') WHERE" + + " s.tenant_id = ? AND s.session_id IN (" + + AgentStoreRows.placeholders(pending.size()) + + ")", + (row, index) -> + new ExecutionFacts( + row.getString("session_id"), + row.getString("parent_session_id"), + row.getString("workspace_id"), + (Long) row.getObject("workspace_generation"), + row.getString("workspace_storage_id"), + row.getBoolean("creator_can_execute")), + arguments.toArray()); + Set queried = Set.copyOf(pending); + pending.clear(); + for (ExecutionFacts row : rows) { + facts.put(row.sessionId(), row); + } + for (ExecutionFacts row : rows) { + if (row.parentId() != null && !facts.containsKey(row.parentId()) + && !queried.contains(row.parentId())) { + pending.add(row.parentId()); + } + } + } + Set verified = new HashSet<>(); + for (String sessionId : sessionIds) { + ExecutionFacts initial = facts.get(sessionId); + if (initial == null || initial.workspaceId() == null || initial.storageId() == null + || initial.generation() == null) { + continue; + } + Set visited = new HashSet<>(); + ExecutionFacts current = initial; + while (current != null && visited.add(current.sessionId()) + && Objects.equals(initial.workspaceId(), current.workspaceId()) + && Objects.equals(initial.generation(), current.generation()) + && Objects.equals(initial.storageId(), current.storageId())) { + if (current.parentId() == null) { + if (current.creatorCanExecute()) { + verified.add(sessionId); + } + break; + } + current = facts.get(current.parentId()); + } + } + return Set.copyOf(verified); + } + + record ExecutionFacts(String sessionId, String parentId, String workspaceId, + Long generation, String storageId, boolean creatorCanExecute) {} + + Optional findSessionById(String sessionId) { + List rows = jdbc.query( + AgentStoreRows.SESSION_WITH_STREAM + " WHERE ma_session.session_kind = 'PUBLIC' AND" + + " ma_session.session_id = ?", + storeRows.sessionMapper, sessionId); + return rows.stream().findFirst(); + } + + SessionPage listSessions(String tenantId, String actorId, + Long beforeCreatedAt, String beforeSessionId, int limit) { + return listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, "all"); + } + + SessionPage listSessions(String tenantId, String actorId, + Long beforeCreatedAt, String beforeSessionId, int limit, String archiveState) { + return listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, archiveState, ""); + } + + SessionPage listSessions(String tenantId, String actorId, + Long beforeCreatedAt, String beforeSessionId, int limit, String archiveState, String titleQuery) { + List arguments = new ArrayList<>(); + arguments.add(tenantId); + arguments.add(actorId == null ? null + : com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId)); + String archiveClause = switch (archiveState) { + case "archived" -> " AND ma_session.status = 'ARCHIVED'"; + case "unarchived" -> " AND ma_session.status <> 'ARCHIVED'"; + default -> ""; + }; + String titleClause = ""; + if (!StringUtils.isEmpty(titleQuery)) { + titleClause = " AND LOCATE(LOWER(?), LOWER(COALESCE(ma_session.title, ''))) > 0"; + arguments.add(titleQuery); + } + // Keyset on the immutable gmt_create: gmt_modified moves on every + // event append, so a row can slide backwards into already-visited + // pages and silently vanish from a paged listing. + String cursorClause = ""; + if (beforeCreatedAt != null && beforeSessionId != null) { + cursorClause = " AND (ma_session.gmt_create < ? OR (ma_session.gmt_create = ?" + + " AND ma_session.session_id < ?))"; + arguments.add(beforeCreatedAt); + arguments.add(beforeCreatedAt); + arguments.add(beforeSessionId); + } + arguments.add(limit + 1); + Map worktrees = new LinkedHashMap<>(); + List rows = + jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + ", COALESCE(es.last_sequence, 0) AS list_last_sequence," + + " COALESCE(es.replay_floor_sequence, 0) AS list_floor_sequence," + + " w.tenant_id AS worktree_tenant_id, w.session_id AS worktree_session_id," + + " w.workspace_id AS worktree_workspace_id, w.worktree_id AS worktree_worktree_id," + + " w.state AS worktree_state, w.plan_json AS worktree_plan_json," + + " w.identity_json AS worktree_identity_json, w.operation_id AS worktree_operation_id," + + " w.revision AS worktree_revision, w.failure_code AS worktree_failure_code," + + " w.owner_actor_key AS worktree_owner_actor_key, o.claim_owner AS worktree_claim_owner," + + " o.claim_generation AS worktree_claim_generation, o.claim_until AS worktree_claim_until," + + " o.phase AS worktree_phase, o.attempt_count AS worktree_attempt_count FROM" + + " ma_session LEFT JOIN ma_event_stream es ON es.tenant_id =" + + " ma_session.tenant_id AND es.session_id = ma_session.session_id AND" + + " es.stream_kind = 'SESSION' AND es.stream_id = X''" + + " LEFT JOIN ma_session_worktree w ON w.tenant_id = ma_session.tenant_id" + + " AND w.session_id = ma_session.session_id LEFT JOIN ma_operation o" + + " ON o.operation_kind = 'SESSION_WORKTREE' AND o.tenant_id = w.tenant_id" + + " AND o.operation_id = w.operation_id WHERE" + + " ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id = ? AND" + + " ma_session.status <> 'DELETED' AND (ma_session.workspace_id IS NULL OR EXISTS (SELECT 1" + + " FROM ma_workspace_access wa WHERE wa.tenant_id =" + + " ma_session.tenant_id AND wa.workspace_id = ma_session.workspace_id" + + " AND wa.actor_id = ? AND wa.role IN ('READER', 'OPERATOR'," + + " 'OWNER')))" + + archiveClause + titleClause + cursorClause + + " ORDER BY ma_session.gmt_create DESC, ma_session.session_id DESC" + + " LIMIT ?", + (result, index) -> { + SessionRecord session = storeRows.sessionListMapper.mapRow(result, index); + if (result.getString("worktree_worktree_id") != null) { + worktrees.put(session.sessionId(), SessionWorktreeStore.readRow(result, "worktree_")); + } + return session; + }, + arguments.toArray()); + boolean hasMore = rows.size() > limit; + if (hasMore) { + rows = new ArrayList<>(rows.subList(0, limit)); + } + var selectedWorktrees = rows.stream().map(row -> worktrees.get(row.sessionId())) + .filter(java.util.Objects::nonNull).toList(); + return new SessionPage(List.copyOf(rows), hasMore, SessionWorktreeStore.descriptors(worktreeFiles, jdbc, selectedWorktrees)); + } + + Map + findLatestLifecycleOperations(String tenantId, List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(); + arguments.add(tenantId); + arguments.addAll(sessionIds); + Map result = new HashMap<>(); + jdbc.query("SELECT * FROM (SELECT o.session_id, o.operation_id, o.operation_kind, o.state, o.failure_code," + + " ROW_NUMBER() OVER (PARTITION BY o.session_id ORDER BY CASE WHEN o.state IN" + + " ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') THEN 0 ELSE 1 END," + + " o.gmt_modified DESC, o.gmt_create DESC, o.operation_id DESC) AS lifecycle_rank FROM ma_operation o" + + " JOIN ma_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" + + " WHERE o.tenant_id = ? AND o.session_id IN (" + AgentStoreRows.placeholders(sessionIds.size()) + ")" + + " AND ((s.status = 'CLOSING' AND o.operation_kind = 'CLOSE')" + + " OR (s.status = 'DELETING' AND o.operation_kind = 'DELETE')" + + " OR (s.status = 'ARCHIVING' AND o.operation_kind = 'ARCHIVE'))) lifecycle WHERE lifecycle_rank = 1", + (RowCallbackHandler) row -> result.put(row.getString("session_id"), + new WebShellLifecycleOperation( + row.getString("operation_id"), row.getString("operation_kind").toLowerCase(Locale.ROOT), + row.getString("state").toLowerCase(Locale.ROOT), row.getString("failure_code"))), + arguments.toArray()); + return result; + } + + /** The active Turn of each given Session, in one round trip. */ + Map findActiveTurns(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(sessionIds.size() + 1); + arguments.add(tenantId); + arguments.addAll(sessionIds); + List rows = jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS + + " FROM ma_turn" + + " WHERE tenant_id = ? AND session_id IN (" + + AgentStoreRows.placeholders(sessionIds.size()) + ") AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING') ORDER BY" + + " gmt_create DESC", + storeRows.turnSummaryMapper, arguments.toArray()); + Map result = new HashMap<>( + sessionIds.size() * 2); + for (TurnSummary row : rows) { + result.putIfAbsent(row.sessionId(), row); + } + return result; + } + + /** The latest Turn of each given Session, in one round trip. */ + Map findLatestTurns(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List arguments = new ArrayList<>(sessionIds.size() + 3); + arguments.add(tenantId); + arguments.addAll(sessionIds); + arguments.add(tenantId); + arguments.add(tenantId); + List rows = + jdbc.query( + "SELECT " + + QUALIFIED_TURN_SUMMARY_COLUMNS + + " FROM ma_turn turn_record JOIN (SELECT event.session_id," + + " event.turn_id FROM ma_public_event event JOIN (SELECT" + + " accepted.session_id, MAX(accepted.sequence_id) AS max_sequence FROM" + + " ma_public_event accepted WHERE accepted.stream_kind = 'SESSION' AND accepted.stream_id =" + + " X'' AND accepted.tenant_id = ? AND accepted.session_id IN (" + + AgentStoreRows.placeholders(sessionIds.size()) + + ") AND accepted.event_type = 'turn.accepted' GROUP BY" + + " accepted.session_id) latest ON latest.session_id =" + + " event.session_id AND latest.max_sequence =" + + " event.sequence_id WHERE event.tenant_id = ?)" + + " latest_turn ON latest_turn.session_id =" + + " turn_record.session_id AND latest_turn.turn_id =" + + " turn_record.turn_id WHERE turn_record.tenant_id =" + + " ?", + storeRows.turnSummaryMapper, + arguments.toArray()); + Map result = new HashMap<>( + sessionIds.size() * 2); + for (TurnSummary row : rows) { + result.put(row.sessionId(), row); + } + return result; + } + + Map findModelContexts( + String tenantId, List sessionIds) { + return ManagedExtensionRecordStore.findModelContexts(jdbc, tenantId, sessionIds); + } + + List modelOptions() { return modelCatalog; } + + Map findModelSelections(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(sessionIds); + Map result = new HashMap<>(); + jdbc.query("SELECT session_id, model_id, model_revision FROM ma_session WHERE tenant_id = ?" + + " AND session_id IN (" + AgentStoreRows.placeholders(sessionIds.size()) + + ") AND session_kind = 'PUBLIC' AND workspace_id IS NOT NULL AND model_id IS NOT NULL", + row -> { + result.put(row.getString("session_id"), new WebShellModelSelection( + row.getString("model_id"), row.getLong("model_revision"))); + }, args.toArray()); + return result; + } + + WebShellModelSelection findTurnModelSelection(String tenantId, String sessionId, + String promptId) { + var rows = jdbc.query("SELECT model_id, model_revision FROM ma_turn WHERE tenant_id = ?" + + " AND session_id = ? AND prompt_id = ?", (row, index) -> row.getString("model_id") == null + ? null : new WebShellModelSelection(row.getString("model_id"), + row.getLong("model_revision")), tenantId, sessionId, promptId); + if (rows.size() != 1) { + throw new ManagedAgentException(409, "turn_not_found", "The model snapshot Turn was not found"); + } + return rows.getFirst(); + } + + Map findApprovalSelections(String tenantId, + List sessionIds) { + if (sessionIds.isEmpty()) { + return Map.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(sessionIds); + Map result = new HashMap<>(); + jdbc.query("SELECT session_id, COALESCE(selected_approval_mode, approval_mode) AS mode," + + " approval_revision, plan_enabled, permission_rules_json FROM ma_session WHERE tenant_id=? AND session_id IN (" + + AgentStoreRows.placeholders(sessionIds.size()) + ") AND session_kind='PUBLIC'", + row -> { result.put(row.getString("session_id"), new WebShellApprovalSelection( + row.getString("mode"), row.getLong("approval_revision"), row.getBoolean("plan_enabled"), + storeRows.readPermissionRules(row.getString("permission_rules_json")))); }, args.toArray()); + return result; + } + + Set findApprovalOwners(String tenantId, String actorId, List sessionIds) { + if (sessionIds.isEmpty() || actorId == null || actorId.isEmpty()) { + return Set.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.add(com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId)); + args.addAll(sessionIds); + return new HashSet<>(jdbc.queryForList("SELECT session_id FROM ma_session WHERE tenant_id=?" + + " AND COALESCE(owner_actor_key, creator_actor_key)=? AND session_id IN (" + + AgentStoreRows.placeholders(sessionIds.size()) + ")", String.class, args.toArray())); + } + + WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId, + String promptId) { + var rows = jdbc.query("SELECT t.approval_mode, t.approval_revision, t.plan_enabled, t.permission_rules_json," + + " s.approval_revision AS selected_revision, s.agent_id, s.agent_definition_digest," + + " s.workspace_id, s.tool_profile FROM ma_turn t JOIN ma_session s" + + " ON s.tenant_id=t.tenant_id AND s.session_id=t.session_id" + + " WHERE t.tenant_id=? AND t.session_id=? AND t.prompt_id=?", (row, index) -> { + if (("qwen-code".equals(row.getString("agent_id")) + ? row.getString("agent_definition_digest") != null + : row.getString("agent_definition_digest") == null) || row.getString("workspace_id") == null + || !(WorkspaceToolProfiles.FILES.equals(row.getString("tool_profile")) + || WorkspaceToolProfiles.FILES_V3.equals(row.getString("tool_profile")) + || WorkspaceToolProfiles.isPublicShell(row.getString("tool_profile")))) { + return null; + } + String mode = row.getString("approval_mode"); + if (mode == null && row.getLong("selected_revision") != 0) { + throw new ManagedAgentException(409, "approval_snapshot_missing", "The Turn has no approval snapshot"); + } + // Legacy prompt replays must preserve the original absence of a selection. + return mode == null ? null : new WebShellApprovalSelection(mode, row.getLong("approval_revision"), + row.getBoolean("plan_enabled"), storeRows.readPermissionRules(row.getString("permission_rules_json"))); + }, tenantId, sessionId, promptId); + if (rows.size() != 1) { + throw new ManagedAgentException(409, "turn_not_found", "The Turn was not found"); + } + return rows.getFirst(); + } + + // Reads leave the Turn's input out; the public view never shows it. + TurnPage listTurns(String tenantId, String sessionId, + Long beforeCreatedAt, String beforeTurnId, int limit) { + List arguments = new ArrayList<>(List.of(tenantId, + sessionId)); + String before = ""; + if (beforeCreatedAt != null) { + before = " AND (gmt_create < ? OR (gmt_create = ? AND" + + " turn_id < ?))"; + arguments.add(beforeCreatedAt); + arguments.add(beforeCreatedAt); + arguments.add(beforeTurnId); + } + arguments.add(limit + 1); + List rows = jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS + + " FROM ma_turn WHERE tenant_id = ? AND" + + " session_id = ?" + before + " ORDER BY gmt_create" + + " DESC, turn_id DESC LIMIT ?", + storeRows.turnSummaryMapper, arguments.toArray()); + boolean hasMore = rows.size() > limit; + return new TurnPage(hasMore ? List.copyOf(rows.subList(0, limit)) + : rows, hasMore); + } + + Optional findTurnSummary(String tenantId, + String sessionId, String turnId) { + return jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS + " FROM" + + " ma_turn WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ?", + storeRows.turnSummaryMapper, tenantId, sessionId, turnId).stream() + // The binary collation ignores trailing spaces, so the + // database also matches an ID that adds some. + .filter(turn -> turn.turnId().equals(turnId)) + .findFirst(); + } + + Optional findLastVisibleStatusBeforeDelete(String tenantId, + String sessionId) { + return jdbc.query("SELECT session_status_before FROM ma_operation" + + " WHERE scope_key = ? AND operation_kind = 'DELETE'" + + " AND target_key = ? AND tenant_id = ? AND session_id = ?" + + " AND session_status_before IS NOT NULL" + + " ORDER BY gmt_create DESC LIMIT 1", + (rows, row) -> rows.getString(1), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), + AgentStoreRows.sessionOperationTarget(sessionId), tenantId, sessionId) + .stream().findFirst(); + } + + Optional findSession(String tenantId, + String sessionId) { + return storeRows.findSession(tenantId, sessionId); + } + + Optional findTurn(String tenantId, String sessionId, + String turnId) { + return storeRows.findTurn(tenantId, sessionId, turnId); + } + + SessionRecord requireSession(String tenantId, String sessionId) { + return storeRows.requireSession(tenantId, sessionId); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java new file mode 100644 index 00000000000..4745389d81f --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentStoreRows.java @@ -0,0 +1,347 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules; +import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnSummary; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class AgentStoreRows { + private final HarnessRules harnessRules; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + AgentStoreRows(HarnessRules harnessRules, + JdbcTemplate jdbc, + ObjectMapper objectMapper) { + this.harnessRules = harnessRules; + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.sessionMapper = (result, row) -> { + if (result.getObject("list_last_sequence") == null || result.getObject("list_floor_sequence") == null) { + throw new IllegalStateException("Session stream is unavailable."); + } + return readSession(result, new StreamCounters(result.getLong("list_last_sequence"), + result.getLong("list_floor_sequence"))); + }; + this.sessionListMapper = (result, row) -> + readSession( + result, + new StreamCounters( + result.getLong("list_last_sequence"), + result.getLong("list_floor_sequence"))); + this.turnSummaryMapper = (result, row) -> + new TurnSummary( + result.getString("session_id"), + result.getString("turn_id"), + result.getString("status"), + result.getLong("gmt_create"), + nullableLong(result, "completed_at"), + result.getString("error_code")); + this.turnMapper = (result, row) -> + new TurnRecord(result.getString("tenant_id"), + result.getString("session_id"), + result.getString("turn_id"), + result.getString("prompt_id"), + readInput(result.getString("input_json")), + result.getString("payload_digest"), + result.getString("status"), + result.getBoolean("submission_attempted"), + result.getString("harness_event_epoch"), + nullableLong(result, "harness_last_event_id"), + result.getString("dispatch_owner"), + nullableLong(result, "dispatch_lease_until"), + result.getInt("retry_count"), + additiveInt(result, "consecutive_failures"), + additiveInt(result, "budget_exempt_failures"), + nullableLong(result, "retry_after"), + result.getString("error_code"), + result.getString("error_message"), + result.getLong("gmt_create"), + result.getLong("gmt_modified"), + nullableLong(result, "completed_at"), + result.getLong("version")); + } + + static final Logger LOG = LoggerFactory.getLogger( + ManagedAgentStore.class); + + static final TypeReference>> INPUT_TYPE = + new TypeReference<>() { + }; + + static final TypeReference> MAP_TYPE = + new TypeReference<>() { + }; + + static final List ACTIVE_TURN_STATES = List.of( + "ACCEPTED", "RUNNING", "CANCELLING"); + + static final String SESSION_WITH_STREAM = + "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + ", es.last_sequence AS list_last_sequence, es.replay_floor_sequence AS list_floor_sequence FROM ma_session LEFT JOIN ma_event_stream es ON es.tenant_id = ma_session.tenant_id AND es.session_id = ma_session.session_id AND es.stream_kind = 'SESSION' AND es.stream_id = X''"; + + final RowMapper sessionMapper; + + final RowMapper sessionListMapper; + + SessionRecord readSession(java.sql.ResultSet result, StreamCounters counters) throws java.sql.SQLException { + return new SessionRecord( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("agent_id"), + result.getString("agent_revision"), + result.getString("title"), + result.getString("status"), + result.getString("harness_boot_id"), + result.getString("harness_event_epoch"), + result.getLong("harness_last_event_id"), + counters.lastSequence(), + counters.floorSequence(), + result.getLong("gmt_create"), + result.getLong("gmt_modified"), + nullableLong(result, "deleted_at"), + result.getLong("version"), + readBinding(result), + result.getString("approval_mode"), + result.getString("tool_profile"), + result.getString("agent_definition_digest"), result.getInt("public_child_agents_version")); + } + + final RowMapper turnSummaryMapper; + + final RowMapper turnMapper; + + static String renameTarget(String tenant, String session) { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + "SESSION", tenant, session); + } + + static final String SESSION_OPERATION_KINDS = + "('CLOSE', 'ARCHIVE', 'DELETE', 'ACTION_RESPONSE', 'CWD_CHANGE', 'TASK_CANCEL', 'CONTEXT_COMPRESSION')"; + + static String sessionOperationTarget(String sessionId) { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(sessionId); + } + + static String sessionOperationKey(String tenantId, String sessionId, OperationKind kind, String operationId) { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.OperationKind.valueOf(kind.name()), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), sessionOperationTarget(sessionId), operationId); + } + + long lifecycleDatabaseTime() { + return jdbc.queryForObject( + "SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", + (row, index) -> + Math.addExact( + Math.multiplyExact(row.getLong(1), 1000), row.getLong(2) / 1000)); + } + + Optional findSession(String tenantId, + String sessionId) { + List rows = + jdbc.query( + SESSION_WITH_STREAM + " WHERE ma_session.session_kind = 'PUBLIC' AND" + + " ma_session.tenant_id = ? AND ma_session.session_id = ?", + sessionMapper, + tenantId, + sessionId); + return rows.stream().findFirst(); + } + + Optional findTurn(String tenantId, String sessionId, + String turnId) { + List rows = jdbc.query( + "SELECT " + ManagedSchemaProjection.TURN.columns() + " FROM ma_turn WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ?", + turnMapper, tenantId, sessionId, turnId); + return rows.stream().findFirst(); + } + + Map> readPermissionRules(String json) { + return json == null ? null : harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(readMap(json))); + } + + SessionRecord requireSession(String tenantId, String sessionId) { + return findSession(tenantId, sessionId).orElseThrow(() -> + new ManagedAgentException(404, "session_not_found", + "The Session was not found.")); + } + + void requireLegacyMutation(String tenantId, String sessionId) { + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); + } + + SessionRecord requireLegacySessionForUpdate(String tenantId, String sessionId) { + return requireSessionForUpdate(tenantId, sessionId, true); + } + + SessionRecord requireSessionForUpdate(String tenantId, + String sessionId) { + return requireSessionForUpdate(tenantId, sessionId, false); + } + + SessionRecord requireSessionForUpdate( + String tenantId, String sessionId, boolean legacyMutation) { + try { + return jdbc.queryForObject( + "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + + " FROM ma_session WHERE ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id =" + + " ? AND ma_session.session_id = ? FOR UPDATE", + (rows, rowNumber) -> { + if (legacyMutation) { + ManagedLegacySessionGuard.requireLegacyMutation(rows.getBoolean("csi_guard")); + } + // H2 can retain pre-wait outer-join values after locking the Session. + // Read its stream only after acquiring that lock. + List streams = jdbc.query( + "SELECT last_sequence, replay_floor_sequence FROM ma_event_stream" + + " WHERE tenant_id = ? AND session_id = ? AND stream_kind =" + + " 'SESSION' AND stream_id = X'' FOR UPDATE", + (stream, index) -> new StreamCounters(stream.getLong("last_sequence"), + stream.getLong("replay_floor_sequence")), + tenantId, sessionId); + if (streams.isEmpty()) { + throw new IllegalStateException("Session stream is unavailable."); + } + return readSession(rows, streams.getFirst()); + }, + tenantId, + sessionId); + } catch (EmptyResultDataAccessException error) { + throw new ManagedAgentException(404, + "session_not_found", "The Session was not found."); + } + } + + TurnRecord requireTurn(String tenantId, String sessionId, + String turnId) { + return findTurn(tenantId, sessionId, turnId).orElseThrow(() -> + new ManagedAgentException(404, "turn_not_found", + "The Turn was not found.")); + } + + TurnRecord requireTurnForUpdate(String tenantId, + String sessionId, String turnId) { + try { + return jdbc.queryForObject("SELECT " + ManagedSchemaProjection.TURN.columns() + " FROM ma_turn" + + " WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? FOR UPDATE", + turnMapper, tenantId, sessionId, turnId); + } catch (EmptyResultDataAccessException error) { + throw new ManagedAgentException(404, "turn_not_found", + "The Turn was not found."); + } + } + + String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException error) { + throw new IllegalArgumentException("Value is not valid JSON", + error); + } + } + + List> readInput(String value) { + try { + return objectMapper.readValue(value, INPUT_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored input is invalid", error); + } + } + + Map readMap(String value) { + try { + return objectMapper.readValue(value, MAP_TYPE); + } catch (JsonProcessingException error) { + throw new IllegalStateException("Stored event is invalid", error); + } + } + + static ContextBinding readBinding(java.sql.ResultSet result) + throws java.sql.SQLException { + String workspaceId = result.getString("workspace_id"); + if (workspaceId == null) { + return null; + } + String configRef = result.getString("workspace_config_ref"); + String policyRef = result.getString("workspace_policy_ref"); + String contextConfigRef = result.getString("context_config_ref"); + if (!ManagedWorkspaceRegistry.descriptorRef(configRef, policyRef) + .equals(contextConfigRef)) { + throw new IllegalStateException( + "Persisted Workspace configuration descriptor changed for session " + + result.getString("session_id") + " of tenant " + + result.getString("tenant_id")); + } + return new ContextBinding(result.getString("tenant_id"), workspaceId, + result.getLong("workspace_generation"), + result.getString("workspace_storage_id"), + result.getString("cwd_relative"), contextConfigRef, + result.getLong("context_revision")); + } + + static Long nullableLong(java.sql.ResultSet result, String name) + throws java.sql.SQLException { + long value = result.getLong(name); + return result.wasNull() ? null : value; + } + + // The cwd columns arrive with V46; an operation read against an + // additive-upgrade schema that predates them must treat the columns as + // absent instead of erroring the whole query. + static String additiveString(java.sql.ResultSet result, + String name) throws java.sql.SQLException { + return hasColumn(result, name) ? result.getString(name) : null; + } + + static Long additiveLong(java.sql.ResultSet result, String name) + throws java.sql.SQLException { + return hasColumn(result, name) ? nullableLong(result, name) : null; + } + + static int additiveInt(java.sql.ResultSet result, String name) + throws java.sql.SQLException { + return hasColumn(result, name) ? result.getInt(name) : 0; + } + + static boolean hasColumn(java.sql.ResultSet result, String name) + throws java.sql.SQLException { + var meta = result.getMetaData(); + for (int column = 1; column <= meta.getColumnCount(); column++) { + if (name.equalsIgnoreCase(meta.getColumnLabel(column))) { + return true; + } + } + return false; + } + + static String publicId(String prefix) { + return prefix + "_" + UUID.randomUUID().toString() + .replace("-", ""); + } + + record StreamCounters(long lastSequence, long floorSequence) {} + + static String placeholders(int count) { + return String.join(", ", Collections.nCopies(count, "?")); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentTurnExecution.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentTurnExecution.java new file mode 100644 index 00000000000..a94cf1ea7ac --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/AgentTurnExecution.java @@ -0,0 +1,821 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore; +import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.managedagent.model.StoreModels.DispatchTarget; +import com.alibaba.qwen.code.managedagent.model.StoreModels.EventRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.HarnessEvent; +import com.alibaba.qwen.code.managedagent.model.StoreModels.ProjectedEvent; +import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; +import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.time.Clock; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class AgentTurnExecution { + private final AgentEventJournal eventJournal; + private final AgentMessageProjection messageProjection; + private final AgentSessionCommands sessionCommands; + private final AgentStoreRows storeRows; + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final Clock clock; + + AgentTurnExecution(AgentEventJournal eventJournal, + AgentMessageProjection messageProjection, + AgentSessionCommands sessionCommands, + AgentStoreRows storeRows, + JdbcTemplate jdbc, + ObjectMapper objectMapper, + Clock clock) { + this.eventJournal = eventJournal; + this.messageProjection = messageProjection; + this.sessionCommands = sessionCommands; + this.storeRows = storeRows; + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.clock = clock; + } + + List findDispatchable(long now, int limit) { + // Workflow/Runtime parks have separate settlement paths. A due + // Harness park enters only the coordinator's read-only observation + // branch; cancellation still uses the normal dispatch lane. + return jdbc.query( + "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR error_code IS NULL OR error_code NOT IN ('" + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "', '" + AgentStateStore.RUNTIME_RECOVERY_BLOCKED + "')) AND (status = 'CANCELLING' OR NOT EXISTS (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id AND w.session_id = ma_turn.session_id AND w.state <> 'READY')) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY gmt_modified ASC LIMIT ?", + (result, row) -> new DispatchTarget( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("turn_id")), now, now, limit); + } + + List findRuntimeRecoveryParked(long now, int limit) { + // D7-2: a Turn whose parked Runtime execution still cannot be proved + // either way waits for its recheck, never for a dispatch. A cancel + // is not scanned here: the normal sweep owns that lane. + return jdbc.query( + "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING') AND error_code = '" + + AgentStateStore.RUNTIME_RECOVERY_BLOCKED + + "' AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY retry_after ASC, gmt_modified ASC LIMIT ?", + (result, row) -> new DispatchTarget( + result.getString("tenant_id"), + result.getString("session_id"), + result.getString("turn_id")), now, now, limit); + } + + Optional claimTurn(String tenantId, String sessionId, + String turnId, String owner, Duration leaseDuration) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + int updated = jdbc.update("UPDATE ma_turn SET" + + " dispatch_owner = ?, dispatch_lease_until = ?," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ? AND turn_id = ?" + + " AND status IN ('ACCEPTED', 'RUNNING'," + + " 'CANCELLING') AND (dispatch_lease_until IS NULL" + + " OR dispatch_lease_until < ?) AND (retry_after IS" + + " NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR NOT EXISTS" + + " (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id" + + " AND w.session_id = ma_turn.session_id AND w.state <> 'READY'))", + owner, now + leaseDuration.toMillis(), now, tenantId, + sessionId, turnId, now, now); + return updated == 0 ? Optional.empty() + : storeRows.findTurn(tenantId, sessionId, turnId); + } + + boolean renewTurn(String tenantId, String sessionId, + String turnId, String owner, Duration leaseDuration) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + return jdbc.update("UPDATE ma_turn SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", dispatch_lease_until = ?, version = version + 1 WHERE tenant_id = ? AND session_id = ? AND turn_id = ? AND dispatch_owner = ? AND status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND dispatch_lease_until >= ?", + now + leaseDuration.toMillis(), tenantId, sessionId, turnId, + owner, now) == 1; + } + + void releaseTurnLease(String tenantId, String sessionId, + String turnId, String owner) { + storeRows.requireLegacyMutation(tenantId, sessionId); + jdbc.update("UPDATE ma_turn SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version = version +" + + " 1 WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND dispatch_owner = ? AND status IN" + + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", + tenantId, sessionId, turnId, owner); + } + + boolean markTurnRecoveryBlocked(String tenantId, + String sessionId, String turnId, String owner, + String errorCode, String errorMessage) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + Long retryAfter = AgentStateStore.HARNESS_RECOVERY_BLOCKED.equals(errorCode) + ? now + 15_000L : null; + // A sampled Harness block can lift. Retain the unsettled outcome + // and back off its read-only recheck without consuming retry budget. + return jdbc.update("UPDATE ma_turn SET error_code = ?," + + " error_message = ?, retry_after = ?, dispatch_owner = NULL," + + " dispatch_lease_until = NULL, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until >= ?" + + " AND status IN ('ACCEPTED', 'RUNNING'," + + " 'CANCELLING') AND (? <> ? OR status <> 'CANCELLING')", + errorCode, errorMessage, retryAfter, now, tenantId, sessionId, turnId, + owner, now, errorCode, AgentStateStore.HARNESS_RECOVERY_BLOCKED) == 1; + } + + boolean markTurnFailureRecovery(String tenantId, String sessionId, + String turnId, String owner, String message) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + String diagnostic = AgentStateStore.RUNTIME_RECOVERY_BLOCKED.equals(turn.errorCode()) + && turn.errorMessage() != null ? turn.errorMessage() : message; + return jdbc.update("UPDATE ma_turn SET error_code = ?, error_message = ?," + + " gmt_modified = ?, version = version + 1 WHERE tenant_id = ?" + + " AND session_id = ? AND turn_id = ? AND dispatch_owner = ?" + + " AND dispatch_lease_until >= ? AND submission_attempted = TRUE" + + " AND status IN ('ACCEPTED', 'RUNNING')", + AgentStateStore.RUNTIME_RECOVERY_BLOCKED, diagnostic, now, + tenantId, sessionId, turnId, owner, now) == 1; + } + + boolean requestRecoveryRecheck(String tenantId, String sessionId, + String turnId) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + long now = clock.millis(); + return jdbc.update("UPDATE ma_turn SET retry_after = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND session_id = ?" + + " AND turn_id = ? AND (retry_after IS NULL OR retry_after > ?)" + + " AND (status = 'CANCELLING' OR status IN ('ACCEPTED', 'RUNNING')" + + " AND error_code = ?)", + now, now, tenantId, sessionId, turnId, now, + AgentStateStore.RUNTIME_RECOVERY_BLOCKED) == 1 + || jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id = ?" + + " AND session_id = ? AND turn_id = ? AND (status = 'CANCELLING'" + + " OR status IN ('ACCEPTED', 'RUNNING') AND error_code = ?)", + Long.class, tenantId, sessionId, turnId, + AgentStateStore.RUNTIME_RECOVERY_BLOCKED) == 1; + } + + boolean parkTurnForRuntimeRecovery(String tenantId, + String sessionId, String turnId, String owner, String message, + long retryAfter) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + // D7-2: the park keeps the Turn active so its input stays unsettled. + // Only a proven cancel may settle it; the recheck drives that cancel. + return jdbc.update("UPDATE ma_turn SET error_code = ?," + + " error_message = ?, retry_after = ?," + + " dispatch_owner = NULL, dispatch_lease_until = NULL," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until >= ? AND" + + " status IN ('ACCEPTED', 'RUNNING')", + AgentStateStore.RUNTIME_RECOVERY_BLOCKED, message, retryAfter, + now, tenantId, sessionId, turnId, owner, now) == 1; + } + + void deferTurnRetry(String tenantId, String sessionId, + String turnId, String owner, long retryAfter) { + scheduleTurnRetry(tenantId, sessionId, turnId, owner, retryAfter, + false); + } + + void scheduleTurnRetry(String tenantId, String sessionId, + String turnId, String owner, long retryAfter, + boolean budgetExempt) { + storeRows.requireLegacyMutation(tenantId, sessionId); + jdbc.update("UPDATE ma_turn SET retry_count = retry_count" + + " + 1, consecutive_failures = consecutive_failures" + + " + 1, budget_exempt_failures =" + + " budget_exempt_failures + ?, retry_after = ?," + + " dispatch_owner = NULL, dispatch_lease_until =" + + " NULL, gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ? AND turn_id = ?" + + " AND dispatch_owner = ? AND status IN ('ACCEPTED'," + + " 'RUNNING', 'CANCELLING')", + budgetExempt ? 1 : 0, retryAfter, clock.millis(), tenantId, + sessionId, turnId, owner); + } + + boolean bindHarness(String tenantId, String sessionId, + String turnId, String owner, String harnessBootId) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status())) { + return false; + } + if (harnessBootId.equals(session.harnessBootId())) { + return true; + } + if (session.harnessBootId() != null + && (turn.submissionAttempted() + || turn.harnessEventEpoch() != null)) { + return false; + } + jdbc.update("UPDATE ma_session SET harness_boot_id = ?," + + " gmt_modified = ?, version = version + 1 WHERE" + + " tenant_id = ? AND session_id = ?", + harnessBootId, now, tenantId, sessionId); + return true; + } + + boolean bindRecoveredHarness(String tenantId, String sessionId, + String turnId, String owner, String expectedHarnessBootId, + String harnessBootId) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status()) + || !turn.submissionAttempted() + || turn.harnessEventEpoch() == null) { + return false; + } + if (harnessBootId.equals(session.harnessBootId())) { + return true; + } + if (!Objects.equals(expectedHarnessBootId, + session.harnessBootId())) { + return false; + } + int updated = jdbc.update("UPDATE ma_session SET" + + " harness_boot_id = ?, gmt_modified = ?, version =" + + " version + 1 WHERE tenant_id = ? AND session_id" + + " = ? AND harness_boot_id = ?", + harnessBootId, now, tenantId, sessionId, + expectedHarnessBootId); + return updated == 1; + } + + void markSubmissionAttempted(String tenantId, String sessionId, + String turnId, String owner) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + int updated = jdbc.update("UPDATE ma_turn SET" + + " submission_attempted = TRUE, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ? AND harness_event_epoch IS NULL", + now, tenantId, sessionId, turnId, owner, now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + } + + /** + * G3: back out a submission mark whose attempt carries no event epoch, so a new Harness + * generation may bind and re-submit. A null epoch only proves the admission REPLY never landed: + * a settled old-generation admission replays idempotently on the journal's commandId when the + * Turn re-submits, while one still unsettled answers the resubmit with the coded + * duplicate-admission 409 — the caller adopts the attach's epoch and streams instead of + * withdrawing again (R10-4). Refuses under the same lease guards as the mark. + */ + boolean withdrawSubmissionAttempted(String tenantId, + String sessionId, String turnId, String owner) { + storeRows.requireLegacyMutation(tenantId, sessionId); + long now = clock.millis(); + int updated = jdbc.update("UPDATE ma_turn SET" + + " submission_attempted = FALSE, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ? AND submission_attempted = TRUE AND" + + " harness_event_epoch IS NULL AND status IN" + + " ('ACCEPTED','RUNNING','CANCELLING')", + now, tenantId, sessionId, turnId, owner, now); + return updated == 1; + } + + void recordAdmission(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastEventId) { + // Lock the session row before the turn row: every writer must take + // the two in this order or InnoDB deadlocks against the + // session-first paths (e.g. insertCancelCommand). + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + long now = clock.millis(); + // The admission proves coordination made progress, so the retry + // budget restarts: it counts consecutive failures without progress. + // retry_count is not reset — it paces the backoff, which must keep + // growing across a crash loop that keeps making progress (review + // round 8, R8-3). + int updated = jdbc.update("UPDATE ma_turn SET status =" + + " CASE WHEN status = 'CANCELLING' THEN status ELSE" + + " 'RUNNING' END, harness_event_epoch = ?," + + " harness_last_event_id = ?, consecutive_failures = 0," + + " budget_exempt_failures = 0," + + " gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastEventId, now, tenantId, sessionId, turnId, + owner, now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + int sessionUpdated = jdbc.update("UPDATE ma_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ?", + eventEpoch, lastEventId, now, tenantId, sessionId); + if (sessionUpdated != 1) { + throw new IllegalStateException("Session disappeared"); + } + if (!eventJournal.hasEventType(tenantId, sessionId, turnId, "turn.started")) { + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.started", + Map.of("turnId", turnId), false, null, now); + } + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + } + + void recordRecoveryAdmission(String tenantId, String sessionId, + String turnId, String owner, String expectedTurnEventEpoch, + String expectedSessionEventEpoch, String eventEpoch, + long lastEventId) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status())) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (eventEpoch.equals(turn.harnessEventEpoch()) + && turn.harnessLastEventId() != null + && turn.harnessLastEventId() >= lastEventId) { + return; + } + if (!turn.submissionAttempted() + || !Objects.equals(expectedTurnEventEpoch, + turn.harnessEventEpoch()) + || !Objects.equals(expectedSessionEventEpoch, + session.harnessEventEpoch())) { + throw new IllegalStateException( + "Hosted Harness recovery epoch changed"); + } + // The recovery admission proves coordination made progress, so the + // retry budget restarts: it counts consecutive failures without + // progress. retry_count is not reset — it paces the backoff (review + // round 8, R8-3). + int updated = jdbc.update("UPDATE ma_turn SET status =" + + " CASE WHEN status = 'CANCELLING' THEN status ELSE" + + " 'RUNNING' END, harness_event_epoch = ?," + + " harness_last_event_id = ?, consecutive_failures = 0," + + " budget_exempt_failures = 0," + + " gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ? AND (harness_event_epoch = ? OR" + + " (harness_event_epoch IS NULL AND ? IS NULL))", + eventEpoch, lastEventId, now, tenantId, sessionId, turnId, + owner, now, expectedTurnEventEpoch, expectedTurnEventEpoch); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + int sessionUpdated = jdbc.update("UPDATE ma_session SET harness_event_epoch =" + + " ?, harness_last_event_id = ?, gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND (harness_event_epoch = ? OR" + + " (harness_event_epoch IS NULL AND ? IS NULL))", + eventEpoch, lastEventId, now, tenantId, sessionId, + expectedSessionEventEpoch, expectedSessionEventEpoch); + if (sessionUpdated != 1) { + throw new IllegalStateException( + "Hosted Harness recovery epoch changed"); + } + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + } + + void retractContinuationOutput(String tenantId, String sessionId, + String turnId, String owner, String harnessBootId, + String eventEpoch) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now + || !AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status())) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (harnessBootId.isBlank() || eventEpoch.isBlank()) { + throw new IllegalArgumentException( + "continuation owner is missing"); + } + String sourcePrefix = harnessBootId + ":" + eventEpoch + ":"; + String reconciliationKey = "reconcile:" + sourcePrefix + turnId; + if (eventJournal.hasSourceEvent(tenantId, sessionId, reconciliationKey)) { + return; + } + jdbc.queryForObject("SELECT covered_sequence FROM" + + " ma_event_stream WHERE tenant_id" + + " = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''" + + " FOR UPDATE", + Long.class, tenantId, sessionId); + List deltas = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" + + " = X'' AND tenant_id = ? AND session_id = ? AND turn_id = ? AND" + + " event_type IN ('item.output_text.delta', 'item.reasoning.delta')" + + " ORDER BY sequence_id ASC", + eventJournal.eventMapper, + tenantId, + sessionId, + turnId); + long firstRetracted = Long.MAX_VALUE; + for (EventRecord event : deltas) { + if (event.sourceKey() == null + || !event.sourceKey().startsWith(sourcePrefix)) { + continue; + } + Map data = new LinkedHashMap<>(event.data()); + data.put("text", ""); + jdbc.update( + "UPDATE ma_public_event SET data_json = ? WHERE tenant_id = ? AND session_id =" + + " ? AND stream_kind = 'SESSION' AND stream_id = X'' AND sequence_id = ?", + storeRows.writeJson(data), + tenantId, + sessionId, + event.sequence()); + firstRetracted = Math.min(firstRetracted, event.sequence()); + } + if (firstRetracted != Long.MAX_VALUE) { + eventJournal.reassignIdentity(tenantId, sessionId, firstRetracted); + } + messageProjection.rebuildTurnText(tenantId, sessionId, turnId, now); + eventJournal.appendEvent(tenantId, sessionId, turnId, "stream.reconciled", Map.of(), + false, reconciliationKey, now); + } + + /** + * In-band sibling of {@link #retractContinuationOutput} (#13319): a restarted model attempt on + * the live Harness retracts the published prefix of the message it replaces. The range is keyed + * by the journal sequence of the message's first delta, which the Harness reports as {@code + * fromSourceId}; only deltas of the current in-flight message carry source ids at or after it, + * so earlier committed rounds stay. The cursor advances past the retraction event in the same + * transaction whether or not the retraction applied, so a crash cannot re-deliver it. + */ + void retractHarnessTurnOutput(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long fromSourceId, long retractionSourceId) { + retractHarnessTurnOutput(tenantId, sessionId, turnId, owner, eventEpoch, + fromSourceId, retractionSourceId, null, null, null); + } + + void retractHarnessTurnOutput(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long fromSourceId, long retractionSourceId, String sourceBootId, + String sourceEventEpoch, Long throughSourceId) { + storeRows.requireLegacyMutation(tenantId, sessionId); + boolean bounded = throughSourceId != null; + if (bounded && (sourceBootId == null || sourceEventEpoch == null + || fromSourceId < 1 || throughSourceId < fromSourceId || throughSourceId >= retractionSourceId)) { + throw new IllegalArgumentException("Invalid bounded Hosted retraction"); + } + SessionRecord session = storeRows.requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + long now = clock.millis(); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < now) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (!eventEpoch.equals(turn.harnessEventEpoch())) { + throw new IllegalStateException( + "Hosted Harness event epoch changed"); + } + String sourcePrefix = (bounded ? sourceBootId : session.harnessBootId()) + ":" + + (bounded ? sourceEventEpoch : eventEpoch) + ":"; + String reconciliationKey = "reconcile:inband:" + sourcePrefix + turnId + + ":" + fromSourceId + (bounded ? ":" + throughSourceId : ""); + if (!eventJournal.hasSourceEvent(tenantId, sessionId, reconciliationKey)) { + jdbc.queryForObject( + "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" + + " UPDATE", + Long.class, + tenantId, + sessionId); + List deltas = + jdbc.query( + "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" + + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND event_type IN ('item.output_text.delta'," + + " 'item.reasoning.delta') ORDER BY sequence_id ASC", + eventJournal.eventMapper, + tenantId, + sessionId, + turnId); + long firstRetracted = Long.MAX_VALUE; + for (EventRecord event : deltas) { + if (event.sourceKey() == null + || (!bounded && !event.sourceKey().startsWith(sourcePrefix)) + || sourceIdOf(event.sourceKey()) < fromSourceId + || (bounded && sourceIdOf(event.sourceKey()) > throughSourceId)) { + continue; + } + Map data = new LinkedHashMap<>(event.data()); + data.put("text", ""); + jdbc.update( + "UPDATE ma_public_event SET data_json = ? WHERE tenant_id = ? AND" + + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' AND" + + " sequence_id = ?", + storeRows.writeJson(data), + tenantId, + sessionId, + event.sequence()); + firstRetracted = Math.min(firstRetracted, event.sequence()); + } + if (firstRetracted != Long.MAX_VALUE) { + eventJournal.reassignIdentity(tenantId, sessionId, firstRetracted); + } + messageProjection.rebuildTurnText(tenantId, sessionId, turnId, now); + eventJournal.appendEvent( + tenantId, + sessionId, + turnId, + "stream.reconciled", + Map.of(), + false, + reconciliationKey, + now); + } + int updated = updateHarnessCursor(tenantId, sessionId, turnId, owner, + eventEpoch, retractionSourceId, now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + } + + // A Harness source key ends in the journal sequence of its journal event; + // the boot and epoch prefixes are colon-free by construction but the + // suffix is what the range compares. + static long sourceIdOf(String sourceKey) { + try { + return Long.parseLong( + sourceKey.substring(sourceKey.lastIndexOf(':') + 1)); + } catch (NumberFormatException error) { + return -1; + } + } + + void recordHarnessEvents(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + List events) { + storeRows.requireLegacyMutation(tenantId, sessionId); + if (events.isEmpty()) { + return; + } + SessionRecord session = storeRows.requireSessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurnForUpdate(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || turn.dispatchLeaseUntil() == null + || turn.dispatchLeaseUntil() < clock.millis()) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + if (!eventEpoch.equals(turn.harnessEventEpoch())) { + throw new IllegalStateException( + "Hosted Harness event epoch changed"); + } + long lastSourceId = turn.harnessLastEventId() == null ? 0 + : turn.harnessLastEventId(); + List accepted = new ArrayList<>(); + for (HarnessEvent event : events) { + if (event.sourceId() > lastSourceId) { + accepted.add(event); + lastSourceId = event.sourceId(); + } + } + if (accepted.isEmpty()) { + return; + } + long now = clock.millis(); + HarnessEvent terminal = null; + for (int index = 0; index < accepted.size(); index++) { + HarnessEvent event = accepted.get(index); + if (event.projection() != null + && event.projection().terminal()) { + if (terminal != null || index != accepted.size() - 1) { + throw new IllegalArgumentException( + "Terminal Harness event must end the batch"); + } + terminal = event; + } + } + int updated = terminal == null + ? updateHarnessCursor(tenantId, sessionId, turnId, owner, + eventEpoch, lastSourceId, now) + : completeHarnessTurn(tenantId, sessionId, turnId, owner, + eventEpoch, lastSourceId, terminal.projection(), now); + if (updated != 1) { + throw new IllegalStateException("Turn dispatch lease was lost"); + } + List projected = accepted.stream() + .filter(event -> event.projection() != null).toList(); + List committed = eventJournal.appendEvents(tenantId, sessionId, + turnId, eventEpoch, lastSourceId, session.lastSequence(), + projected, now); + if (terminal != null) { + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + } + eventJournal.publishAfterCommit(committed); + } + + int updateHarnessCursor(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastSourceId, long now) { + // Journaling new events proves coordination made progress, so the + // retry budget restarts: it counts consecutive failures without + // progress. retry_count is not reset — it paces the backoff (review + // round 8, R8-3). + return jdbc.update("UPDATE ma_turn SET" + + " harness_event_epoch = ?," + + " harness_last_event_id = ?, consecutive_failures = 0," + + " budget_exempt_failures = 0," + + " gmt_modified = ?," + + " version = version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastSourceId, now, tenantId, sessionId, turnId, + owner, now); + } + + int completeHarnessTurn(String tenantId, String sessionId, + String turnId, String owner, String eventEpoch, + long lastSourceId, ProjectedEvent terminal, long now) { + return jdbc.update("UPDATE ma_turn SET" + + " harness_event_epoch = ?," + + " harness_last_event_id = ?, status = ?," + + " error_code = ?, error_message = ?," + + " completed_at = ?, gmt_modified = ?," + + " dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until" + + " >= ?", + eventEpoch, lastSourceId, terminal.terminalStatus(), + terminal.errorCode(), terminal.errorMessage(), now, now, + tenantId, sessionId, turnId, owner, now); + } + + boolean cancelUnadmittedTurn(String tenantId, String sessionId, + String turnId, String owner) { + SessionRecord session = storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurn(tenantId, sessionId, turnId); + if (!"CANCELLING".equals(turn.status()) + || !owner.equals(turn.dispatchOwner()) + || turn.harnessEventEpoch() != null + || !turn.submissionAttempted()) { + return false; + } + var heads = WorkspaceOperations.rows(jdbc, + "SELECT tenant_id, session_id, state, writer_lease_until FROM" + + " ma_session_head WHERE tenant_key = ? AND harness_key = ?" + + " AND head_kind = 'MANAGED_SESSION' FOR UPDATE", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId)); + long now = storeRows.lifecycleDatabaseTime(); + if (heads.size() != 1 || !tenantId.equals(heads.getFirst().get("tenant_id")) + || !sessionId.equals(heads.getFirst().get("session_id")) + || !("SEALED".equals(heads.getFirst().get("state")) + || "ACTIVE".equals(heads.getFirst().get("state")) + && heads.getFirst().get("writer_lease_until") instanceof Number until + && until.longValue() <= now)) { + return false; + } + // The locked, fenced writer cannot admit a late HTTP request. Even an + // idle journal may contain this prompt's completed admission: replay + // its real result instead of replacing it with cancellation. + java.util.Set accepted; + try { + accepted = WorkspaceLifecycleStore.requireIdleJournal(jdbc, objectMapper, + tenantId, sessionId, false, + WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) == 2); + } catch (ManagedAgentException unverified) { + return false; + } + if (accepted.contains(turn.promptId()) || !withdrawSubmissionAttempted(tenantId, sessionId, turnId, owner)) { + return false; + } + cancelBeforeAdmission(tenantId, sessionId, turnId, owner); + return "CANCELLED".equals(storeRows.requireTurn(tenantId, sessionId, turnId).status()); + } + + void cancelBeforeAdmission(String tenantId, String sessionId, + String turnId, String owner) { + // Session-row lock first; see recordAdmission. + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurn(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || turn.harnessEventEpoch() != null + || turn.submissionAttempted()) { + return; + } + long now = clock.millis(); + int updated = jdbc.update("UPDATE ma_turn SET status =" + + " 'CANCELLED'," + + " completed_at = ?, gmt_modified = ?," + + " dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version =" + + " version + 1 WHERE tenant_id = ? AND" + + " session_id = ? AND turn_id = ? AND" + + " dispatch_owner = ? AND dispatch_lease_until >= ?" + + " AND submission_attempted = FALSE", + now, now, tenantId, sessionId, turnId, owner, now); + if (updated != 1) { + return; + } + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.cancelled", + Map.of("turnId", turnId), true, null, now); + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + } + + void failTurn(String tenantId, String sessionId, String turnId, + String owner, String code, String message) { + settleFailedTurn(tenantId, sessionId, turnId, owner, code, message, true); + } + + void failWorktreeInitialTurns(String tenantId, String sessionId, String code) { + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + if (jdbc.queryForList("SELECT 1 FROM ma_session_worktree WHERE tenant_id = ? AND session_id = ?" + + " AND state IN ('FAILED', 'BLOCKED') FOR UPDATE", Integer.class, tenantId, sessionId).isEmpty()) { + return; + } + long now = clock.millis(); + String message = "The Session worktree could not be prepared."; + List turns = jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id = ? AND session_id = ?" + + " AND status = 'ACCEPTED' AND submission_attempted = FALSE FOR UPDATE", String.class, tenantId, sessionId); + for (String turnId : turns) { + jdbc.update("UPDATE ma_turn SET status = 'FAILED', error_code = ?, error_message = ?, completed_at = ?," + + " gmt_modified = ?, dispatch_owner = NULL, dispatch_lease_until = NULL, version = version + 1" + + " WHERE tenant_id = ? AND session_id = ? AND turn_id = ? AND status = 'ACCEPTED'" + + " AND submission_attempted = FALSE", code, message, now, now, tenantId, sessionId, turnId); + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.failed", Map.of("code", code, "message", message), true, null, now); + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + } + } + + boolean failRecoveryBlockedTurn(String tenantId, String sessionId, + String turnId, String owner, String message) { + return settleFailedTurn(tenantId, sessionId, turnId, owner, + "managed_runtime_recovery_blocked", message, false); + } + + boolean settleFailedTurn(String tenantId, String sessionId, String turnId, + String owner, String code, String message, boolean allowCancelling) { + // Session-row lock first; see recordAdmission. + storeRows.requireLegacySessionForUpdate(tenantId, sessionId); + TurnRecord turn = storeRows.requireTurn(tenantId, sessionId, turnId); + if (!owner.equals(turn.dispatchOwner()) + || !AgentStoreRows.ACTIVE_TURN_STATES.contains(turn.status()) + || !allowCancelling && "CANCELLING".equals(turn.status())) { + return false; + } + long now = clock.millis(); + int updated = jdbc.update("UPDATE ma_turn SET status =" + + " 'FAILED'," + + " error_code = ?, error_message = ?, completed_at =" + + " ?, gmt_modified = ?, dispatch_owner = NULL," + + " dispatch_lease_until = NULL, version = version +" + + " 1 WHERE tenant_id = ? AND session_id = ? AND" + + " turn_id = ? AND dispatch_owner = ? AND status IN" + + (allowCancelling ? " ('ACCEPTED', 'RUNNING', 'CANCELLING') AND" + : " ('ACCEPTED', 'RUNNING') AND") + + " dispatch_lease_until >= ?", + code, message, now, now, tenantId, sessionId, turnId, owner, + now); + if (updated != 1) { + return false; + } + eventJournal.appendEvent(tenantId, sessionId, turnId, "turn.failed", + Map.of("code", code, "message", message), true, null, now); + sessionCommands.settleTurnCommands(tenantId, sessionId, turnId, now); + return true; + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java new file mode 100644 index 00000000000..22714b2c4b2 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalCommit.java @@ -0,0 +1,875 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHostedRecoveryRecords; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitReceipt; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ToolPublicationContract; +import com.alibaba.qwen.code.managedagent.contracts.persistence.WorkspaceCsiApplicationJson; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.ManagedCheckpointHistory; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +final class JournalCommit { + private final JournalFencing fencing; + private final JournalLifecycleAuthorization lifecycleAuthorization; + private final JournalResourceWrites resourceWrites; + private final JournalStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion; + private final JdbcTemplate jdbc; + private final ManagedToolResultStore toolResults; + private final JournalEventTransport journalEventTransport; + private final ManagedExtensionRecordStore extensionRecords; + private final ManagedActionStore actions; + + JournalCommit(JournalFencing fencing, + JournalLifecycleAuthorization lifecycleAuthorization, + JournalResourceWrites resourceWrites, + JournalStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion, + JdbcTemplate jdbc, + ManagedToolResultStore toolResults, + JournalEventTransport journalEventTransport, + ManagedExtensionRecordStore extensionRecords, + ManagedActionStore actions) { + this.fencing = fencing; + this.lifecycleAuthorization = lifecycleAuthorization; + this.resourceWrites = resourceWrites; + this.storeRows = storeRows; + this.readerVersion = readerVersion; + this.jdbc = jdbc; + this.toolResults = toolResults; + this.journalEventTransport = journalEventTransport; + this.extensionRecords = extensionRecords; + this.actions = actions; + } + + CommitReceipt commit(String tenantId, String sessionId, + String writerToken, CommitTransactionRequest request) { + return commit(tenantId, sessionId, writerToken, request, null); + } + + CommitReceipt commit(String tenantId, String sessionId, String writerToken, + CommitTransactionRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + fencing.checkLifecycleWriter(tenantId, sessionId, authority); + // Ordinary record commits still hold the placement guard while they + // fly: a begin/lifecycle admission serializes with only that row, so + // without it an admission sails through mid-commit. The acquisition + // stays bounded — a held row degrades to a named failure within the + // hot-path budget rather than waiting out the caller (#13800). + JournalFencing.lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + ValidatedCommit validated = validateCommit(request); + HarnessPlacementStore.Placement placement = fencing.requirePlacement(tenantId, request.workspaceId(), + sessionId, writerToken, request.writerId(), false); + boolean promotes = ManagedHostedRecoveryRecords.requiresVersionTwo(validated.recordBytes(), request.resources()) + || validated.resources().stream().anyMatch(resource -> { + if (!"managed-action-options".equals(resource.resource().kind()) || resource.bytes() == null) { + return false; + } + JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); + return body != null && body.path("v").asInt() == 3; + }); + if (promotes) { + readerVersion.requireReadable(2); + } + var continuation = fencing.lockCsiContinuation(tenantId, sessionId); + var csiOriginal = continuation == null ? null : continuation.original(); + if (csiOriginal != null) { + csiOriginal.requireContinuation(); + } + JournalStoreRows.HeadRow csiHead = csiOriginal == null ? null : storeRows.requireHeadForUpdate(tenantId, sessionId); + JournalFencing.requireCsiWriter(csiOriginal, csiHead); + if (csiOriginal != null + && ("commitFileHistory".equals(request.operation()) + || !jdbc.query( + "SELECT journal_revision FROM ma_session_journal_tx WHERE" + + " tenant_id = ? AND session_id = ? AND operation =" + + " 'commitFileHistory' ORDER BY journal_revision LIMIT" + + " 1 FOR UPDATE", + (row, index) -> row.getLong(1), + tenantId, + sessionId) + .isEmpty())) { + fencing.requireCsiHistoryOwner(csiOriginal); + } + JournalStoreRows.TransactionRow existing = storeRows.findTransactionByCommand(tenantId, + sessionId, JournalStoreRows.commandKeyHash(request.operation(), + request.commandId())); + if (existing != null) { + JournalStoreRows.requireTransactionWorkspace(existing, request.workspaceId()); + if (csiOriginal != null) { + fencing.requireWriter( + csiHead, + request.writerId(), + request.writerGeneration(), + writerToken, + storeRows.databaseNow(), + true); + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + JdbcCsiActivationAdmission.requireReplay( + connection, + csiOriginal, + csiHead.journalRevision(), + csiHead.committedSequence(), + csiHead.lastCommitDigest(), + csiHead.writerId()); + return null; + }); + fencing.requireCsiCurrentTime(continuation); + } + // A duplicate is fenced like a first attempt: before #13800 the + // v2 claim check ran ahead of this lookup. + if (authority == null) { + fencing.requireNoLifecycleClaim(tenantId, sessionId); + } + return replay(existing, request, validated.recordDigest(), + writerToken); + } + fencing.requirePhysicalSessionOpen(tenantId, request.workspaceId(), sessionId); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if (authority == null) { + fencing.requireNoLifecycleClaim(tenantId, sessionId); + } + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, true); + validateHead(head, request); + long revision = JournalStoreRows.increment(head.journalRevision(), + "journal revision"); + String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + if (csiOriginal != null + && java.util.Set.of("commitFileHistory", "toolIntent", "commitCheckpoint") + .contains(request.operation())) { + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + JdbcCsiActivationAdmission.preflightCommit( + connection, + csiOriginal, + JournalStoreRows.CSI_JSON.valueToTree(request), + com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof + .records(validated.recordBytes()), + head.journalRevision(), + head.committedSequence(), + head.lastCommitDigest(), + head.writerId()); + return null; + }); + fencing.requireWriter( + head, + request.writerId(), + request.writerGeneration(), + writerToken, + storeRows.databaseNow(), + true); + } + resourceWrites.commitResources(scopeKey, tenantId, sessionId, request, revision, + now, validated.resources(), + () -> fencing.requireCsiCurrentTime(continuation)); + Set declaredResources = new HashSet<>(); + for (var resource : request.resources() == null ? List.of() : request.resources()) { + declaredResources.add(resource.resourceId()); + } + var resourceCache = new HashMap(); + var hostedReferences = new HashMap(); + java.util.function.Function reader = resourceId -> resourceCache.computeIfAbsent(resourceId, + id -> storeRows.storedResource(scopeKey, tenantId, request.workspaceId(), sessionId, id)); + ManagedHostedRecoveryRecords.HistoricalResources closureReader = new ManagedHostedRecoveryRecords.HistoricalResources() { + @Override + public StoredResource apply(String resourceId) { + if (!declaredResources.contains(resourceId)) { + throw JournalStoreRows.invalid("Hosted nested reference is absent from the transaction census."); + } + return reader.apply(resourceId); + } + + @Override + public void checkpointHistory(JsonNode ref) { + validateCheckpointHistory(ref, tenantId, request.workspaceId(), sessionId, + head.journalRevision(), declaredResources, reader); + } + }; + for (var resource : request.resources() == null ? List.of() : request.resources()) { + if (ManagedCheckpointHistory.KIND.equals(resource.kind())) { + ObjectNode ref = JournalStoreRows.CSI_JSON.valueToTree(resource); + ref.remove("bytesBase64"); + closureReader.checkpointHistory(ref); + } else if ("managed-checkpoint".equals(resource.kind())) { + validatePagedCheckpoint(closureReader.apply(resource.resourceId()), tenantId, request.workspaceId(), sessionId, closureReader); + } + if (ManagedHostedRecoveryRecords.KINDS.contains(resource.kind()) || "managed-action-options".equals(resource.kind())) { + ManagedHostedRecoveryRecords.validateResource(closureReader.apply(resource.resourceId()), tenantId, + request.workspaceId(), sessionId, closureReader, hostedReferences); + } + } + boolean wakeInput = false; + for (String line : + new String(validated.recordBytes(), StandardCharsets.UTF_8).split("\n")) { + JsonNode record = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(line); + if (record != null && record.has("managedSession")) { + JsonNode event = record.get("managedSession"); + wakeInput |= WakeRecoveryStore.isWakeInput(event); + ManagedHostedRecoveryRecords.validateEvent( + event.path("kind").asText(), + event.path("payload"), + tenantId, + request.workspaceId(), + sessionId, + closureReader, + hostedReferences); + if ("hosted.cleanup".equals(event.path("kind").asText())) { + HostedCleanupReferences.apply(jdbc, tenantId, request.workspaceId(), sessionId, + event.path("payload"), reader, revision, now.getTime()); + } + } + } + lifecycleAuthorization.authorizeNewLifecycleDispatch(tenantId, sessionId, authority, + validated.recordBytes(), reader); + if (authority == null && fencing.lifecycleProtocolFenced(tenantId, sessionId)) { + extensionRecords.requireLifecycleSettlement(tenantId, sessionId, validated.recordBytes(), + reader); + } + var applied = + csiOriginal == null + ? extensionRecords.apply(tenantId, request.workspaceId(), sessionId, + request.firstSequence(), request.eventCount(), + validated.recordBytes(), reader, now.getTime()) + : extensionRecords.applyNative( + tenantId, + request.workspaceId(), + sessionId, + request.firstSequence(), + request.eventCount(), + validated.recordBytes(), + reader, + now.getTime()); + if (csiOriginal != null) { + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + JdbcCsiActivationAdmission.acceptCommit( + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection), + csiOriginal, + JournalStoreRows.CSI_JSON.valueToTree(request), + applied.records(), + head.journalRevision(), + head.committedSequence(), + head.lastCommitDigest(), + head.writerId(), + now.toInstant().toEpochMilli(), + continuation.progress()); + return null; + }); + fencing.requireWriter( + head, + request.writerId(), + request.writerGeneration(), + writerToken, + storeRows.databaseNow(), + true); + } + var receiptEvents = applied.receipts(); + if (actions != null) { + actions.apply( + tenantId, + request.workspaceId(), + sessionId, request.firstSequence(), request.eventCount(), + validated.recordBytes(), + reader, hostedReferences); + } + fencing.requireCsiWriterCurrent( + continuation, head, request.writerId(), request.writerGeneration(), writerToken); + jdbc.update("INSERT INTO ma_session_journal_tx" + + " (tenant_id, workspace_id, session_id," + + " journal_revision, command_key_hash," + + " transaction_id, operation, command_id," + + " content_digest, first_sequence, last_sequence," + + " event_count, events_digest," + + " previous_commit_digest, commit_digest," + + " writer_generation, writer_id," + + " writer_token_hash, activation_epoch," + + " latest_checkpoint_resource_id," + + " record_encoding, record_bytes, byte_length," + + " record_digest, gmt_create) VALUES (?, ?, ?, ?," + + " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," + + " ?," + + " 'identity', ?, ?, ?, ?)", + tenantId, request.workspaceId(), sessionId, revision, + JournalStoreRows.commandKeyHash(request.operation(), request.commandId()), + request.transactionId(), request.operation(), + request.commandId(), request.contentDigest(), + request.firstSequence(), request.lastSequence(), + request.eventCount(), request.eventsDigest(), + request.previousCommitDigest(), request.commitDigest(), + request.writerGeneration(), request.writerId(), + JournalFencing.tokenHash(writerToken), request.activationEpoch(), + request.latestCheckpointResourceId(), + validated.recordBytes(), validated.recordBytes().length, + validated.recordDigest(), now.getTime()); + ActivationChange activation = activationChange( + applied.lastActivation()); + if (placement != null && "QUIESCING".equals(placement.state()) && activation != null + && "active".equals(activation.phase()) + && (!"active".equals(head.activationPhase()) || !Objects.equals(activation.id(), head.activationId()) + || !Objects.equals(activation.epoch(), head.activationEventEpoch()))) { + throw HarnessPlacementStore.blocked("harness_placement_quiescing"); + } + // Never certify a skewed activation projection as current. + Long activationHeadRevision = head.activationHeadRevision(); + if (activation != null || (activationHeadRevision != null + && activationHeadRevision == head.journalRevision())) { + activationHeadRevision = revision; + } + fencing.requireCsiWriterCurrent( + continuation, head, request.writerId(), request.writerGeneration(), writerToken); + jdbc.update("UPDATE ma_session_head SET" + + " journal_revision = ?, committed_sequence = ?," + + " wake_pending = wake_pending OR ?," + + " wake_head_revision = CASE WHEN wake_head_revision = ? THEN ? ELSE wake_head_revision END," + + " cleanup_head_revision = CASE WHEN cleanup_head_revision = ? THEN ? ELSE cleanup_head_revision END," + + " last_commit_digest = ?, activation_epoch = ?," + + " latest_checkpoint_resource_id = COALESCE(?," + + " latest_checkpoint_resource_id)," + + " activation_id = ?, activation_phase = ?," + + " activation_event_epoch = ?," + + " activation_expires_at = ?," + + " activation_head_revision = ?, storage_version = ?, gmt_modified = ?" + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" + + " AND session_id = ?", + revision, request.lastSequence(), + wakeInput, + head.journalRevision(), revision, head.journalRevision(), revision, request.commitDigest(), + request.activationEpoch(), + request.latestCheckpointResourceId(), + activation == null ? head.activationId() + : activation.id(), + activation == null ? head.activationPhase() + : activation.phase(), + activation == null ? head.activationEventEpoch() + : Long.valueOf(activation.epoch()), + activation == null ? head.activationExpiresAt() + : activation.expiresAt(), + activationHeadRevision, Math.max(head.storageVersion(), promotes ? 2 : 1), now.getTime(), JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); + if (toolResults != null && csiOriginal == null) { + toolResults.captureEvents(tenantId, request.workspaceId(), sessionId, revision, receiptEvents); + } + ManagedUserMessageStore.capture(jdbc, tenantId, sessionId, applied.records()); + publishJournalAfterCommit(validated.recordBytes()); + return new CommitReceipt(revision, request.transactionId(), + request.commandId(), request.operation(), + request.firstSequence(), request.lastSequence(), + request.lastSequence(), request.commitDigest(), false); + } + + // The authoritative-journal wire (design 2026-10-04-managed-agent-event-transport, + // MQ2b): hands the committed journal record to the transport only after the + // transaction is durable, and never from a replayed commit (those return + // before this point). A no-op when no transport is enabled. + void publishJournalAfterCommit(byte[] recordBytes) { + JournalEventTransport transport = journalEventTransport; + if (transport == null) { + return; + } + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + transport.publishCommitted(recordBytes); + return; + } + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + transport.publishCommitted(recordBytes); + } + }); + } + + void persistTransaction( + String tenantId, + String sessionId, + String writerTokenHash, + CommitTransactionRequest request, + long revision, + ValidatedCommit validated, + Timestamp now) { + jdbc.update( + "INSERT INTO ma_session_journal_tx (tenant_id, workspace_id, session_id, journal_revision, command_key_hash, transaction_id, operation, command_id, content_digest, first_sequence, last_sequence, event_count, events_digest, previous_commit_digest, commit_digest, writer_generation, writer_id, writer_token_hash, activation_epoch, latest_checkpoint_resource_id, record_encoding, record_bytes, byte_length, record_digest, gmt_create) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'identity', ?, ?, ?, ?)", + tenantId, + request.workspaceId(), + sessionId, + revision, + JournalStoreRows.commandKeyHash(request.operation(), request.commandId()), + request.transactionId(), + request.operation(), + request.commandId(), + request.contentDigest(), + request.firstSequence(), + request.lastSequence(), + request.eventCount(), + request.eventsDigest(), + request.previousCommitDigest(), + request.commitDigest(), + request.writerGeneration(), + request.writerId(), + writerTokenHash, + request.activationEpoch(), + request.latestCheckpointResourceId(), + validated.recordBytes(), + validated.recordBytes().length, + validated.recordDigest(), + now.getTime()); + } + + ManagedSessionStore.TerminalCandidate preflightCsiTerminal(java.sql.Connection target, + com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, + String writerToken, byte[] exactBody, JdbcRuntimeBindingRepository bindings) throws SQLException { + authority.requirePreCut(target); + if (!org.springframework.jdbc.datasource.DataSourceUtils.isConnectionTransactional( + org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(target), jdbc.getDataSource()) + || bindings == null || !bindings.usesDataSource(jdbc.getDataSource()) + || exactBody == null || exactBody.length == 0 || exactBody.length > 128 * 1024) { + throw JournalFencing.nativeReadConflict(); + } + var original = authority.original(); + CommitTransactionRequest request = csiTerminalRequest(exactBody); + String tenant = original.request().getScope().getTenantId(); + String session = original.request().getIsolationKey(); + JournalStoreRows.validateScope(tenant, request.workspaceId(), session); + fencing.requireCredential(tenant, request.workspaceId(), session, writerToken); + fencing.requireCsiJournalIsolation(); + validateCommit(request); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenant, session); + storeRows.requireHeadScope(head, tenant, request.workspaceId(), session); + JournalFencing.requireCsiWriter(original, head); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, storeRows.databaseNow(), true); + validateHead(head, request); + var proof = JdbcCsiActivationAdmission.terminalPreflight(target, authority, JournalStoreRows.CSI_JSON.valueToTree(request), bindings); + return new ManagedSessionStore.TerminalCandidate(request, proof, head.leaseTokenHash()); + } + + String commitCsiTerminal(java.sql.Connection target, + com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, + String writerToken, byte[] exactBody, JdbcRuntimeBindingRepository bindings) throws SQLException { + authority.requireConnection(target); + if (authority.stage() != com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Stage.FROZEN) { + throw JournalFencing.nativeReadConflict(); + } + var frozen = WorkspaceCsiApplicationJson.object(authority.frozenBytes(), WorkspaceCsiApplicationInventory.MAX_BYTES); + byte[] saved = WorkspaceCsiApplicationJson.base64(frozen.path("commitBodyBase64"), 128 * 1024); + if (!java.util.Arrays.equals(saved, exactBody) + || !com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof.sha256(saved) + .equals(frozen.path("preparedCommitSha256").textValue())) { + throw JournalFencing.nativeReadConflict(); + } + var candidate = preflightCsiTerminal(target, authority, writerToken, exactBody, bindings); + var request = candidate.request(); + String tenant = authority.original().request().getScope().getTenantId(); + String session = authority.original().request().getIsolationKey(); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenant, session); + var validated = validateCommit(request); + Timestamp now = storeRows.databaseNow(); + String writtenAt = now.toInstant().toString(); + long revision = JournalStoreRows.increment(head.journalRevision(), "journal revision"); + Runnable fence = () -> { + try { + authority.requireCurrentTime(target); + } catch (SQLException error) { + throw new org.springframework.dao.DataAccessResourceFailureException("Original terminal time is unavailable", error); + } + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, storeRows.databaseNow(), true); + }; + resourceWrites.commitResources(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), tenant, session, request, revision, now, validated.resources(), fence); + fence.run(); + persistTransaction(tenant, session, candidate.writerTokenSha256(), request, revision, validated, now); + fence.run(); + int changed = jdbc.update("UPDATE ma_session_head SET state = 'SEALED'," + + " journal_revision = ?, wake_head_revision = CASE WHEN wake_head_revision = ? THEN ? ELSE wake_head_revision END, committed_sequence = ?, last_commit_digest = ?," + + " activation_phase = 'released', activation_head_revision = ?, gmt_modified = ?" + + " WHERE head_kind = 'MANAGED_SESSION' AND tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND state = 'ACTIVE'" + + " AND journal_revision = ? AND committed_sequence = ? AND last_commit_digest = ?", + revision, head.journalRevision(), revision, candidate.proof().terminalSequence(), request.commitDigest(), revision, now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenant), JdbcRuntimeBindingRepository.harnessDrainKey(session), tenant, session, + head.journalRevision(), head.committedSequence(), head.lastCommitDigest()); + if (changed != 1) { + throw JournalFencing.nativeReadConflict(); + } + authority.requireCurrentTime(target); + return writtenAt; + } + + static CommitTransactionRequest csiTerminalRequest(byte[] bytes) { + WorkspaceCsiApplicationJson.object(bytes, 128 * 1024); + try { + return JournalStoreRows.CSI_TERMINAL_JSON.readValue(bytes, CommitTransactionRequest.class); + } catch (IOException malformed) { + throw JournalStoreRows.invalid("Invalid original terminal request."); + } + } + + static CommitTransactionRequest validateCsiTerminalBytes(byte[] bytes) { + var request = csiTerminalRequest(bytes); + validateCommit(request); + return request; + } + + void validatePagedCheckpoint(StoredResource stored, String tenant, String workspace, String session, + ManagedHostedRecoveryRecords.HistoricalResources resources) { + JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(stored.bytes(), StandardCharsets.UTF_8)); + if (body == null || !ManagedCheckpointHistory.paged(body)) { + return; + } + if (!ManagedCheckpointHistory.PROFILE.equals(body.path("storageProfile").asText())) { + throw JournalStoreRows.invalid("Unsupported checkpoint storage profile."); + } + JsonNode history = body.path("history"); + ManagedExtensionRecords.closed(history, Set.of("ref", "toolCount", "bindingCount"), "checkpoint history"); + resources.checkpointHistory(history.path("ref")); + StoredResource page = resources.apply(history.path("ref").path("resourceId").asText()); + JsonNode saved = ManagedCheckpointHistory.page(page.bytes()); + if (!saved.path("sessionKey").equals(body.path("identity").path("sessionKey")) + || !saved.path("turnId").equals(body.path("identity").path("turnId")) + || !saved.path("promptId").equals(body.path("identity").path("promptId")) + || !saved.path("toolCount").equals(history.path("toolCount")) + || !saved.path("bindingCount").equals(history.path("bindingCount"))) { + throw JournalStoreRows.invalid("Checkpoint history identity or coverage conflicts."); + } + } + + void validateCheckpointHistory(JsonNode ref, String tenant, String workspace, String session, + long previousRevision, Set declared, java.util.function.Function resources) { + try { + ManagedCheckpointHistory.pageRef(ref); + StoredResource stored = resources.apply(ref.path("resourceId").asText()); + requireCheckpointResource(ref, stored); + JsonNode page = ManagedCheckpointHistory.page(stored.bytes()); + JsonNode key = page.path("sessionKey"); + if (!tenant.equals(key.path("tenantId").asText()) || !workspace.equals(key.path("workspaceId").asText()) + || !session.equals(key.path("sessionId").asText())) { + throw JournalStoreRows.invalid("Checkpoint history belongs to another Session."); + } + Long origin = priorCheckpointResourceRevision(tenant, workspace, session, stored.resourceId(), previousRevision); + long ceiling = origin == null ? previousRevision : origin; + for (JsonNode nested : checkpointReferences(page)) { + StoredResource dependency = resources.apply(nested.path("resourceId").asText()); + requireCheckpointResource(nested, dependency); + Long owned = priorCheckpointResourceRevision(tenant, workspace, session, dependency.resourceId(), ceiling); + if (owned == null && (origin != null || !declared.contains(dependency.resourceId()))) { + throw JournalStoreRows.invalid("Checkpoint history dependency has no committed owner."); + } + } + long tools = page.path("toolCount").longValue() - page.path("items").size(); + long bindings = page.path("bindingCount").longValue() - page.path("bindings").size(); + JsonNode previous = page.path("previous"); + if (tools < 0 || bindings < 0) { + throw JournalStoreRows.invalid("Checkpoint history count conflicts."); + } + if (previous.isNull()) { + if (tools != 0 || bindings != 0) { + throw JournalStoreRows.invalid("Checkpoint history predecessor is missing."); + } + } else { + JsonNode predecessor = ManagedCheckpointHistory.page(resources.apply(previous.path("resourceId").asText()).bytes()); + if (!predecessor.path("sessionKey").equals(key) + || !predecessor.path("turnId").equals(page.path("turnId")) + || !predecessor.path("promptId").equals(page.path("promptId")) + || predecessor.path("toolCount").longValue() != tools + || predecessor.path("bindingCount").longValue() != bindings) { + throw JournalStoreRows.invalid("Checkpoint history predecessor conflicts."); + } + } + } catch (IllegalArgumentException | com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException error) { + throw JournalStoreRows.invalid("Invalid checkpoint history page."); + } + } + + static void requireCheckpointResource(JsonNode ref, StoredResource stored) { + ManagedExtensionRecords.durableRef(ref, "checkpoint history dependency"); + if (!stored.kind().equals(ref.path("kind").asText()) + || stored.schemaVersion() != ref.path("schemaVersion").intValue() + || stored.byteLength() != ref.path("byteLength").longValue() + || !stored.digest().equals(ref.path("digest").asText()) + || stored.bytes() == null || stored.bytes().length != stored.byteLength() + || !ToolPublicationContract.sha256(stored.bytes()).equals(stored.digest())) { + throw JournalStoreRows.invalid("Checkpoint history resource metadata conflicts."); + } + } + + static List checkpointReferences(JsonNode value) { + List result = new ArrayList<>(); + if (value.isObject() && value.has("resourceId")) { + result.add(value); + } else if (value.isContainerNode()) { + for (JsonNode child : value) { + result.addAll(checkpointReferences(child)); + } + } + return result; + } + + Long priorCheckpointResourceRevision(String tenant, String workspace, String session, + String resourceId, long maximum) { + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session); + List origins = jdbc.query("SELECT x.owner_revision FROM ma_resource_ref x JOIN ma_session_journal_tx t" + + " ON t.tenant_id = x.tenant_id AND t.workspace_id = x.workspace_id AND t.session_id = x.session_id" + + " AND t.journal_revision = x.owner_revision WHERE x.resource_key = ? AND x.session_scope_key = ?" + + " AND x.tenant_id = ? AND x.workspace_id = ? AND x.session_id = ? AND x.resource_id = ?" + + " AND x.owner_kind = 'JOURNAL' AND x.owner_key = ? AND x.owner_revision = x.journal_revision" + + " AND x.owner_revision <= ? ORDER BY x.owner_revision LIMIT 1", + (row, index) -> row.getLong(1), com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, resourceId), scope, tenant, workspace, session, + resourceId, scope, maximum); + return origins.isEmpty() ? null : origins.getFirst(); + } + + static ValidatedCommit validateCommit( + CommitTransactionRequest request) { + JournalStoreRows.validateStableId(request.transactionId(), "transactionId"); + JournalStoreRows.validateText(request.operation(), "operation", JournalStoreRows.MAX_TEXT_BYTES); + JournalStoreRows.validateStableId(request.commandId(), "commandId"); + JournalStoreRows.validateDigest(request.contentDigest(), "contentDigest", false); + JournalStoreRows.validateDigest(request.eventsDigest(), "eventsDigest", true); + JournalStoreRows.validateDigest(request.previousCommitDigest(), + "previousCommitDigest", true); + JournalStoreRows.validateDigest(request.commitDigest(), "commitDigest", true); + JournalStoreRows.validateDigest(request.recordDigest(), "recordDigest", false); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + JournalStoreRows.validateCounter(request.expectedJournalRevision(), + "expectedJournalRevision", 0); + JournalStoreRows.validateCounter(request.expectedCommittedSequence(), + "expectedCommittedSequence", 0); + JournalStoreRows.validateCounter(request.firstSequence(), "firstSequence", 0); + JournalStoreRows.validateCounter(request.lastSequence(), "lastSequence", 0); + JournalStoreRows.validateCounter(request.activationEpoch(), "activationEpoch", 0); + if (request.latestCheckpointResourceId() != null) { + JournalStoreRows.validateStableId(request.latestCheckpointResourceId(), + "latestCheckpointResourceId"); + } + if (request.eventCount() < 0 + || request.eventCount() + > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS + || request.recordCount() < 1 + || request.recordCount() + > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS + + 1) { + throw JournalStoreRows.invalid("eventCount or recordCount is outside its bound."); + } + byte[] recordBytes = JournalStoreRows.decodeBase64(request.recordBytesBase64(), + ManagedSessionStoreModels.MAX_TRANSACTION_BYTES, + "recordBytesBase64"); + if (!JournalStoreRows.sha256(recordBytes).equals(request.recordDigest())) { + throw JournalStoreRows.invalid("recordDigest does not match recordBytesBase64."); + } + JournalStoreRows.validateUtf8JsonLines(recordBytes, request.recordCount()); + if ("session.create".equals(request.operation())) { + if (request.eventCount() != 0 || request.firstSequence() != 0 + || request.lastSequence() != 0 + || request.recordCount() != 2 + || request.eventsDigest() != null + || request.previousCommitDigest() != null + || request.commitDigest() != null + || request.latestCheckpointResourceId() != null) { + throw JournalStoreRows.invalid("The session.create transaction must use" + + " sequence zero and no commit digest chain."); + } + } else { + if (request.eventCount() < 1 + || request.firstSequence() < 1 + || request.lastSequence() < request.firstSequence() + || request.lastSequence() - request.firstSequence() + 1 + != request.eventCount() + || request.eventsDigest() == null + || request.commitDigest() == null + || request.recordCount() != request.eventCount() + 1) { + throw JournalStoreRows.invalid("The transaction event range or record count" + + " is invalid."); + } + } + List resources = new ArrayList<>(); + long inlineBytes = 0; + List requestedResources = request.resources() == null + ? List.of() : request.resources(); + if (requestedResources.size() + > ManagedSessionStoreModels.MAX_RESOURCES_PER_TRANSACTION) { + throw JournalStoreRows.invalid("resources exceeds its item limit."); + } + for (CommitResource resource : requestedResources) { + if (resource == null) { + throw JournalStoreRows.invalid("resources must not contain null entries."); + } + JournalStoreRows.validateStableId(resource.resourceId(), "resourceId"); + JournalStoreRows.validateStableId(resource.kind(), "resource kind"); + if (ManagedNativeJournalSnapshots.isSnapshotKind(resource.kind()) + || ManagedNativeJournalSnapshots.isSnapshotId(resource.resourceId())) { + throw JournalStoreRows.invalid("Native snapshot resources require fenced snapshot publication."); + } + JournalStoreRows.validateDigest(resource.digest(), "resource digest", false); + if (resource.schemaVersion() < 1 || resource.byteLength() < 0) { + throw JournalStoreRows.invalid("Resource version or length is invalid."); + } + int maxResourceBytes = ManagedSessionStoreModels.inlineResourceBytes(resource.kind()); + if (resource.byteLength() > maxResourceBytes + && !("managed-tool-outcome".equals(resource.kind()) + && resource.bytesBase64() == null)) { + throw new ManagedAgentException(501, + ManagedSessionStoreModels.ERROR_OSS_DISABLED, + "Resources larger than " + (maxResourceBytes / 1024) + " KiB require the disabled" + + " OSS storage path."); + } + byte[] bytes = resource.bytesBase64() == null ? null + : JournalStoreRows.decodeBase64(resource.bytesBase64(), + maxResourceBytes, + "resource bytesBase64"); + if (bytes != null && (bytes.length != resource.byteLength() + || !JournalStoreRows.sha256(bytes).equals(resource.digest()))) { + throw JournalStoreRows.invalid("Resource bytes do not match their length or" + + " digest."); + } + if (bytes != null) { + inlineBytes += bytes.length; + if (inlineBytes + > ManagedSessionStoreModels.MAX_INLINE_TRANSACTION_BYTES) { + throw JournalStoreRows.payloadTooLarge("Inline resources exceed their" + + " transaction byte limit."); + } + } + resources.add(new JournalResourceWrites.ValidatedResource(resource, bytes)); + } + if (request.latestCheckpointResourceId() != null + && resources.stream().map(JournalResourceWrites.ValidatedResource::resource) + .noneMatch(resource -> request + .latestCheckpointResourceId() + .equals(resource.resourceId()) + && "managed-checkpoint" + .equals(resource.kind()))) { + throw JournalStoreRows.invalid("latestCheckpointResourceId must identify a" + + " managed-checkpoint resource in this transaction."); + } + return new ValidatedCommit(recordBytes, request.recordDigest(), + resources); + } + + void validateHead(JournalStoreRows.HeadRow head, + CommitTransactionRequest request) { + if (request.expectedJournalRevision() != head.journalRevision() + || request.expectedCommittedSequence() + != head.committedSequence() + || !Objects.equals(request.previousCommitDigest(), + head.lastCommitDigest())) { + throw JournalStoreRows.conflict("managed_session_head_conflict", + "The Managed Session head changed; reload before" + + " retrying."); + } + if (request.activationEpoch() < head.activationEpoch()) { + throw JournalStoreRows.conflict("managed_session_activation_conflict", + "The activation epoch is stale."); + } + if (!"READY".equals(head.recoveryStatus())) { + throw JournalStoreRows.conflict("managed_session_recovery_blocked", + "The Managed Session is blocked from new commits."); + } + if ("session.create".equals(request.operation())) { + if (head.journalRevision() != 0 + || head.committedSequence() != 0) { + throw JournalStoreRows.conflict("managed_session_head_conflict", + "The Managed Session genesis is already committed."); + } + } else if (head.journalRevision() == 0 + || request.firstSequence() + != JournalStoreRows.increment(head.committedSequence(), + "committed sequence")) { + throw JournalStoreRows.conflict("managed_session_head_conflict", + "The transaction does not extend the committed" + + " sequence."); + } + } + + CommitReceipt replay(JournalStoreRows.TransactionRow existing, + CommitTransactionRequest request, String recordDigest, + String writerToken) { + if (!existing.operation().equals(request.operation()) + || !existing.commandId().equals(request.commandId()) + || !existing.transactionId() + .equals(request.transactionId()) + || !existing.contentDigest().equals(request.contentDigest()) + || !existing.recordDigest().equals(recordDigest) + || existing.firstSequence() != request.firstSequence() + || existing.lastSequence() != request.lastSequence() + || existing.eventCount() != request.eventCount() + || !Objects.equals(existing.eventsDigest(), + request.eventsDigest()) + || !Objects.equals(existing.previousCommitDigest(), + request.previousCommitDigest()) + || existing.writerGeneration() + != request.writerGeneration() + || !existing.writerId().equals(request.writerId()) + || !JournalFencing.secureEquals(existing.writerTokenHash(), + JournalFencing.tokenHash(writerToken)) + || existing.activationEpoch() != request.activationEpoch() + || !Objects.equals(existing.latestCheckpointResourceId(), + request.latestCheckpointResourceId()) + || !Objects.equals(existing.commitDigest(), + request.commitDigest())) { + throw JournalStoreRows.conflict( + ManagedSessionStoreModels.ERROR_IDEMPOTENCY_CONFLICT, + "The command key was reused with different content."); + } + return new CommitReceipt(existing.journalRevision(), + existing.transactionId(), existing.commandId(), + existing.operation(), existing.firstSequence(), + existing.lastSequence(), existing.lastSequence(), + existing.commitDigest(), true); + } + + /** + * Converts the last activation.changed payload of a committed transaction, so the head row can + * answer authorization reads without rescanning the journal. The payload was already parsed by + * the extension record pass of this commit. + */ + static ActivationChange activationChange(JsonNode payload) { + if (payload == null) { + return null; + } + return new ActivationChange( + ToolPublicationContract.text(payload, "activationId"), + ToolPublicationContract.text(payload, "phase"), + payload.path("epoch").asLong(), + ManagedExtensionRecords.millisLenient( + payload.path("expiresAt"))); + } + + record ActivationChange(String id, String phase, long epoch, + Long expiresAt) { + } + + record ValidatedCommit(byte[] recordBytes, String recordDigest, + List resources) { + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalFencing.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalFencing.java new file mode 100644 index 00000000000..111f3d3ffc3 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalFencing.java @@ -0,0 +1,500 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.WorkspaceCsiRuntimeIdentity; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiFilesRetirementGuard; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import org.springframework.dao.DeadlockLoserDataAccessException; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +final class JournalFencing { + private final JournalStoreRows storeRows; + private final JdbcTemplate jdbc; + private final JournalEventTransport journalEventTransport; + private final WriterCredentialPolicy credentials; + private final JdbcRuntimeBindingRepository csiBindings; + + JournalFencing(JournalStoreRows storeRows, + JdbcTemplate jdbc, + JournalEventTransport journalEventTransport, + WriterCredentialPolicy credentials, + JdbcRuntimeBindingRepository csiBindings) { + this.storeRows = storeRows; + this.jdbc = jdbc; + this.journalEventTransport = journalEventTransport; + this.credentials = credentials; + this.csiBindings = csiBindings; + } + + // Hot paths touch the tenant-wide guard rows only on admission, and + // never wait on them past this per-statement bound: a held row degrades + // to a named, retryable failure instead of out-waiting the caller's own + // request budget (#13800). + static final int GUARD_ACQUIRE_TIMEOUT_SECONDS = 5; + + static final Pattern WRITER_TOKEN = Pattern.compile( + "^[A-Za-z0-9_-]{" + + ManagedSessionStoreModels.MIN_WRITER_TOKEN_LENGTH + "," + + ManagedSessionStoreModels.MAX_WRITER_TOKEN_LENGTH + "}$"); + + void checkLifecycleWriter(String tenantId, String sessionId, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + // Ordinary traffic fences per Session (head row, drain admission); + // taking the two tenant-wide rows here would let any long holder of + // them wedge every Session of the tenant (#13800). No v2 probe may + // run here: a plain consistent read pins the REPEATABLE READ snapshot + // before the guard waits below; a row-locking read inverts against + // the claim order (guards → operation) of a concurrently claiming + // lifecycle; and borrowing a second pooled connection deadlocks the + // pool under saturation. The fence + // that rejects ordinary writes against a v2 Session lives in + // requireOrdinaryAdmission, where the caller's own guards are already + // held and the same consistent read layer is established. + if (authority == null) { + return; + } + lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + GUARD_ACQUIRE_TIMEOUT_SECONDS)); + lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, + GUARD_ACQUIRE_TIMEOUT_SECONDS)); + ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); + } + + static ManagedAgentException guardContended() { + return new ManagedAgentException(503, + "tenant_guard_contended", + "A tenant-wide Managed Session guard could not be acquired" + + " within the hot-path bound; retry."); + } + + /** + * Runs one sweep candidate in its own transaction. A guard refusal skips that candidate + * instead of aborting the sweep; any other failure propagates unchanged. + */ + static T skipContended( + org.springframework.transaction.support.TransactionTemplate transactions, + org.springframework.transaction.support.TransactionCallback body) { + try { + return transactions.execute(body); + } catch (ManagedAgentException refusal) { + if (!"tenant_guard_contended".equals(refusal.getCode())) { + throw refusal; + } + return null; + } + } + + static void lockBounded(Runnable lock) { + lockBoundedResult(() -> { + lock.run(); + return null; + }); + } + + static T lockBoundedResult(Supplier lock) { + try { + return lock.get(); + } catch (PessimisticLockingFailureException | QueryTimeoutException error) { + throw guardContended(); + } + } + + /** + * The root-side half of {@link #lockBounded}'s mapping. A pinned CSI + * Session's continuation holds its root while it waits for the tenant + * guards, so a guard-first lane on that Session closes an InnoDB cycle + * when it reaches the root, and InnoDB may roll back either side + * (#13800 R1-1). The root acquisitions name that rollback the way the + * guard side does; an ordinary wait on the root stays unbounded and + * keeps its own failure. The default translator reports a deadlock + * (MySQL/MariaDB 1213, SQLState 40001) as CannotAcquireLockException, + * the error-code translator as DeadlockLoserDataAccessException, so the + * victim is recognized by its vendor code. + */ + static T namedRootDeadlock(Supplier lock) { + try { + return lock.get(); + } catch (PessimisticLockingFailureException error) { + if (error instanceof DeadlockLoserDataAccessException || deadlockVictim(error)) { + throw guardContended(); + } + throw error; + } + } + + static boolean deadlockVictim(Throwable error) { + for (Throwable cause = error; cause != null; cause = cause.getCause()) { + if (cause instanceof java.sql.SQLException sql && sql.getErrorCode() == 1213) { + return true; + } + } + return false; + } + + void requirePhysicalSessionOpen(String tenantId, String workspaceId, String sessionId) { + // Placement is the tenant-wide row migration admission shares; on + // these per-turn paths it stays bounded with the shared named-failure + // mapping, so a held row answers tenant_guard_contended instead of + // out-waiting the caller (#13800). + lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + GUARD_ACQUIRE_TIMEOUT_SECONDS)); + WorkspaceMigrationAdmission.requirePhysicalOpen(jdbc, tenantId, + WorkspaceMigrationAdmission.sessionStorage(jdbc, tenantId, sessionId, workspaceId)); + } + + // An ordinary write against a v2-claimed Session must fail with the + // claim's name, and the read that names it must never walk the three + // cliffs the earlier probes hit: no plain consistent read ahead of the + // guard waits in the calling path (it pins the snapshot before the retry + // learns its winner), no row lock ahead of the claim order (guards → + // operation), and no second pooled connection while this transaction + // holds its own (the pool self-deadlocks at saturation). + // Every call site invokes this only after its own guard or head row has + // been taken, so the consistent read layer the flow required is already + // established and the classification costs one lock-free session-scoped + // range scan on the caller's own connection. + void requireNoLifecycleClaim(String tenantId, String sessionId) { + if (!jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE scope_key = ?" + + " AND operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE') AND tenant_id = ? AND session_id = ?" + + " AND lifecycle_protocol_version = 2", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), tenantId, sessionId).isEmpty()) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_claim_fenced"); + } + } + + void requireOrdinaryAdmission(String tenantId, String sessionId) { + requireNoLifecycleClaim(tenantId, sessionId); + if (lifecycleFenced(tenantId, sessionId)) { + throw JournalStoreRows.conflict( + "managed_session_lifecycle_active", "Ordinary execution admission is closed."); + } + } + + boolean lifecycleFenced(String tenantId, String sessionId) { + return !jdbc.queryForList( + "SELECT phase FROM ma_session_head WHERE phase <> 'OPEN' AND tenant_key = ?" + + " AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?" + + " FOR UPDATE", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId) + .isEmpty(); + } + + boolean lifecycleProtocolFenced(String tenantId, String sessionId) { + return !jdbc.queryForList( + "SELECT operation_id FROM ma_session_head WHERE tenant_key = ? AND" + + " harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND" + + " operation_id IS NOT NULL FOR UPDATE", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId) + .isEmpty(); + } + + // A bound credential is checked before any state lookup so a foreign + // token never learns whether a Session exists. + void requireCredential(String tenantId, String workspaceId, + String sessionId, String writerToken) { + credentials.require(tenantId, workspaceId, sessionId, writerToken); + } + + HarnessPlacementStore.Placement requirePlacement(String tenant, String workspace, + String session, String token, String writer, boolean newWork) { + return HarnessPlacementStore.requireCurrent(jdbc, credentials, tenant, workspace, session, token, writer, newWork); + } + + static ManagedAgentException nativeReadConflict() { + return JournalStoreRows.conflict( + "csi_resource_read_conflict", + "The original resource read identity is unavailable."); + } + + void requireCsiApplicationCredential(java.sql.Connection target, + com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, + String writerToken) throws SQLException { + authority.requireConnection(target); + if (!org.springframework.jdbc.datasource.DataSourceUtils.isConnectionTransactional( + org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(target), jdbc.getDataSource())) { + throw nativeReadConflict(); + } + var scope = authority.original().request().getScope(); + String session = authority.original().request().getIsolationKey(); + requireCredential(scope.getTenantId(), scope.getWorkspaceId(), session, writerToken); + requireCsiJournalIsolation(); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(scope.getTenantId(), session); + storeRows.requireHeadScope(head, scope.getTenantId(), scope.getWorkspaceId(), session); + requireCsiWriter(authority.original(), head); + if (!secureEquals(head.leaseTokenHash(), tokenHash(writerToken))) { + throw nativeReadConflict(); + } + } + + void requireCsiHistoricalCredential(String tenant, String workspace, String session, String token, String retainedHash) { + requireCredential(tenant, workspace, session, token); + String presented = tokenHash(token); + if (retainedHash == null || !retainedHash.matches("[0-9a-f]{64}") || !secureEquals(retainedHash, presented)) { + throw nativeReadConflict(); + } + } + + void requireWriter(JournalStoreRows.HeadRow head, String writerId, + long writerGeneration, String writerToken, Timestamp now, + boolean requireUnexpired) { + if (!"ACTIVE".equals(head.state()) + || writerGeneration != head.writerGeneration() + || !writerId.equals(head.writerId()) + || !secureEquals(tokenHash(writerToken), + head.leaseTokenHash()) + || requireUnexpired && (head.writerLeaseUntil() == null + || !head.writerLeaseUntil().after(now))) { + throw writerConflict(); + } + } + + void requireReadGrant(JournalStoreRows.HeadRow head, String writerToken) { + ToolPublicationRetentionStore.requireLive(jdbc, head.tenantId(), head.sessionId()); + Timestamp now = storeRows.databaseNow(); + if (!"ACTIVE".equals(head.state()) + || !secureEquals(tokenHash(writerToken), + head.leaseTokenHash()) + || head.writerLeaseUntil() == null + || !head.writerLeaseUntil().after(now)) { + throw writerConflict(); + } + } + + JdbcCsiFilesRetirementGuard.Original lockCsiOriginal(String tenantId, String sessionId) { + var continuation = lockCsiContinuation(tenantId, sessionId); + return continuation == null ? null : continuation.original(); + } + + CsiContinuation lockCsiContinuation(String tenantId, String sessionId) { + Boolean pinned = jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + var target = + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection); + if (!org.springframework.jdbc.datasource.DataSourceUtils + .isConnectionTransactional(target, jdbc.getDataSource())) { + var privatePins = + jdbc.query( + "SELECT csi_guard FROM ma_session WHERE tenant_id =" + + " ? AND session_id = ? AND csi_guard = TRUE", + (row, index) -> row.getBoolean("csi_guard"), + tenantId, + sessionId); + if (!privatePins.isEmpty()) { + throw new IllegalStateException( + "CSI mutation requires its original transaction" + + " connection"); + } + return null; + } + // An ordinary Session stops here, before the + // tenant-wide guard rows and the slot/binding + // range locks: taking them on every writer path + // would couple per-turn traffic of unrelated + // Sessions again (#13800). The root is locked + // first, by primary key once it exists, so its pin + // is read as committed now even under an older + // REPEATABLE READ snapshot, no gap is taken that + // another tenant's first writer inserts into, and + // no consistent read below runs before this wait. + // The runtime rows are then read plainly: a CSI + // slot or binding is only created under a pinned + // root, a pin never changes after its row is + // inserted, and roots are never deleted, so an + // unpinned root can only have legacy markers, + // which no snapshot here is older than. + return ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId) + || JdbcCsiFilesRetirementGuard.hasCsiRuntimeMarker( + target, tenantId, sessionId); + }); + if (!Boolean.TRUE.equals(pinned)) { + return null; + } + // A pinned Session takes the tenant guards behind its root, bounded + // and named like every other per-turn lane: on renew, commit and + // recovery state these are the first tenant-wide waits (#13800). + // Every other lane takes the guards before the root, so a guard-first + // operation on this same Session meanwhile resolves as an InnoDB + // deadlock. Either side may be rolled back: this side's waits are + // named by lockBounded, the other side's root wait by + // namedRootDeadlock (#13800 R1-1). + lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + GUARD_ACQUIRE_TIMEOUT_SECONDS)); + lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, + GUARD_ACQUIRE_TIMEOUT_SECONDS)); + return jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + var target = + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection); + var original = + JdbcCsiFilesRetirementGuard.lockManagedSession( + target, tenantId, sessionId); + if (original != null) { + requireCsiJournalIsolation(); + } + return original == null + ? null + : new CsiContinuation( + original, + com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationProgress.lock(target, original)); + }); + } + + void requireCsiJournalIsolation() { + if (journalEventTransport != null) { + throw JournalStoreRows.conflict("csi_journal_transport_unsupported", + "The private CSI lane requires an isolated journal without an event transport."); + } + } + + void requireCsiCurrentTime(CsiContinuation continuation) { + if (continuation != null) { + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + continuation + .progress() + .requireCurrentTime( + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection)); + return null; + }); + } + } + + void requireCsiLease( + CsiContinuation continuation, long existingExpiry, long targetExpiry) { + if (continuation != null) { + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + continuation + .progress() + .requireLease( + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection), + existingExpiry, + targetExpiry); + return null; + }); + } + } + + void requireCsiWriterCurrent( + CsiContinuation continuation, + JournalStoreRows.HeadRow head, + String writerId, + long generation, + String token) { + if (continuation != null) { + requireCsiLease( + continuation, + JournalStoreRows.epochMillis(head.writerLeaseUntil()), + JournalStoreRows.epochMillis(head.writerLeaseUntil())); + requireWriter(head, writerId, generation, token, storeRows.databaseNow(), true); + } + } + + void requireCsiReadGrant(CsiContinuation continuation, JournalStoreRows.HeadRow head, String token) { + requireReadGrant(head, token); + requireCsiLease( + continuation, + JournalStoreRows.epochMillis(head.writerLeaseUntil()), + JournalStoreRows.epochMillis(head.writerLeaseUntil())); + } + + record CsiContinuation( + JdbcCsiFilesRetirementGuard.Original original, + com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationProgress.Continuation progress) {} + + static void requireCsiWriter( + JdbcCsiFilesRetirementGuard.Original original, JournalStoreRows.HeadRow head) { + if (original != null && head.writerGeneration() < 1) { + throw JournalStoreRows.conflict( + "csi_original_writer_unavailable", + "The original CSI writer cannot be replaced."); + } + } + + void requireInstalledCsiWriter(JdbcCsiFilesRetirementGuard.Original original) { + if (original != null) { + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + JdbcCsiActivationAdmission.lockNativeHead(connection, original); + return null; + }); + } + } + + void requireCsiHistoryOwner(JdbcCsiFilesRetirementGuard.Original original) { + if (csiBindings == null) { + throw JournalStoreRows.conflict( + "csi_native_identity_unavailable", + "CSI history requires original encrypted Runtime identity"); + } + jdbc.execute( + (org.springframework.jdbc.core.ConnectionCallback) + connection -> { + var target = + org.springframework.jdbc.datasource.DataSourceUtils + .getTargetConnection(connection); + var binding = + csiBindings.findByIdForUpdate(target, original.bindingId()); + if (binding == null + || !binding.getRequest().equals(original.request()) + || binding.getGeneration() != original.generation() + || binding.getVersion() != original.version()) { + throw JournalStoreRows.conflict( + "csi_native_identity_unavailable", + "Original execution bootstrap is unavailable"); + } + WorkspaceCsiRuntimeIdentity.fileHistoryBoot(binding); + return null; + }); + } + + static String tokenHash(String writerToken) { + if (writerToken == null + || !WRITER_TOKEN.matcher(writerToken).matches()) { + throw new ManagedAgentException(400, + "invalid_writer_token", + ManagedSessionStoreModels.WRITER_TOKEN_HEADER + + " must contain 32-512 Base64URL characters."); + } + return JournalStoreRows.sha256(writerToken.getBytes(StandardCharsets.UTF_8)); + } + + static boolean secureEquals(String left, String right) { + return left != null && right != null && MessageDigest.isEqual( + left.getBytes(StandardCharsets.US_ASCII), + right.getBytes(StandardCharsets.US_ASCII)); + } + + static ManagedAgentException writerConflict() { + return JournalStoreRows.conflict(ManagedSessionStoreModels.ERROR_WRITER_CONFLICT, + "The Managed Session writer grant is stale or unavailable."); + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalHistoryQueries.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalHistoryQueries.java new file mode 100644 index 00000000000..06bf6be5d60 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalHistoryQueries.java @@ -0,0 +1,404 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredTransaction; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.TransactionPage; +import com.alibaba.qwen.code.managedagent.model.RequestDigests; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.StringUtils; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +final class JournalHistoryQueries { + private final JournalFencing fencing; + private final JournalStoreRows storeRows; + private final JdbcTemplate jdbc; + + JournalHistoryQueries(JournalFencing fencing, + JournalStoreRows storeRows, + JdbcTemplate jdbc) { + this.fencing = fencing; + this.storeRows = storeRows; + this.jdbc = jdbc; + } + + static final RequestDigests INITIAL_DIGESTS = new RequestDigests(); + + RestoreHead restore(String tenantId, String workspaceId, + String sessionId, String writerToken) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + fencing.requireCredential(tenantId, workspaceId, sessionId, writerToken); + JournalStoreRows.HeadRow head = storeRows.requireHead(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, workspaceId, sessionId); + fencing.requireReadGrant(head, writerToken); + return storeRows.restoreHead(head); + } + + TransactionPage transactions(String tenantId, String workspaceId, + String sessionId, String writerToken, long afterRevision, + int limit) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + fencing.requireCredential(tenantId, workspaceId, sessionId, writerToken); + if (afterRevision < 0 + || afterRevision > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || limit < 1 || limit > 100) { + throw JournalStoreRows.invalid("afterRevision and limit are outside their bounds."); + } + JournalStoreRows.HeadRow head = storeRows.requireHead(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, workspaceId, sessionId); + fencing.requireReadGrant(head, writerToken); + List candidates = jdbc.query( + "SELECT journal_revision, byte_length" + + " FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND journal_revision > ?" + + " ORDER BY journal_revision LIMIT ?", + (result, row) -> new TransactionSize( + result.getLong("journal_revision"), + result.getLong("byte_length")), + tenantId, sessionId, afterRevision, + limit + 1); + int selectedCount = 0; + long selectedBytes = 0; + long expectedRevision = afterRevision + 1; + for (TransactionSize candidate : candidates) { + if (candidate.journalRevision() != expectedRevision + || candidate.journalRevision() + > head.journalRevision() + || candidate.byteLength() < 1 || candidate.byteLength() + > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw JournalStoreRows.journalCorrupt(); + } + expectedRevision++; + if (selectedCount == limit || selectedCount > 0 + && selectedBytes + candidate.byteLength() + > ManagedSessionStoreModels + .MAX_TRANSACTION_BYTES) { + break; + } + selectedBytes += candidate.byteLength(); + selectedCount++; + } + if (afterRevision < head.journalRevision() + && (candidates.isEmpty() + || candidates.size() < limit + 1 + && candidates.get(candidates.size() - 1) + .journalRevision() + != head.journalRevision())) { + throw JournalStoreRows.journalCorrupt(); + } + List rows = selectedCount == 0 ? List.of() + : jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + "" + + " FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND journal_revision > ?" + + " ORDER BY journal_revision LIMIT ?", + storeRows.transactionMapper, tenantId, sessionId, + afterRevision, selectedCount); + if (rows.size() != selectedCount) { + throw JournalStoreRows.journalCorrupt(); + } + for (int index = 0; index < rows.size(); index++) { + JournalStoreRows.TransactionRow row = rows.get(index); + TransactionSize candidate = candidates.get(index); + if (row.journalRevision() != candidate.journalRevision() + || row.byteLength() != candidate.byteLength()) { + throw JournalStoreRows.journalCorrupt(); + } + } + boolean hasMore = candidates.size() > selectedCount; + rows.forEach(row -> JournalStoreRows.requireTransactionWorkspace(row, workspaceId)); + List transactions = rows.stream() + .map(storeRows::storedTransaction).toList(); + long nextRevision = transactions.isEmpty() ? afterRevision + : transactions.get(transactions.size() - 1) + .journalRevision(); + return new TransactionPage(transactions, nextRevision, hasMore); + } + + ManagedSessionStoreModels.RecentTransactionPage recentTransactions(String tenantId, String workspaceId, + String sessionId, String writerToken, Long throughRevision, Long beforeRevision, int limit) { + long through = historyRevision(tenantId, workspaceId, sessionId, writerToken, throughRevision); + if (limit < 1 || limit > 100 || beforeRevision != null && (beforeRevision < 1 || beforeRevision > through)) { + throw JournalStoreRows.invalid("History page position or limit is outside its bounds."); + } + long upper = beforeRevision == null ? through : beforeRevision - 1; + List candidates = jdbc.query("SELECT journal_revision, byte_length FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ? AND journal_revision <= ?" + + " ORDER BY journal_revision DESC LIMIT ?", + (row, index) -> new TransactionSize(row.getLong("journal_revision"), row.getLong("byte_length")), + tenantId, sessionId, upper, limit + 1); + int selected = 0; + long bytes = 0; + long expected = upper; + for (TransactionSize candidate : candidates) { + if (candidate.journalRevision() != expected-- || candidate.byteLength() < 1 + || candidate.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw JournalStoreRows.journalCorrupt(); + } + if (selected == limit || selected > 0 && bytes + candidate.byteLength() + > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + break; + } + bytes += candidate.byteLength(); + selected++; + } + if (upper > 0 && (candidates.isEmpty() || candidates.size() < limit + 1 + && candidates.getLast().journalRevision() != 1)) { + throw JournalStoreRows.journalCorrupt(); + } + List rows = selected == 0 ? List.of() : jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + " FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ? AND journal_revision <= ?" + + " ORDER BY journal_revision DESC LIMIT ?", storeRows.transactionMapper, tenantId, sessionId, upper, selected); + if (rows.size() != selected) { + throw JournalStoreRows.journalCorrupt(); + } + List transactions = new ArrayList<>(); + for (int index = 0; index < rows.size(); index++) { + JournalStoreRows.TransactionRow row = rows.get(index); + if (row.journalRevision() != candidates.get(index).journalRevision() + || row.byteLength() != candidates.get(index).byteLength()) { + throw JournalStoreRows.journalCorrupt(); + } + JournalStoreRows.requireTransactionWorkspace(row, workspaceId); + transactions.add(storeRows.storedTransaction(row)); + } + boolean more = candidates.size() > selected; + Long next = more ? transactions.getLast().journalRevision() : null; + java.util.Collections.reverse(transactions); + restore(tenantId, workspaceId, sessionId, writerToken); + return new ManagedSessionStoreModels.RecentTransactionPage(List.copyOf(transactions), through, next, more); + } + + ManagedSessionStoreModels.CommandTransaction commandTransaction(String tenantId, String workspaceId, + String sessionId, String writerToken, Long throughRevision, String operation, String commandId) { + long through = historyRevision(tenantId, workspaceId, sessionId, writerToken, throughRevision); + if (StringUtils.isBlank(operation) || operation.length() > 4096 + || StringUtils.isBlank(commandId) || commandId.length() > 512) { + throw JournalStoreRows.invalid("History command identity is invalid."); + } + String key = JournalStoreRows.commandKeyHash(operation, commandId); + var sizes = jdbc.query("SELECT journal_revision, byte_length FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ? AND command_key_hash = ? AND journal_revision <= ?", + (row, index) -> new TransactionSize(row.getLong("journal_revision"), row.getLong("byte_length")), + tenantId, sessionId, key, through); + StoredTransaction transaction = null; + if (!sizes.isEmpty()) { + TransactionSize size = sizes.getFirst(); + if (sizes.size() != 1 || size.byteLength() < 1 + || size.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw JournalStoreRows.journalCorrupt(); + } + JournalStoreRows.TransactionRow row = storeRows.findTransactionByCommand(tenantId, sessionId, key); + if (row == null || row.journalRevision() != size.journalRevision() || row.byteLength() != size.byteLength()) { + throw JournalStoreRows.journalCorrupt(); + } + JournalStoreRows.requireTransactionWorkspace(row, workspaceId); + if (!operation.equals(row.operation()) || !commandId.equals(row.commandId()) + || row.byteLength() < 1 || row.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { + throw JournalStoreRows.journalCorrupt(); + } + transaction = storeRows.storedTransaction(row); + } + restore(tenantId, workspaceId, sessionId, writerToken); + return new ManagedSessionStoreModels.CommandTransaction(through, transaction); + } + + long historyRevision(String tenantId, String workspaceId, String sessionId, String writerToken, + Long throughRevision) { + RestoreHead head = restore(tenantId, workspaceId, sessionId, writerToken); + if (head.compactedThroughRevision() != 0) { + throw JournalStoreRows.conflict(ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT, "History prefix is no longer retained."); + } + if (throughRevision != null && (throughRevision < 0 + || throughRevision > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || throughRevision > head.journalRevision())) { + throw JournalStoreRows.invalid("History prefix is outside its committed bounds."); + } + long through = throughRevision == null ? head.journalRevision() : throughRevision; + if (through > 0) { + var prefix = jdbc.query("SELECT workspace_id FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ? AND journal_revision = ?", + (row, index) -> row.getString("workspace_id"), tenantId, sessionId, through); + if (prefix.size() != 1 || !workspaceId.equals(prefix.getFirst())) { + throw JournalStoreRows.journalCorrupt(); + } + } + return through; + } + + /** Proves only an untouched initial journal; it never opens or fences a writer. */ + boolean hasPristineInitialJournal(String tenantId, String sessionId, String workspaceId) { + try { + JournalStoreRows.HeadRow head = storeRows.requireHead(tenantId, sessionId); + if (!Set.of("ACTIVE", "SEALED").contains(head.state()) + || !"READY".equals(head.recoveryStatus()) || head.storageVersion() != JournalStoreRows.STORAGE_VERSION + || head.compactedThroughRevision() != 0 || head.latestCheckpointResourceId() != null + || head.journalRevision() < 1 || head.committedSequence() < 0 + || workspaceId != null && !workspaceId.equals(head.workspaceId())) { + return false; + } + var rows = jdbc.query("SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + + " FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " ORDER BY journal_revision", storeRows.transactionMapper, tenantId, sessionId); + long revision = 0; + long sequence = 0; + long activationEpoch = 0; + String digest = null; + for (JournalStoreRows.TransactionRow row : rows) { + if (row.journalRevision() != ++revision || !head.workspaceId().equals(row.workspaceId()) + || row.latestCheckpointResourceId() != null || row.activationEpoch() < activationEpoch + || !Objects.equals(digest, row.previousCommitDigest())) { + return false; + } + storeRows.storedTransaction(row); + JournalStoreRows.validateUtf8JsonLines(row.recordBytes(), revision == 1 ? 2 : row.eventCount() + 1); + String[] lines = new String(row.recordBytes(), StandardCharsets.UTF_8).split("\n"); + if (revision == 1) { + if (!"session.create".equals(row.operation()) || row.eventCount() != 0 + || row.firstSequence() != 0 || row.lastSequence() != 0 || lines.length != 2 + || row.commitDigest() != null || row.eventsDigest() != null || row.activationEpoch() != 0 + || !("session.create:" + sessionId).equals(row.transactionId()) + || !row.transactionId().equals(row.commandId()) + || !row.recordDigest().equals(row.contentDigest())) { + return false; + } + JsonNode engine = JournalStoreRows.CSI_TERMINAL_JSON.readTree(lines[0]); + JsonNode header = JournalStoreRows.CSI_TERMINAL_JSON.readTree(lines[1]); + JsonNode value = header.path("managedSession"); + requireInitialResource(value.path("definitionRef"), head, "managed-definition"); + requireInitialResource(value.path("rootSnapshotRef"), head, "managed-root"); + if (!"session_execution_engine".equals(engine.path("subtype").asText()) + || !"managed".equals(engine.path("systemPayload").path("engine").asText()) + || !sessionId.equals(engine.path("sessionId").asText()) + || !"managed_session_header_v1".equals(header.path("subtype").asText()) + || !sessionId.equals(header.path("sessionId").asText()) + || value.size() != 7 || value.path("formatVersion").asInt() != JournalStoreRows.STORAGE_VERSION + || !"managed-session/1".equals(value.path("minimumReader").asText()) + || !"managed".equals(value.path("engine").asText()) + || !"hosted-harness".equals(value.path("createdBy").asText()) + || value.has("baseTranscriptProof") || !initialScope(value, head)) { + return false; + } + } else { + if (row.eventCount() < 1 || lines.length != row.eventCount() + 1 + || row.firstSequence() != sequence + 1 + || row.lastSequence() != sequence + row.eventCount() || row.commitDigest() == null) { + return false; + } + var events = JournalStoreRows.CSI_JSON.createArrayNode(); + boolean activationChanged = false; + for (int index = 0; index < row.eventCount(); index++) { + JsonNode record = JournalStoreRows.CSI_TERMINAL_JSON.readTree(lines[index]); + JsonNode event = record.path("managedSession"); + JsonNode payload = event.path("payload"); + String kind = event.path("kind").asText(); + if (!sessionId.equals(record.path("sessionId").asText()) + || !"managed_session_event_v1".equals(record.path("subtype").asText()) + || event.path("v").asInt() != JournalStoreRows.STORAGE_VERSION + || event.path("sequence").asLong(-1) != ++sequence || !initialScope(event, head) + || !("activation.changed".equals(kind) + && "activation".equals(payload.path("subject").path("type").asText()) + && payload.path("activationId").isTextual() + && payload.path("activationId").equals(payload.path("subject").path("scopeId")) + && payload.path("activationId").equals(payload.path("subject").path("activationId")) + && payload.path("epoch").equals(payload.path("subject").path("epoch")) + && payload.path("epoch").asLong() > 0 + && payload.path("epoch").asLong() == row.activationEpoch() + || "domain.committed".equals(kind) + && "session_metadata".equals(payload.path("domain").asText()))) { + return false; + } + if ("domain.committed".equals(kind)) { + requireInitialResource(payload.path("recordRef"), head, "managed-session_metadata"); + } else { + activationChanged = true; + if (!payload.path("installRef").isNull()) { + requireInitialResource(payload.path("installRef"), head, "managed-activation-install"); + } + if (!payload.path("boundaryRef").isNull()) { + requireInitialResource(payload.path("boundaryRef"), head, "managed-activation-boundary"); + } + } + events.add(event); + } + if (!activationChanged && row.activationEpoch() != activationEpoch) { + return false; + } + JsonNode markerRecord = JournalStoreRows.CSI_TERMINAL_JSON.readTree(lines[lines.length - 1]); + JsonNode marker = markerRecord.path("managedSession"); + if (!"managed_session_commit_v1".equals(markerRecord.path("subtype").asText()) + || !sessionId.equals(markerRecord.path("sessionId").asText()) + || !row.transactionId().equals(marker.path("transactionId").asText()) + || !row.commandId().equals(marker.path("commandId").asText()) + || !row.operation().equals(marker.path("operation").asText()) + || !row.contentDigest().equals(marker.path("contentDigest").asText()) + || row.firstSequence() != marker.path("firstSequence").asLong(-1) + || row.lastSequence() != marker.path("lastSequence").asLong(-1) + || row.eventCount() != marker.path("eventCount").asInt(-1) + || !row.eventsDigest().equals(marker.path("eventsDigest").asText()) + || !INITIAL_DIGESTS.digest(JournalStoreRows.CSI_JSON.convertValue(events, Object.class)) + .equals("sha256:" + row.eventsDigest()) + || !INITIAL_DIGESTS.digest(JournalStoreRows.CSI_JSON.convertValue(marker, Object.class)) + .equals("sha256:" + row.commitDigest()) + || !Objects.equals(digest, marker.path("previousCommitDigest").isNull() + ? null : marker.path("previousCommitDigest").asText())) { + return false; + } + } + digest = row.commitDigest(); + activationEpoch = row.activationEpoch(); + } + return revision == head.journalRevision() && sequence == head.committedSequence() + && activationEpoch == head.activationEpoch() + && Objects.equals(digest, head.lastCommitDigest()) && head.equals(storeRows.requireHead(tenantId, sessionId)); + } catch (IOException | RuntimeException unverified) { + return false; + } + } + + static boolean initialScope(JsonNode value, JournalStoreRows.HeadRow head) { + JsonNode key = value.path("sessionKey"); + return head.tenantId().equals(key.path("tenantId").asText()) + && head.workspaceId().equals(key.path("workspaceId").asText()) + && head.sessionId().equals(key.path("sessionId").asText()); + } + + void requireInitialResource(JsonNode ref, JournalStoreRows.HeadRow head, String kind) throws IOException { + WorkspaceRecoveryReader.validateRef(ref); + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(head.tenantId(), head.sessionId()); + var resources = jdbc.query("SELECT " + ManagedSchemaProjection.RESOURCE.columns() + + " FROM ma_resource WHERE resource_key = ? AND session_scope_key = ? AND resource_id = ?", + storeRows.resourceMapper, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, ref.path("resourceId").asText()), scope, ref.path("resourceId").asText()); + if (resources.size() != 1) { + throw JournalStoreRows.journalCorrupt(); + } + JournalStoreRows.ResourceRow resource = resources.getFirst(); + JournalStoreRows.requireResourceScope(resource, head.tenantId(), head.workspaceId(), head.sessionId(), ref.path("resourceId").asText()); + if (!"MYSQL_INLINE".equals(resource.storageKind()) || !kind.equals(resource.kind()) + || !kind.equals(ref.path("kind").asText()) || resource.schemaVersion() != ref.path("schemaVersion").asInt() + || resource.byteLength() != ref.path("byteLength").asLong() || !resource.digest().equals(ref.path("digest").asText())) { + throw JournalStoreRows.journalCorrupt(); + } + storeRows.verifyStoredResource(resource); + if ("managed-definition".equals(kind)) { + JsonNode definition = JournalStoreRows.CSI_TERMINAL_JSON.readTree(resource.bytes()); + if (definition == null || !definition.isObject()) { + throw JournalStoreRows.journalCorrupt(); + } + } + } + + record TransactionSize(long journalRevision, long byteLength) { + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalLifecycleAuthorization.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalLifecycleAuthorization.java new file mode 100644 index 00000000000..9f80c2ddb9b --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalLifecycleAuthorization.java @@ -0,0 +1,199 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedConfigInstall; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionQueryStore; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; + +final class JournalLifecycleAuthorization { + private final JournalFencing fencing; + private final JournalStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules harnessRules; + private final JdbcTemplate jdbc; + private final ManagedExtensionRecordStore extensionRecords; + private final WorkspaceExecutionStore lifecycleExecution; + private final SessionQueryStore lifecycleSessions; + + JournalLifecycleAuthorization(JournalFencing fencing, + JournalStoreRows storeRows, + com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules harnessRules, + JdbcTemplate jdbc, + ManagedExtensionRecordStore extensionRecords, + WorkspaceExecutionStore lifecycleExecution, + SessionQueryStore lifecycleSessions) { + this.fencing = fencing; + this.storeRows = storeRows; + this.harnessRules = harnessRules; + this.jdbc = jdbc; + this.extensionRecords = extensionRecords; + this.lifecycleExecution = lifecycleExecution; + this.lifecycleSessions = lifecycleSessions; + } + + void authorizeLifecycle(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.AuthorizeLifecycleRequest request, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + JournalFencing.lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + JournalFencing.lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + storeRows.ensureSessionRoot(tenantId, sessionId); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + jdbc.queryForList( + "SELECT session_id FROM ma_session WHERE tenant_id = ? AND session_id = ? FOR" + + " UPDATE", + tenantId, + sessionId); + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); + if (request.kind() != null) { + String kind = + jdbc.queryForObject( + "SELECT operation_kind FROM ma_operation WHERE tenant_id = ?" + + " AND session_id = ? AND operation_id = ?", + String.class, + tenantId, + sessionId, + authority.operationId()); + if (!kind.toLowerCase(java.util.Locale.ROOT).equals(request.kind())) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_operation_conflict"); + } + } else if (!"DRAINING" + .equals( + jdbc.queryForObject( + "SELECT phase FROM ma_session_head WHERE tenant_key = ? AND" + + " harness_key = ? AND tenant_id = ? AND harness_session_id =" + + " ?", + String.class, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId))) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_hooks_unsettled"); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if (request.kind() != null) { + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, storeRows.databaseNow(), true); + } else if (!List.of("ACTIVE", "SEALED").contains(head.state()) + || request.writerGeneration() != head.writerGeneration() + || !request.writerId().equals(head.writerId()) + || !JournalFencing.secureEquals(JournalFencing.tokenHash(writerToken), head.leaseTokenHash())) { + throw JournalFencing.writerConflict(); + } + } + + void authorizeOrdinary(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + // Ordinary authorisation fences per Session (drain + head): the + // tenant-wide guard rows stay with lifecycle/publication paths, or + // any long holder of them wedges unrelated Sessions (#13800). The + // Session root is locked before the drain admission reads the head + // row, keeping the root-then-head order of every other writer. + ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + if (!"legacy-close".equals(request.kind()) || !WorkspaceLifecycleStore.legacyClose(jdbc, tenantId, sessionId)) { + fencing.requireOrdinaryAdmission(tenantId, sessionId); + } + if (request.kind() != null && request.kind().startsWith("configuration:")) { + String operation = request.kind().substring("configuration:".length()); + JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); + if (!ManagedConfigInstall.uuid(operation) || ManagedConfigStore.blocked(jdbc, tenantId, sessionId) + && (!ManagedConfigInstall.isPackageConfig(current) || !operation.equals(current.path("operationId").asText()))) { + throw JournalStoreRows.conflict("session_config_recovery_required", "Only the original configuration operation may continue."); + } + } else { + ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, storeRows.databaseNow(), true); + } + + java.util.Map approvalSelection(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { + if (request.kind() != null) { + throw JournalStoreRows.invalid("Approval queries do not accept lifecycle authority."); + } + authorizeOrdinary(tenantId, sessionId, writerToken, request); + var rows = jdbc.queryForList("SELECT agent_id, agent_definition_digest, tool_profile, workspace_id," + + " approval_mode, selected_approval_mode, approval_revision, permission_rules_json FROM ma_session" + + " WHERE tenant_id=? AND session_id=? FOR UPDATE", tenantId, sessionId); + if (rows.size() != 1) { + throw JournalStoreRows.conflict("managed_session_scope_conflict", "The Managed Session is missing."); + } + var row = rows.getFirst(); + String profile = (String) row.get("tool_profile"); + if (row.get("workspace_id") == null + || ("qwen-code".equals(row.get("agent_id")) + ? row.get("agent_definition_digest") != null : row.get("agent_definition_digest") == null) + || !(com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.FILES.equals(profile) + || com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.FILES_V3.equals(profile) + || com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.isPublicShell(profile))) { + return null; + } + String mode = (String) row.get("selected_approval_mode"); + if (mode == null) { + mode = (String) row.get("approval_mode"); + } + long revision = ((Number) row.get("approval_revision")).longValue(); + if (!com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.supportsApprovalMode(mode) + || revision < 0 || revision > 9_007_199_254_740_991L) { + throw JournalStoreRows.conflict("approval_selection_conflict", "The approval selection is invalid."); + } + java.util.Map result = new java.util.LinkedHashMap<>(java.util.Map.of("mode", mode, "revision", revision)); + if (row.get("permission_rules_json") != null) { + try { + var rules = harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules( + JournalStoreRows.CSI_JSON.readValue((String) row.get("permission_rules_json"), java.util.Map.class))); + if (rules != null) { + result.put("permissionRules", rules); + } + } catch (java.io.IOException | IllegalArgumentException invalid) { + throw JournalStoreRows.conflict("approval_selection_conflict", "The permission rules are invalid."); + } + } + return result; + } + + void authorizeNewLifecycleDispatch(String tenantId, String sessionId, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority, + byte[] recordBytes, java.util.function.Function reader) { + boolean v2 = authority != null && jdbc.queryForObject( + "SELECT lifecycle_protocol_version FROM ma_operation WHERE" + + " operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE')" + + " AND tenant_id = ? AND session_id = ? AND" + + " operation_id = ?", Integer.class, + tenantId, sessionId, authority.operationId()) == 2; + boolean resourcePhase = v2 && WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority); + if (authority != null && extensionRecords.hasNewLifecycleDispatch(tenantId, sessionId, recordBytes, + reader, v2, resourcePhase)) { + if (lifecycleExecution == null || lifecycleSessions == null) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); + } + try { + lifecycleExecution.authorizeLifecycle(lifecycleSessions.requireSession(tenantId, sessionId), authority); + } catch (com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException refusal) { + if (refusal.getStatusCode() == 409 && "workspace_unavailable".equals(refusal.getCode())) { + throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked( + "workspace_lifecycle_authorization_revoked"); + } + if (refusal.getStatusCode() == 409) { + throw new ManagedAgentException(409, refusal.getCode(), refusal.getMessage()); + } + throw refusal; + } + } + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceReads.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceReads.java new file mode 100644 index 00000000000..f2dd080dc9b --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceReads.java @@ -0,0 +1,482 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiFilesRetirementGuard; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.Objects; + +final class JournalResourceReads { + private final JournalFencing fencing; + private final JournalStoreRows storeRows; + private final JdbcTemplate jdbc; + private final ToolPublicationObjectStore publicationObjects; + private final ToolPublicationRetentionStore outputRetention; + private final org.springframework.transaction.support.TransactionTemplate ownerReads; + + JournalResourceReads(JournalFencing fencing, + JournalStoreRows storeRows, + JdbcTemplate jdbc, + ToolPublicationObjectStore publicationObjects, + ToolPublicationRetentionStore outputRetention, + org.springframework.transaction.support.TransactionTemplate ownerReads) { + this.fencing = fencing; + this.storeRows = storeRows; + this.jdbc = jdbc; + this.publicationObjects = publicationObjects; + this.outputRetention = outputRetention; + this.ownerReads = ownerReads; + } + + StoredResource readResource(String tenantId, String workspaceId, + String sessionId, String resourceId, String writerToken) { + return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken, true); + } + + StoredResource readSnapshotResource(String tenantId, String workspaceId, String sessionId, + String resourceId, String writerToken) { + return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken, false); + } + + StoredResource readResource(String tenantId, String workspaceId, + String sessionId, String resourceId, String writerToken, boolean updateVerification) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + fencing.requireCredential(tenantId, workspaceId, sessionId, writerToken); + if (updateVerification && ManagedNativeJournalSnapshots.isSnapshotId(resourceId)) { + throw JournalStoreRows.invalid("Native snapshot resources require the journal-snapshots read API."); + } + JournalStoreRows.validateStableId(resourceId, "resourceId"); + JournalStoreRows.HeadRow head = storeRows.requireHead(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, workspaceId, sessionId); + fencing.requireReadGrant(head, writerToken); + try (var lease = outputRetention.read(tenantId, sessionId)) { + String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + JournalStoreRows.ResourceRow resource = storeRows.findResource(scopeKey, resourceId, updateVerification); + if (resource == null) { + throw new ManagedAgentException(404, + ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND, + "The Managed Session resource does not exist."); + } + JournalStoreRows.requireResourceScope(resource, tenantId, workspaceId, sessionId, + resourceId); + storeRows.verifyStoredResource(resource); + if (updateVerification) { + jdbc.update("UPDATE ma_resource SET" + + " last_verified_at = ? WHERE session_scope_key = ?" + + " AND resource_id = ?", + storeRows.databaseNow(), scopeKey, resourceId); + } + byte[] bytes = "TOOL_PUBLICATION".equals(resource.storageKind()) + ? readPublicationObject(resource, lease, updateVerification) : resource.bytes(); + var result = new StoredResource(resource.resourceId(), resource.kind(), + resource.schemaVersion(), resource.byteLength(), + resource.digest(), bytes); + lease.check(); + return result; + } + } + + StoredResource readOwnerResource( + String tenantId, + String workspaceId, + String sessionId, + String resourceId, + String writerToken) { + if (ManagedNativeJournalSnapshots.isSnapshotId(resourceId)) { + fencing.requireCredential(tenantId, workspaceId, sessionId, writerToken); + throw JournalStoreRows.invalid("Native snapshot resources require the journal-snapshots read API."); + } + if (org.springframework.transaction.support.TransactionSynchronizationManager + .isActualTransactionActive() + || org.springframework.transaction.support.TransactionSynchronizationManager + .hasResource(jdbc.getDataSource())) { + throw new IllegalStateException( + "Owner resource reads require a fresh transaction boundary"); + } + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + fencing.requireCredential(tenantId, workspaceId, sessionId, writerToken); + JournalStoreRows.validateStableId(resourceId, "resourceId"); + var guarded = + jdbc.queryForList( + "SELECT csi_guard FROM ma_session" + + " WHERE tenant_id = ? AND session_id = ? AND csi_guard = TRUE", + tenantId, + sessionId); + if (guarded.isEmpty()) { + return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken); + } + NativeRead admission = + ownerReads.execute( + status -> { + var continuation = fencing.lockCsiContinuation(tenantId, sessionId); + var original = continuation == null ? null : continuation.original(); + if (original == null) { + throw JournalFencing.nativeReadConflict(); + } + if (original.state() == com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord.State.DRAINING) { + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + JdbcCsiActivationAdmission.requireCheckpointContinuationRead( + org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(connection), + original, continuation.progress(), resourceId); + return null; + }); + } else { + original.requireAdmission(); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, workspaceId, sessionId); + JournalFencing.requireCsiWriter(original, head); + fencing.requireReadGrant(head, writerToken); + JournalStoreRows.ResourceRow resource = + nativeResourceMetadata(tenantId, sessionId, resourceId); + if (resource == null) { + throw new ManagedAgentException( + 404, + ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND, + "The Managed Session resource does not exist."); + } + JournalStoreRows.requireResourceScope( + resource, tenantId, workspaceId, sessionId, resourceId); + if (!"MYSQL_INLINE".equals(resource.storageKind()) + || resource.objectKey() != null + || resource.objectVersionId() != null + || resource.encryptionKeyId() != null) { + throw JournalStoreRows.conflict( + "csi_inline_resource_required", + "The original owner reader requires an inline resource."); + } + String id = java.util.UUID.randomUUID().toString(); + fencing.requireCsiReadGrant(continuation, head, writerToken); + long started = ToolPublicationRetentionStore.now(jdbc); + long expires = + Math.addExact( + started, + ToolPublicationRetentionStore.READ_BUDGET_MILLIS); + fencing.requireCsiLease(continuation, expires, expires); + long readBindingVersion = continuation.progress().sealedBindingVersion() == null ? original.version() : continuation.progress().sealedBindingVersion(); + var evidence = + JournalStoreRows.CSI_JSON.createObjectNode() + .put("read_id", id) + .put("tenant_id", tenantId) + .put("workspace_id", workspaceId) + .put("session_id", sessionId) + .put("request_key", original.request().requestKey()) + .put("binding_id", original.bindingId()) + .put("runtime_generation", original.generation()) + .put("binding_version", readBindingVersion) + .put("writer_id", head.writerId()) + .put("writer_generation", head.writerGeneration()) + .put("writer_token_hash", head.leaseTokenHash()) + .put("journal_revision", head.journalRevision()) + .put("activation_id", head.activationId()) + .put("activation_epoch", head.activationEpoch()) + .put("resource_id", resourceId) + .put("kind", resource.kind()) + .put("schema_version", resource.schemaVersion()) + .put("byte_length", resource.byteLength()) + .put("sha256", resource.digest()) + .put("storage_kind", resource.storageKind()) + .put("resource_state", resource.state()) + .put("started_at", started) + .put("delivery_expires_at", expires); + String encoded = evidence.toString(); + String receipt = + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key("csi-read", id); + jdbc.update( + "INSERT INTO ma_request_receipt (receipt_key, namespace, tenant_key, tenant_id, scope_kind, scope_key, scope_id, idempotency_key, idempotency_digest, request_digest, operation, state, session_id, result_json, gmt_create, gmt_modified) VALUES (?, 'SESSION_COMMAND', ?, ?, 'SESSION', ?, ?, ?, ?, ?, 'CSI_RESOURCE_READ', 'OPEN', ?, ?, ?, ?)", + receipt, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(tenantId), + tenantId, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key("csi-read", tenantId, sessionId), + sessionId, + id, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(id), + JournalStoreRows.sha256(encoded.getBytes(StandardCharsets.UTF_8)), + sessionId, + encoded, + started, + started); + fencing.requireCsiReadGrant(continuation, head, writerToken); + jdbc.update( + "INSERT INTO ma_read_lease (lease_id, tenant_key, session_key," + + " retirement_generation, expires_at) VALUES (?, ?, ?, 0," + + " ?)", + id, + ToolPublicationRetentionStore.hash(tenantId), + ToolPublicationRetentionStore.hash(sessionId), + expires); + var persisted = nativeReadReceipt(id, false); + return new NativeRead(id, original, head, resource, persisted, expires); + }); + JournalStoreRows.ResourceRow fetched; + try { + fetched = fetchNativeResource(admission); + } catch (RuntimeException | Error failure) { + try { + finishNativeRead(admission, null, false, writerToken); + } catch (RuntimeException | Error completion) { + failure.addSuppressed(completion); + } + throw failure; + } + ManagedAgentException failure = finishNativeRead(admission, fetched, true, writerToken); + if (failure != null) { + throw failure; + } + return new StoredResource( + fetched.resourceId(), + fetched.kind(), + fetched.schemaVersion(), + fetched.byteLength(), + fetched.digest(), + fetched.bytes()); + } + + java.util.Map nativeReadReceipt(String id, boolean lock) { + return jdbc.queryForMap( + "SELECT " + ManagedSchemaProjection.REQUEST_RECEIPT.columns() + " FROM ma_request_receipt WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND operation = 'CSI_RESOURCE_READ'" + + (lock ? " FOR UPDATE" : ""), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + "csi-read", id)); + } + + void completeNativeRead( + NativeRead admission, String state, Long endedAt, String outcome) { + com.fasterxml.jackson.databind.node.ObjectNode evidence; + try { + evidence = + (com.fasterxml.jackson.databind.node.ObjectNode) + JournalStoreRows.CSI_JSON.readTree((String) admission.persisted().get("result_json")); + } catch (java.io.IOException error) { + throw new IllegalStateException("Original read receipt is corrupt", error); + } + evidence.put("ended_at", endedAt).put("outcome_code", outcome); + if (jdbc.update( + "UPDATE ma_request_receipt SET state = ?, result_json = ?, gmt_modified = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND operation = 'CSI_RESOURCE_READ' AND state = 'OPEN'", + state, + evidence.toString(), + ToolPublicationRetentionStore.now(jdbc), + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + "csi-read", admission.id())) + != 1) { + throw JournalFencing.nativeReadConflict(); + } + } + + JournalStoreRows.ResourceRow nativeResourceMetadata(String tenant, String session, String resource) { + var rows = + jdbc.query( + "SELECT tenant_id, workspace_id, session_id, resource_id, kind," + + " schema_version, byte_length, sha256, storage_kind, NULL AS" + + " inline_bytes, object_key, object_version_id, encryption_key_id," + + " state, capture_state, deletion_operation_key FROM ma_resource WHERE" + + " session_scope_key = ? AND resource_id = ? AND resource_key = ? FOR" + + " UPDATE", + storeRows.resourceMapper, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), + resource, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), resource)); + return rows.isEmpty() ? null : rows.getFirst(); + } + + JournalStoreRows.ResourceRow fetchNativeResource(NativeRead admission) { + try (var connection = jdbc.getDataSource().getConnection(); + var statement = + connection.prepareStatement( + "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE session_scope_key = ? AND resource_id = ? AND resource_key = ?")) { + statement.setQueryTimeout(10); + statement.setString( + 1, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(admission.head().tenantId(), admission.head().sessionId())); + statement.setBytes( + 2, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity( + admission.resource().resourceId())); + statement.setString( + 3, + com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey( + admission.head().tenantId(), admission.head().sessionId()), + admission.resource().resourceId())); + try (var rows = statement.executeQuery()) { + return rows.next() ? storeRows.resourceMapper.mapRow(rows, 0) : null; + } + } catch (SQLException failure) { + throw new org.springframework.dao.DataAccessResourceFailureException( + "Original resource fetch did not complete", failure); + } + } + + ManagedAgentException finishNativeRead( + NativeRead admission, JournalStoreRows.ResourceRow fetched, boolean returned, String writerToken) { + return ownerReads.execute( + status -> { + JournalStoreRows.HeadRow admittedHead = admission.head(); + var continuation = + fencing.lockCsiContinuation(admittedHead.tenantId(), admittedHead.sessionId()); + var original = continuation == null ? null : continuation.original(); + if (original == null + || !original.request().equals(admission.original().request()) + || !original.bindingId().equals(admission.original().bindingId()) + || original.generation() != admission.original().generation() + || original.version() < admission.original().version()) { + throw JournalFencing.nativeReadConflict(); + } + original.requireAdmission(); + JournalStoreRows.HeadRow head = + storeRows.requireHeadForUpdate(admittedHead.tenantId(), admittedHead.sessionId()); + var persisted = nativeReadReceipt(admission.id(), true); + if (!persisted.equals(admission.persisted())) { + throw JournalFencing.nativeReadConflict(); + } + if (!returned) { + fencing.requireCsiReadGrant(continuation, head, writerToken); + completeNativeRead(admission, "UNKNOWN", null, "jdbc_fetch_unknown"); + return null; + } + ManagedAgentException failure = null; + try { + storeRows.requireHeadScope( + head, + admittedHead.tenantId(), + admittedHead.workspaceId(), + admittedHead.sessionId()); + fencing.requireReadGrant(head, writerToken); + if (!Objects.equals(head.writerId(), admittedHead.writerId()) + || head.writerGeneration() != admittedHead.writerGeneration()) { + throw JournalFencing.writerConflict(); + } + var lease = + jdbc.queryForList( + "SELECT expires_at, retirement_generation FROM" + + " ma_read_lease WHERE lease_id = ? AND tenant_key = ?" + + " AND session_key = ? FOR UPDATE", + admission.id(), + ToolPublicationRetentionStore.hash(head.tenantId()), + ToolPublicationRetentionStore.hash(head.sessionId())); + long now = ToolPublicationRetentionStore.now(jdbc); + if (lease.size() != 1 + || ((Number) lease.getFirst().get("expires_at")).longValue() + != admission.expires() + || admission.expires() <= now + || ((Number) lease.getFirst().get("retirement_generation")) + .longValue() + != 0) { + throw JournalStoreRows.conflict( + "tool_output_read_expired", + "The original output read delivery lease expired."); + } + JournalStoreRows.ResourceRow current = + nativeResourceMetadata( + head.tenantId(), + head.sessionId(), + admission.resource().resourceId()); + if (!sameNativeResource(admission.resource(), fetched) + || !sameNativeResource(admission.resource(), current)) { + throw JournalStoreRows.resourceCorrupt(); + } + storeRows.verifyStoredResource(fetched); + fencing.requireCsiReadGrant(continuation, head, writerToken); + if (admission.expires() <= ToolPublicationRetentionStore.now(jdbc)) { + throw JournalFencing.nativeReadConflict(); + } + jdbc.update( + "UPDATE ma_resource SET last_verified_at = ?" + + " WHERE session_scope_key = ? AND resource_id = ?", + storeRows.databaseNow(), + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(head.tenantId(), head.sessionId()), + fetched.resourceId()); + } catch (ManagedAgentException rejected) { + failure = rejected; + } + fencing.requireCsiReadGrant(continuation, head, writerToken); + if (admission.expires() <= ToolPublicationRetentionStore.now(jdbc)) { + throw JournalFencing.nativeReadConflict(); + } + completeNativeRead( + admission, + "RETURNED", + ToolPublicationRetentionStore.now(jdbc), + failure == null ? "verified" : failure.getCode()); + fencing.requireCsiReadGrant(continuation, head, writerToken); + jdbc.update("DELETE FROM ma_read_lease WHERE lease_id = ?", admission.id()); + return failure; + }); + } + + static boolean sameNativeResource(JournalStoreRows.ResourceRow expected, JournalStoreRows.ResourceRow actual) { + return actual != null + && Objects.equals(expected.tenantId(), actual.tenantId()) + && Objects.equals(expected.workspaceId(), actual.workspaceId()) + && Objects.equals(expected.sessionId(), actual.sessionId()) + && Objects.equals(expected.resourceId(), actual.resourceId()) + && Objects.equals(expected.kind(), actual.kind()) + && expected.schemaVersion() == actual.schemaVersion() + && expected.byteLength() == actual.byteLength() + && Objects.equals(expected.digest(), actual.digest()) + && Objects.equals(expected.storageKind(), actual.storageKind()) + && Objects.equals(expected.state(), actual.state()) + && actual.objectKey() == null + && actual.objectVersionId() == null + && actual.encryptionKeyId() == null; + } + + record NativeRead( + String id, + JdbcCsiFilesRetirementGuard.Original original, + JournalStoreRows.HeadRow head, + JournalStoreRows.ResourceRow resource, + java.util.Map persisted, + long expires) {} + + InputStream guardedPublicationResource(JournalStoreRows.ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease) { + lease.requireScope(resource.tenantId(), resource.sessionId()); + var rows = jdbc.queryForList("SELECT scope_key, publication_id FROM ma_resource" + + " WHERE scope_key = ? AND resource_id = ? AND object_key = ?", + ToolPublicationDataStore.scope(resource.tenantId(), resource.workspaceId(), resource.sessionId()), + resource.resourceId(), resource.objectKey()); + if (rows.size() != 1) { + throw JournalStoreRows.resourceCorrupt(); + } + return outputRetention.open(resource.objectKey(), publicationObjects, lease, () -> {}); + } + + byte[] readPublicationObject(JournalStoreRows.ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease) { + return readPublicationObject(resource, lease, true); + } + + byte[] readPublicationObject(JournalStoreRows.ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease, boolean quarantine) { + if (publicationObjects == null) { + throw JournalStoreRows.resourceCorrupt(); + } + try (InputStream stream = guardedPublicationResource(resource, lease)) { + byte[] bytes = stream.readNBytes(Math.toIntExact(resource.byteLength()) + 1); + if (bytes.length != resource.byteLength() + || !JournalStoreRows.sha256(bytes).equals(resource.digest())) { + var error = JournalStoreRows.resourceCorrupt(); + try { + if (quarantine) { + outputRetention.quarantineResource( + ToolPublicationDataStore.scope(resource.tenantId(), resource.workspaceId(), resource.sessionId()), + resource.resourceId(), resource.objectKey()); + } + } catch (RuntimeException cleanup) { + error.addSuppressed(cleanup); + } + throw error; + } + return bytes; + } catch (IOException error) { + throw new IllegalStateException("Tool publication object read failed", error); + } + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceWrites.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceWrites.java new file mode 100644 index 00000000000..7cf5caa3131 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalResourceWrites.java @@ -0,0 +1,276 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.catalog.AgentDefinitionExtensions; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.PublishToolResultRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.ToolResultResourceRef; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class JournalResourceWrites { + private final JournalFencing fencing; + private final JournalStoreRows storeRows; + private final JdbcTemplate jdbc; + + JournalResourceWrites(JournalFencing fencing, + JournalStoreRows storeRows, + JdbcTemplate jdbc) { + this.fencing = fencing; + this.storeRows = storeRows; + this.jdbc = jdbc; + } + + ToolResultResourceRef publishAgentInstructions(String tenantId, + String workspaceId, String sessionId, String instructions) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); + byte[] bytes = instructions.getBytes(StandardCharsets.UTF_8); + if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES) { + throw JournalStoreRows.payloadTooLarge("Agent instructions exceed 64 KiB."); + } + return publishAgentResource(tenantId, workspaceId, sessionId, + "managed-agent-instructions", "agent-instructions-", bytes); + } + + ToolResultResourceRef publishAgentSkills(String tenantId, + String workspaceId, String sessionId, List> skills) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); + return publishAgentResource(tenantId, workspaceId, sessionId, + "managed-agent-skills", "agent-skills-", AgentDefinitionExtensions.skillsBytes(skills)); + } + + ToolResultResourceRef publishInheritedExtensionResource(String tenantId, String workspaceId, String sessionId, StoredResource source) { + JournalStoreRows.validateScope(tenantId, workspaceId, sessionId); + if (ManagedNativeJournalSnapshots.isSnapshotId(source.resourceId())) { + throw JournalStoreRows.invalid("Native snapshot resources require fenced snapshot publication."); + } + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); + if (!Set.of("managed-extension-config-bundle", "managed-extension-contributions", "managed-extension-contributions-part").contains(source.kind()) + || source.schemaVersion() != 1 || source.byteLength() > ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES + || source.bytes().length != source.byteLength() || !JournalStoreRows.sha256(source.bytes()).equals(source.digest())) { + throw JournalStoreRows.invalid("Invalid inherited extension resource"); + } + return publishAgentResource(tenantId, workspaceId, sessionId, source.kind(), source.bytes(), source.resourceId()); + } + + ToolResultResourceRef publishAgentResource(String tenantId, + String workspaceId, String sessionId, String kind, String prefix, byte[] bytes) { + return publishAgentResource(tenantId, workspaceId, sessionId, kind, bytes, prefix + JournalStoreRows.sha256(bytes)); + } + + ToolResultResourceRef publishAgentResource(String tenantId, String workspaceId, String sessionId, + String kind, byte[] bytes, String resourceId) { + String digest = JournalStoreRows.sha256(bytes); + String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + Timestamp now = storeRows.databaseNow(); + JournalStoreRows.ResourceRow existing = storeRows.findResource(scopeKey, resourceId); + if (existing == null) { + jdbc.update( + "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id," + + " workspace_id, session_id, resource_id, kind, schema_version," + + " byte_length, sha256, storage_kind, inline_bytes, publish_command_id," + + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?," + + " ?, 1, ?, ?, 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resourceId), + scopeKey, + tenantId, + workspaceId, + sessionId, + resourceId, + kind, + bytes.length, + digest, + bytes, + resourceId, + now.getTime(), + now.getTime()); + } else { + JournalStoreRows.requireResourceScope(existing, tenantId, workspaceId, sessionId, resourceId); + storeRows.verifyStoredResource(existing); + if (!kind.equals(existing.kind()) + || existing.schemaVersion() != 1 + || existing.byteLength() != bytes.length + || !digest.equals(existing.digest())) { + throw JournalStoreRows.conflict("managed_session_resource_conflict", + "Agent configuration resource does not match."); + } + } + return new ToolResultResourceRef(resourceId, kind, 1, bytes.length, digest); + } + + ToolResultResourceRef publishToolResult(String tenantId, String sessionId, + String writerToken, PublishToolResultRequest request) { + return publishToolResult(tenantId, sessionId, writerToken, request, null); + } + + ToolResultResourceRef publishToolResult(String tenantId, String sessionId, + String writerToken, PublishToolResultRequest request, + com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + fencing.checkLifecycleWriter(tenantId, sessionId, authority); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + JournalStoreRows.validateStableId(request.resourceId(), "resourceId"); + if (ManagedNativeJournalSnapshots.isSnapshotId(request.resourceId())) { + throw JournalStoreRows.invalid("Native snapshot resources require fenced snapshot publication."); + } + JournalStoreRows.validateDigest(request.digest(), "resource digest", false); + int limit = JournalStoreRows.toolResultLimit(request.kind()); + if (limit == 0 || request.schemaVersion() != 1 || request.byteLength() < 1) { + throw JournalStoreRows.invalid("Tool result resource kind, version or length is invalid."); + } + if (request.byteLength() > limit || request.bytesBase64() != null + && request.bytesBase64().length() > 4 * ((limit + 2) / 3)) { + throw JournalStoreRows.payloadTooLarge("Tool result resource exceeds its byte limit."); + } + byte[] bytes = JournalStoreRows.decodeBase64(request.bytesBase64(), limit, "resource bytesBase64"); + if (bytes.length != request.byteLength() || !JournalStoreRows.sha256(bytes).equals(request.digest())) { + throw JournalStoreRows.invalid("Tool result bytes do not match their length or digest."); + } + fencing.requirePhysicalSessionOpen(tenantId, request.workspaceId(), sessionId); + // Tool-result publication lane: keeps the tenant CAS row before the + // Session root the CSI check locks and before the head row, the + // order retention reads take (CAS row, root, head) and the same as + // before #13800 decoupled ordinary traffic. + JournalFencing.lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + // After both bounded waits, so the CSI check's read does not pin this + // transaction's snapshot ahead of them. + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + var continuation = fencing.lockCsiContinuation(tenantId, sessionId); + var csiOriginal = continuation == null ? null : continuation.original(); + if (csiOriginal != null) { + csiOriginal.requireContinuation(); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + // Unlike the other writer paths, a v2 claim fences this lane even + // for the claiming lifecycle authority: tool results are never + // published against a Session claimed under lifecycle protocol v2. + fencing.requireNoLifecycleClaim(tenantId, sessionId); + JournalFencing.requireCsiWriter(csiOriginal, head); + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, now, true); + fencing.requireInstalledCsiWriter(csiOriginal); + String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + JournalStoreRows.ResourceRow existing = storeRows.findResource(scopeKey, request.resourceId()); + fencing.requireCsiWriterCurrent( + continuation, head, request.writerId(), request.writerGeneration(), writerToken); + if (existing == null) { + jdbc.update( + "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id," + + " workspace_id, session_id, resource_id, kind, schema_version," + + " byte_length, sha256, storage_kind, inline_bytes, publish_command_id," + + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?," + + " ?, 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, request.resourceId()), + scopeKey, + tenantId, + request.workspaceId(), + sessionId, + request.resourceId(), + request.kind(), + request.schemaVersion(), + request.byteLength(), + request.digest(), + bytes, + request.resourceId(), + now.getTime(), + now.getTime()); + } else { + JournalStoreRows.requireResourceScope(existing, tenantId, request.workspaceId(), sessionId, request.resourceId()); + storeRows.verifyStoredResource(existing); + if (!request.kind().equals(existing.kind()) || request.schemaVersion() != existing.schemaVersion() + || request.byteLength() != existing.byteLength() || !request.digest().equals(existing.digest())) { + throw JournalStoreRows.conflict( + "managed_session_resource_conflict", + "A resourceId was reused with different content metadata."); + } + } + fencing.requireCsiCurrentTime(continuation); + return new ToolResultResourceRef(request.resourceId(), request.kind(), request.schemaVersion(), + request.byteLength(), request.digest()); + } + + void commitResources(String scopeKey, String tenantId, + String sessionId, CommitTransactionRequest request, + long revision, Timestamp now, + List resources, + Runnable writeFence) { + Set resourceIds = new HashSet<>(); + for (ValidatedResource input : resources) { + CommitResource resource = input.resource(); + if (!resourceIds.add(resource.resourceId())) { + throw JournalStoreRows.invalid("resources contains a duplicate resourceId."); + } + JournalStoreRows.ResourceRow existing = storeRows.findResource(scopeKey, + resource.resourceId()); + writeFence.run(); + if (existing == null) { + if (input.bytes() == null) { + throw JournalStoreRows.conflict( + ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, + "A referenced Managed Session resource is" + + " missing."); + } + jdbc.update("INSERT INTO ma_resource" + + " (resource_key, session_scope_key, tenant_id," + + " workspace_id, session_id, resource_id," + + " kind, schema_version, byte_length, sha256," + + " storage_kind, inline_bytes," + + " publish_command_id, state, gmt_create," + + " last_verified_at) VALUES (?, ?, ?, ?, ?, ?," + + " ?, ?, ?, ?, 'MYSQL_INLINE', ?, ?," + + " 'REFERENCED', ?, ?)", + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resource.resourceId()), scopeKey, tenantId, request.workspaceId(), sessionId, + resource.resourceId(), resource.kind(), + resource.schemaVersion(), resource.byteLength(), + resource.digest(), input.bytes(), request.commandId(), + now.getTime(), now.getTime()); + } else { + JournalStoreRows.requireResourceScope(existing, tenantId, + request.workspaceId(), sessionId, + resource.resourceId()); + storeRows.verifyStoredResource(existing); + if (!resource.kind().equals(existing.kind()) + || resource.schemaVersion() + != existing.schemaVersion() + || resource.byteLength() != existing.byteLength() + || !resource.digest().equals(existing.digest())) { + throw JournalStoreRows.conflict("managed_session_resource_conflict", + "A resourceId was reused with different" + + " content metadata."); + } + } + jdbc.update( + "INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key," + + " owner_revision, resource_key, tenant_id, workspace_id, session_id," + + " journal_revision, resource_id, gmt_create) VALUES (?, 'JOURNAL', ?, ?," + + " ?, ?, ?, ?, ?, ?, ?)", + scopeKey, + scopeKey, + revision, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resource.resourceId()), + tenantId, + request.workspaceId(), + sessionId, + revision, + resource.resourceId(), + now.getTime()); + } + } + + record ValidatedResource(CommitResource resource, byte[] bytes) { + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalStoreRows.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalStoreRows.java new file mode 100644 index 00000000000..ddbff30ab59 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalStoreRows.java @@ -0,0 +1,707 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredTransaction; +import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.codec.digest.DigestUtils; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Base64; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +final class JournalStoreRows { + private final com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion; + private final JdbcTemplate jdbc; + + JournalStoreRows(com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion, + JdbcTemplate jdbc) { + this.readerVersion = readerVersion; + this.jdbc = jdbc; + this.headMapper = (result, row) -> + new HeadRow(result.getString("tenant_id"), + result.getString("workspace_id"), + result.getString("session_id"), + result.getString("state"), + result.getInt("storage_version"), + result.getLong("writer_generation"), + result.getString("writer_id"), + ManagedSchemaTime.timestamp(result, "writer_lease_until"), + result.getString("lease_token_hash"), + result.getLong("journal_revision"), + result.getLong("committed_sequence"), + result.getString("last_commit_digest"), + result.getLong("activation_epoch"), + result.getString("latest_checkpoint_resource_id"), + result.getLong("compacted_through_revision"), + result.getString("recovery_status"), + result.getString("recovery_detail_code"), + result.getString("activation_id"), + result.getString("activation_phase"), + result.getObject("activation_event_epoch", Long.class), + result.getObject("activation_expires_at", Long.class), + result.getObject("activation_head_revision", Long.class)); + this.transactionMapper = (result, row) -> transactionRow(result); + this.resourceMapper = (result, row) -> + new ResourceRow( + result.getString("tenant_id"), + result.getString("workspace_id"), + result.getString("session_id"), + result.getString("resource_id"), + result.getString("kind"), + result.getInt("schema_version"), + result.getLong("byte_length"), + result.getString("sha256"), + result.getString("storage_kind"), + result.getBytes("inline_bytes"), + result.getString("object_key"), + result.getString("object_version_id"), + result.getString("encryption_key_id"), + result.getString("state"), + result.getString("capture_state"), + result.getString("deletion_operation_key")); + } + + static final int STORAGE_VERSION = 1; + + static final ObjectMapper CSI_JSON = new ObjectMapper(); + + static final ObjectMapper CSI_TERMINAL_JSON = CSI_JSON.copy().enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) + .disable(com.fasterxml.jackson.databind.MapperFeature.ALLOW_COERCION_OF_SCALARS) + .disable(com.fasterxml.jackson.databind.DeserializationFeature.ACCEPT_FLOAT_AS_INT); + + static final int MAX_ID_BYTES = 512; + + static final int MAX_TEXT_BYTES = 4096; + + static final Pattern DIGEST = Pattern.compile("^[0-9a-f]{64}$"); + + static final Set HEAD_STATES = Set.of( + "ACTIVE", "SEALED", "DELETING", "DELETED"); + + static final Set RECOVERY_STATES = Set.of( + "READY", "BLOCKED_RESOURCE", "BLOCKED_WORKSPACE", + "BLOCKED_EXECUTION"); + + final RowMapper headMapper; + + final RowMapper transactionMapper; + + final RowMapper resourceMapper; + + boolean usesDataSource(javax.sql.DataSource source) { + return source != null && jdbc.getDataSource() == source; + } + + RestoreHead restoreHead(HeadRow head) { + boolean renewed = "active".equals(head.activationPhase()) && !jdbc.queryForList( + "SELECT 1 FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " AND activation_epoch = ? AND activation_lease_until IS NOT NULL LIMIT 1", + head.tenantId(), head.sessionId(), head.activationEpoch()).isEmpty(); + var lease = renewed ? activationLeaseRow(head).lease() : null; + return new RestoreHead(head.state(), head.storageVersion(), + head.writerGeneration(), head.journalRevision(), + head.committedSequence(), head.lastCommitDigest(), + head.activationEpoch(), head.latestCheckpointResourceId(), + head.compactedThroughRevision(), head.recoveryStatus(), + head.recoveryDetailCode(), lease); + } + + record ActivationLeaseRow(long revision, String workerId, ManagedSessionStoreModels.ActivationLease lease) { + } + + ActivationLeaseRow activationLeaseRow(HeadRow head) { + var rows = jdbc.query("SELECT journal_revision, record_bytes, record_digest, activation_lease_until," + + " activation_renewal_seq FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" + + " AND activation_epoch = ? AND operation IN ('installActivation', 'renewActivation')" + + " ORDER BY journal_revision DESC LIMIT 1", (result, index) -> { + try { + byte[] raw = result.getBytes("record_bytes"); + if (raw == null || !sha256(raw).equals(result.getString("record_digest"))) { + throw journalCorrupt(); + } + String first = new String(raw, StandardCharsets.UTF_8).split("\n", 2)[0]; + JsonNode event = CSI_TERMINAL_JSON.readTree(first).path("managedSession"); + JsonNode payload = event.path("payload"); + if (!"activation.changed".equals(event.path("kind").asText()) + || !"active".equals(payload.path("phase").asText())) { + throw journalCorrupt(); + } + long renewal = payload.path("renewalSeq").asLong(0); + long until = payload.path("expiresAt").asLong(-1); + Long savedUntil = result.getObject("activation_lease_until", Long.class); + Long savedRenewal = result.getObject("activation_renewal_seq", Long.class); + if ((savedUntil == null) != (savedRenewal == null) + || savedUntil != null && (savedUntil < until || savedRenewal <= renewal + || savedRenewal > ManagedSessionStoreModels.MAX_SAFE_COUNTER)) { + throw journalCorrupt(); + } + var lease = new ManagedSessionStoreModels.ActivationLease( + payload.path("activationId").textValue(), payload.path("epoch").asLong(), + savedRenewal == null ? renewal : savedRenewal, + savedUntil == null ? until : savedUntil); + if (!Objects.equals(head.activationId(), lease.activationId()) + || !Objects.equals(head.activationEventEpoch(), lease.epoch()) + || !Objects.equals(head.activationExpiresAt(), lease.expiresAt())) { + throw journalCorrupt(); + } + return new ActivationLeaseRow(result.getLong("journal_revision"), + payload.path("workerId").textValue(), lease); + } catch (java.io.IOException error) { + throw journalCorrupt(); + } + }, head.tenantId(), head.sessionId(), head.activationEpoch()); + if (rows.size() != 1) { + throw journalCorrupt(); + } + return rows.getFirst(); + } + + static int toolResultLimit(String kind) { + if (kind == null) { + return 0; + } + return switch (kind) { + case "managed-tool-result-content" -> 1024 * 1024; + case "managed-tool-result-page" -> 256 * 1024; + case "managed-tool-result-manifest" -> 64 * 1024; + default -> 0; + }; + } + + /** A committed resource of this transaction's Session, verified. */ + StoredResource storedResource(String scopeKey, String tenantId, + String workspaceId, String sessionId, String resourceId) { + ResourceRow resource = findResource(scopeKey, resourceId); + if (resource == null) { + throw conflict(ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, + "A referenced Managed Session resource is missing."); + } + requireResourceScope(resource, tenantId, workspaceId, sessionId, + resourceId); + if (!"REFERENCED".equals(resource.state()) + && !("PUBLISHED".equals(resource.state()) + && Set.of("managed-agent-instructions", "managed-agent-skills").contains(resource.kind()))) { + throw conflict(ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, + "A referenced Managed Session resource is missing."); + } + verifyStoredResource(resource); + return new StoredResource(resource.resourceId(), resource.kind(), + resource.schemaVersion(), resource.byteLength(), + resource.digest(), resource.bytes()); + } + + HeadRow findHeadForUpdate(String tenantId, String sessionId) { + // Ordinary writers serialize on their own Session's rows only: the + // Session root, then the head — the per-Session order retirement + // also takes behind its tenant guard. Both are locking reads, so + // neither pins this transaction's REPEATABLE READ snapshot, and + // requireLive below reads after the waits end. The tenant-wide CAS + // row stays with explicit publication paths so ordinary writes never + // queue on it (#13800). + ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); + List rows = + jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION' FOR UPDATE", + headMapper, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); + return rows.isEmpty() ? null : rows.get(0); + } + + HeadRow requireHeartbeatHead(String tenantId, String sessionId) { + // An ordinary heartbeat holds no tenant fence (#13800): retirement + // serializes with it on the Session root, which the CSI check locked + // before its first consistent read, and on the head row, locked here + // before requireLive reads. + var heads = + jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" + + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'" + + " FOR UPDATE", + headMapper, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); + if (heads.isEmpty()) { + throw sessionNotFound(); + } + return heads.getFirst(); + } + + HeadRow requireHeadForUpdate(String tenantId, String sessionId) { + HeadRow head = findHeadForUpdate(tenantId, sessionId); + if (head == null) { + throw sessionNotFound(); + } + return head; + } + + HeadRow requireHead(String tenantId, String sessionId) { + List rows = + jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" + + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", + headMapper, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + if (rows.isEmpty()) { + throw sessionNotFound(); + } + return rows.get(0); + } + + TransactionRow findTransactionByCommand(String tenantId, + String sessionId, String commandKeyHash) { + List rows = jdbc.query( + "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + " FROM ma_session_journal_tx" + + " WHERE tenant_id = ? AND session_id = ?" + + " AND command_key_hash = ?", + transactionMapper, tenantId, sessionId, commandKeyHash); + return rows.isEmpty() ? null : rows.get(0); + } + + ResourceRow findResource(String scopeKey, String resourceId) { + return findResource(scopeKey, resourceId, true); + } + + ResourceRow findResource(String scopeKey, String resourceId, boolean lock) { + List rows = + jdbc.query( + "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE resource_key = ? AND session_scope_key = ?" + + " AND resource_id = ?" + (lock ? " FOR UPDATE" : ""), + resourceMapper, + com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resourceId), + scopeKey, + resourceId); + return rows.isEmpty() ? null : rows.get(0); + } + + static TransactionRow transactionRow(ResultSet result) + throws SQLException { + return new TransactionRow(result.getString("workspace_id"), + result.getLong("journal_revision"), + result.getString("transaction_id"), + result.getString("operation"), + result.getString("command_id"), + result.getString("content_digest"), + result.getLong("first_sequence"), + result.getLong("last_sequence"), + result.getInt("event_count"), + result.getString("events_digest"), + result.getString("previous_commit_digest"), + result.getString("commit_digest"), + result.getLong("writer_generation"), + result.getString("writer_id"), + result.getString("writer_token_hash"), + result.getLong("activation_epoch"), + result.getString("latest_checkpoint_resource_id"), + result.getString("record_encoding"), + result.getBytes("record_bytes"), + result.getLong("byte_length"), + result.getString("record_digest")); + } + + StoredTransaction storedTransaction(TransactionRow row) { + if (!"identity".equals(row.recordEncoding()) + || row.recordBytes() == null + || row.recordBytes().length != row.byteLength() + || !sha256(row.recordBytes()).equals(row.recordDigest())) { + throw journalCorrupt(); + } + return new StoredTransaction(row.journalRevision(), + row.transactionId(), row.operation(), row.commandId(), + row.contentDigest(), row.firstSequence(), + row.lastSequence(), row.eventCount(), row.eventsDigest(), + row.previousCommitDigest(), row.commitDigest(), + row.writerGeneration(), row.activationEpoch(), + row.latestCheckpointResourceId(), + row.recordEncoding(), Base64.getEncoder() + .encodeToString(row.recordBytes()), + row.byteLength(), row.recordDigest()); + } + + Timestamp databaseNow() { + Timestamp now = jdbc.queryForObject("SELECT " + ManagedSchemaTime.SQL_NOW, + (row, index) -> ManagedSchemaTime.timestamp(row, 1)); + if (now == null) { + throw new IllegalStateException("Database time is unavailable"); + } + return now; + } + + static long epochMillis(Timestamp timestamp) { + return timestamp.getTime(); + } + + static long increment(long value, String label) { + if (value < 0 + || value >= ManagedSessionStoreModels.MAX_SAFE_COUNTER) { + throw conflict("managed_session_counter_exhausted", + "The " + label + " cannot advance."); + } + return value + 1; + } + + static void validateScope(String tenantId, String workspaceId, + String sessionId) { + validateText(tenantId, "tenantId", 128); + validateKeyComponent(workspaceId, "workspaceId"); + validateKeyComponent(sessionId, "sessionId"); + } + + static void validateKeyComponent(String value, String label) { + validateStableId(value, label); + if (value.contains("/") || value.contains("\\") + || value.chars().allMatch(character -> character == '.')) { + throw invalid(label + " is not a safe key component."); + } + } + + static void validateStableId(String value, String label) { + validateText(value, label, MAX_ID_BYTES); + } + + static void validateText(String value, String label, + int maxBytes) { + if (!isValidText(value, maxBytes)) { + throw invalid(label + " is invalid."); + } + } + + static boolean isValidText(String value, int maxBytes) { + if (value == null || value.isEmpty() || value.chars().anyMatch(character -> character < 32 || character == 127)) { + return false; + } + try { + return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity(value).length <= maxBytes; + } catch (IllegalArgumentException invalidUnicode) { + return false; + } + } + + static void validateDigest(String value, String label, + boolean nullable) { + if (value == null && nullable) { + return; + } + if (value == null || !DIGEST.matcher(value).matches()) { + throw invalid(label + " must be a lowercase SHA-256 digest."); + } + } + + static byte[] decodeBase64(String value, int maxBytes, + String label) { + if (value == null) { + throw invalid(label + " is required."); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(value); + } catch (IllegalArgumentException error) { + throw invalid(label + " is not valid Base64."); + } + if (decoded.length > maxBytes) { + throw payloadTooLarge(label + " exceeds its byte limit."); + } + return decoded; + } + + void verifyStoredResource(ResourceRow resource) { + if (resource.deletionOperation() != null + || resource.captureState() != null && !"VERIFIED".equals(resource.captureState()) + || resource.resourceId().startsWith("capture-segment-") + || resource.resourceId().startsWith("capture-seal-")) { + throw resourceCorrupt(); + } + if ("TOOL_PUBLICATION".equals(resource.storageKind())) { + if (!"REFERENCED".equals(resource.state()) || resource.bytes() != null + || resource.objectKey() == null || resource.objectVersionId() != null + || resource.encryptionKeyId() != null + || !"managed-tool-outcome".equals(resource.kind()) + || resource.byteLength() > 2L * 1024 * 1024) { + throw resourceCorrupt(); + } + Long matching = + jdbc.queryForObject( + "SELECT COUNT(*) FROM ma_output_capture p JOIN ma_resource o ON" + + " o.scope_key = p.scope_key AND o.publication_id =" + + " p.publication_id WHERE p.tenant_id = ? AND p.workspace_id = ?" + + " AND p.session_id = ? AND p.admission_resource_id = ? AND" + + " p.producer_phase IN ('FINISHED', 'REFERENCED') AND" + + " p.quarantined = FALSE AND o.slot_key = X'61646d697373696f6e'" + + " AND o.capture_state = 'VERIFIED' AND o.resource_id = ? AND" + + " o.capture_object_key = ? AND o.sha256 = ? AND o.byte_length =" + + " ?", + Long.class, + resource.tenantId(), + resource.workspaceId(), + resource.sessionId(), + resource.resourceId(), + resource.resourceId(), + resource.objectKey(), + resource.digest(), + resource.byteLength()); + if (matching == null || matching != 1) { + throw resourceCorrupt(); + } + return; + } + if (!"MYSQL_INLINE".equals(resource.storageKind()) + || !("REFERENCED".equals(resource.state()) + || "PUBLISHED".equals(resource.state()) && resource.schemaVersion() == 1 + && (resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind()) + || "managed-agent-instructions".equals(resource.kind()) + && resource.byteLength() >= 0 + && resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES + || "managed-agent-skills".equals(resource.kind()) + && resource.byteLength() > 0 + && resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_SKILLS_BYTES)) + || resource.bytes() == null + || resource.objectKey() != null + || resource.objectVersionId() != null + || resource.encryptionKeyId() != null + || resource.bytes().length != resource.byteLength() + || !sha256(resource.bytes()).equals(resource.digest())) { + throw resourceCorrupt(); + } + if ("managed-tool-outcome".equals(resource.kind())) { + Long quarantined = jdbc.queryForObject("SELECT COUNT(*) FROM ma_output_capture" + + " WHERE tenant_id = ? AND workspace_id = ? AND session_id = ?" + + " AND admission_resource_id = ? AND quarantined = TRUE", + Long.class, resource.tenantId(), resource.workspaceId(), + resource.sessionId(), resource.resourceId()); + if (quarantined != null && quarantined > 0) { + throw resourceCorrupt(); + } + } + } + + static ManagedAgentException resourceCorrupt() { + return new ManagedAgentException(500, + "managed_session_resource_corrupt", + "The Managed Session resource failed verification."); + } + + static void validateCounter(long value, String label, + long minimum) { + if (value < minimum + || value > ManagedSessionStoreModels.MAX_SAFE_COUNTER) { + throw invalid(label + " is outside its safe integer bound."); + } + } + + static void validateUtf8JsonLines(byte[] bytes, + int expectedRecords) { + try { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + } catch (CharacterCodingException error) { + throw invalid("recordBytesBase64 is not valid UTF-8."); + } + int records = 0; + int lineLength = 0; + for (byte value : bytes) { + if (value == '\n') { + if (lineLength == 0) { + throw invalid("recordBytesBase64 contains a blank line."); + } + if (lineLength > ManagedSessionStoreModels.MAX_EVENT_BYTES) { + throw payloadTooLarge("A Managed Session record exceeds" + + " its byte limit."); + } + records++; + lineLength = 0; + } else { + lineLength++; + } + } + if (lineLength != 0 || records != expectedRecords) { + throw invalid("recordBytesBase64 must end with a newline and" + + " match recordCount."); + } + } + + void requireHeadScope(HeadRow head, String tenantId, + String workspaceId, String sessionId) { + if (!tenantId.equals(head.tenantId()) + || !workspaceId.equals(head.workspaceId()) + || !sessionId.equals(head.sessionId())) { + throw sessionNotFound(); + } + if (head.storageVersion() < 1 || head.storageVersion() > com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED) { + throw conflict("managed_session_storage_version_unsupported", + "The Managed Session storage version is unsupported."); + } + readerVersion.requireReadable(head.storageVersion()); + if (!HEAD_STATES.contains(head.state()) + || !RECOVERY_STATES.contains(head.recoveryStatus()) + || head.writerGeneration() < 1 + || head.writerGeneration() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.journalRevision() < 0 + || head.journalRevision() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.committedSequence() < 0 + || head.committedSequence() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.activationEpoch() < 0 + || head.activationEpoch() + > ManagedSessionStoreModels.MAX_SAFE_COUNTER + || head.compactedThroughRevision() < 0 + || head.compactedThroughRevision() + > head.journalRevision() + || head.lastCommitDigest() != null + && !DIGEST.matcher(head.lastCommitDigest()).matches() + || head.latestCheckpointResourceId() != null + && !isValidText(head.latestCheckpointResourceId(), + MAX_ID_BYTES) + || ("ACTIVE".equals(head.state()) + || "SEALED".equals(head.state())) + && (!isValidText(head.writerId(), MAX_ID_BYTES) + || head.writerLeaseUntil() == null + || head.leaseTokenHash() == null + || !DIGEST.matcher(head.leaseTokenHash()) + .matches())) { + throw headCorrupt(); + } + } + + static void requireResourceScope(ResourceRow resource, + String tenantId, String workspaceId, String sessionId, + String resourceId) { + if (!tenantId.equals(resource.tenantId()) + || !workspaceId.equals(resource.workspaceId()) + || !sessionId.equals(resource.sessionId()) + || !resourceId.equals(resource.resourceId())) { + throw sessionNotFound(); + } + } + + static void requireTransactionWorkspace(TransactionRow row, + String workspaceId) { + if (!workspaceId.equals(row.workspaceId())) { + throw new ManagedAgentException(500, + ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, + "The Managed Session transaction scope is corrupt."); + } + } + + void ensureSessionRoot(String tenantId, String sessionId) { + // Take the root row exclusively first: ordinary writers lock it + // without a tenant guard (#13800); keep that order before + // initializing the session event stream. + ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); + long now = epochMillis(databaseNow()); + jdbc.update( + "INSERT IGNORE INTO ma_event_stream (tenant_id, session_id, stream_kind, stream_id," + + " gmt_modified) VALUES (?, ?, 'SESSION', X'', ?)", + tenantId, + sessionId, + now); + } + + static String commandKeyHash(String operation, + String commandId) { + return sha256((operation + "\u0000" + commandId) + .getBytes(StandardCharsets.UTF_8)); + } + + static String sha256(byte[] value) { + return DigestUtils.sha256Hex(value); + } + + static ManagedAgentException sessionNotFound() { + return new ManagedAgentException(404, + "managed_session_not_found", + "The Managed Session does not exist."); + } + + // Package-private: ToolPublicationStore's evidence reads answer a + // damaged journal with the same fault the session store raises. + static ManagedAgentException journalCorrupt() { + return new ManagedAgentException(500, + ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, + "The Managed Session transaction failed verification."); + } + + static ManagedAgentException headCorrupt() { + return new ManagedAgentException(500, + ManagedSessionStoreModels.ERROR_HEAD_CORRUPT, + "The Managed Session head failed verification."); + } + + static ManagedAgentException invalid(String message) { + return new ManagedAgentException(400, + ManagedSessionStoreModels.ERROR_INVALID_REQUEST, message); + } + + static ManagedAgentException payloadTooLarge(String message) { + return new ManagedAgentException(413, + ManagedSessionStoreModels.ERROR_PAYLOAD_TOO_LARGE, message); + } + + static ManagedAgentException conflict(String code, String message) { + return new ManagedAgentException(409, code, message); + } + + record HeadRow(String tenantId, String workspaceId, + String sessionId, String state, int storageVersion, + long writerGeneration, String writerId, + Timestamp writerLeaseUntil, String leaseTokenHash, + long journalRevision, long committedSequence, + String lastCommitDigest, long activationEpoch, + String latestCheckpointResourceId, + long compactedThroughRevision, String recoveryStatus, + String recoveryDetailCode, String activationId, + String activationPhase, Long activationEventEpoch, + Long activationExpiresAt, Long activationHeadRevision) { + } + + record TransactionRow(String workspaceId, long journalRevision, + String transactionId, String operation, String commandId, + String contentDigest, long firstSequence, long lastSequence, + int eventCount, String eventsDigest, + String previousCommitDigest, String commitDigest, + long writerGeneration, String writerId, String writerTokenHash, + long activationEpoch, String latestCheckpointResourceId, + String recordEncoding, byte[] recordBytes, long byteLength, + String recordDigest) { + } + + record ResourceRow(String tenantId, String workspaceId, + String sessionId, String resourceId, String kind, + int schemaVersion, long byteLength, String digest, + String storageKind, byte[] bytes, String objectKey, + String objectVersionId, String encryptionKeyId, String state, String captureState, String deletionOperation) { + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java new file mode 100644 index 00000000000..1451f4ee3c1 --- /dev/null +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalWriterLifecycle.java @@ -0,0 +1,638 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHarnessReleaseRecords; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AcquireWriterRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.BlockRecoveryRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RecoveryStateReceipt; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RenewWriterRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RestoreHead; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.SealReceipt; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.SealWriterRequest; +import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.WriterGrant; +import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy; +import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.util.List; +import java.util.Objects; + +final class JournalWriterLifecycle { + private final JournalFencing fencing; + private final JournalStoreRows storeRows; + private final com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion; + private final JdbcTemplate jdbc; + private final WriterCredentialPolicy credentials; + private final JdbcRuntimeBindingRepository csiBindings; + + JournalWriterLifecycle(JournalFencing fencing, + JournalStoreRows storeRows, + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion, + JdbcTemplate jdbc, + WriterCredentialPolicy credentials, + JdbcRuntimeBindingRepository csiBindings) { + this.fencing = fencing; + this.storeRows = storeRows; + this.readerVersion = readerVersion; + this.jdbc = jdbc; + this.credentials = credentials; + this.csiBindings = csiBindings; + } + + void bindControlSessionStorage(String tenantId, String workspaceId, String sessionId) { + String storage = WorkspaceMigrationAdmission.sessionStorage(jdbc, tenantId, sessionId, workspaceId); + if (storage == null) { + return; + } + var rows = jdbc.queryForList("SELECT session_kind, workspace_id, workspace_storage_id FROM ma_session" + + " WHERE tenant_id = ? AND session_id = ? FOR UPDATE", tenantId, sessionId); + if (rows.size() != 1 || !"CONTROL".equals(rows.getFirst().get("session_kind"))) { + return; + } + var row = rows.getFirst(); + if (row.get("workspace_id") != null && !workspaceId.equals(row.get("workspace_id")) + || row.get("workspace_storage_id") != null && !storage.equals(row.get("workspace_storage_id"))) { + throw JournalStoreRows.conflict("managed_session_scope_conflict", "The Managed Session Workspace binding conflicts."); + } + jdbc.update( + "UPDATE ma_session SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", workspace_id = ?, workspace_storage_id = ? WHERE tenant_id =" + + " ? AND session_id = ? AND session_kind = 'CONTROL'", + workspaceId, storage, tenantId, sessionId); + } + + RestoreHead lockSnapshotWriter(String tenant, String session, String token, + ManagedNativeJournalSnapshots.PublishRequest request) { + JournalStoreRows.validateScope(tenant, request.workspaceId(), session); + fencing.requireCredential(tenant, request.workspaceId(), session, token); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateStableId(request.snapshotId(), "snapshotId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + JournalStoreRows.validateCounter(request.expectedJournalRevision(), "expectedJournalRevision", 1); + JournalStoreRows.validateCounter(request.expectedCommittedSequence(), "expectedCommittedSequence", 0); + JournalStoreRows.validateDigest(request.expectedLastCommitDigest(), "expectedLastCommitDigest", true); + JournalFencing.lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenant, JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + lockPublicationWriter(tenant, request.workspaceId(), session, request.writerId(), request.writerGeneration(), token); + fencing.requireOrdinaryAdmission(tenant, session); + JournalStoreRows.HeadRow locked = storeRows.requireHeadForUpdate(tenant, session); + RestoreHead head = storeRows.restoreHead(locked); + if (!"READY".equals(head.recoveryStatus())) { + throw JournalStoreRows.conflict("managed_session_recovery_blocked", "The Session is blocked from snapshot publication."); + } + return head; + } + + ManagedSessionStore.PublicationWriter lockPublicationWriter(String tenant, String workspace, + String session, String writer, long generation, String token) { + JournalStoreRows.validateScope(tenant, workspace, session); + fencing.requireCredential(tenant, workspace, session, token); + ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenant, session); + fencing.requirePhysicalSessionOpen(tenant, workspace, session); + // Publication-lane writer: keeps the tenant CAS row before the + // Session root the CSI check locks and before the head row, the + // order retention reads take (CAS row, root, head) and the same as + // before #13800 decoupled ordinary traffic. + JournalFencing.lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenant, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + fencing.requirePlacement(tenant, workspace, session, token, writer, false); + var continuation = fencing.lockCsiContinuation(tenant, session); + var csiOriginal = continuation == null ? null : continuation.original(); + if (csiOriginal != null) { + csiOriginal.requireAdmission(); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenant, session); + storeRows.requireHeadScope(head, tenant, workspace, session); + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, writer, generation, token, now, true); + fencing.requireInstalledCsiWriter(csiOriginal); + return new ManagedSessionStore.PublicationWriter(JournalStoreRows.epochMillis(now), JournalStoreRows.epochMillis(head.writerLeaseUntil()), + head.journalRevision(), head.committedSequence(), head.activationEpoch(), + head.latestCheckpointResourceId(), head.recoveryStatus(), head.activationId(), + head.activationPhase(), head.activationEventEpoch(), head.activationExpiresAt(), + head.activationHeadRevision()); + } + + WriterGrant acquireWriter(String tenantId, String sessionId, + String writerToken, AcquireWriterRequest request) { + return acquireWriter(tenantId, sessionId, writerToken, request, null); + } + + WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, + AcquireWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + fencing.checkLifecycleWriter(tenantId, sessionId, authority); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + validateLeaseMillis(request.leaseMillis()); + // Acquisition stays serialized with an in-flight delete/retire: while + // a Session has no head row, the tenant CAS row is the only ordering + // point, so it stays. Both acquisitions run in the codebase-wide + // placement-before-publication order, bounded — a held row degrades + // to a named failure instead of a silent wedge or a 1213 (#13800). + // Placement must precede the drain admission check below: begin paths + // insert drain rows only after locking placement, so checking the + // drain first would take its gap lock and deadlock against a + // lifecycle that already holds placement (#13800). + // requireSessionOpen must run after the CAS guard: it is this + // transaction's first consistent read, so its REPEATABLE READ + // snapshot only sees a retirement committed underneath the CAS wait + // if it is pinned after that wait (#13800 R2). + JournalFencing.lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, + JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + JournalFencing.lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, + tenantId, JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + var csiOriginal = fencing.lockCsiOriginal(tenantId, sessionId); + if (csiOriginal != null) { + csiOriginal.requireAdmission(); + } + WorkspaceMigrationAdmission.requireSessionOpen(jdbc, tenantId, sessionId, request.workspaceId()); + storeRows.ensureSessionRoot(tenantId, sessionId); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), true); + bindControlSessionStorage(tenantId, request.workspaceId(), sessionId); + // Materialize the key before any locking read: missing-key RR locks can + // deadlock independent tenants when both first writers insert a head. + jdbc.update("INSERT IGNORE INTO ma_session_head (tenant_key, harness_key, tenant_id, harness_session_id, session_id, head_kind, phase, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, 'HARNESS', 'OPEN', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, sessionId, sessionId); + if (authority != null) { + WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, + WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority)); + } else { + fencing.requireOrdinaryAdmission(tenantId, sessionId); + } + ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); + List closed = jdbc.query( + "SELECT status FROM ma_session WHERE tenant_id = ? AND session_id = ? AND" + + " (workspace_id IS NOT NULL OR session_kind = 'PUBLIC') FOR UPDATE", + (row, index) -> row.getString("status"), tenantId, sessionId); + if (authority == null && !closed.isEmpty() && !"ACTIVE".equals(closed.getFirst())) { + throw JournalStoreRows.conflict("managed_session_not_writable", "The Session is closing or closed."); + } + String tokenHash = JournalFencing.tokenHash(writerToken); + readerVersion.requireReadable(1); + Timestamp createdAt = storeRows.databaseNow(); + Timestamp initialLeaseUntil = plusMillis(createdAt, + request.leaseMillis()); + List kinds = + jdbc.queryForList( + "SELECT head_kind FROM ma_session_head WHERE tenant_key = ? AND harness_key" + + " = ? AND tenant_id = ? AND session_id = ? FOR UPDATE", + String.class, + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + if (!kinds.isEmpty() && "HARNESS".equals(kinds.getFirst())) { + jdbc.update( + "UPDATE ma_session_head SET head_kind = 'MANAGED_SESSION', workspace_id = ?, storage_version = ?, state = 'ACTIVE', writer_generation = 1, writer_id = ?, writer_lease_until = ?, lease_token_hash = ?, journal_revision = 0, wake_head_revision = 0, cleanup_head_revision = 0, committed_sequence = 0, activation_epoch = 0, compacted_through_revision = 0, recovery_status = 'READY', gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND head_kind = 'HARNESS'", + request.workspaceId(), + JournalStoreRows.STORAGE_VERSION, + request.writerId(), + initialLeaseUntil.getTime(), + tokenHash, + createdAt.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return new WriterGrant(1, JournalStoreRows.epochMillis(initialLeaseUntil), 0, 0, null, 0, false); + } + try { + jdbc.update( + "INSERT INTO ma_session_head (tenant_key, harness_key, harness_session_id, head_kind, tenant_id, workspace_id, session_id, storage_version, state, writer_generation, writer_id, writer_lease_until, lease_token_hash, journal_revision, wake_head_revision, cleanup_head_revision, committed_sequence, activation_epoch, compacted_through_revision, recovery_status, gmt_create, gmt_modified) VALUES (?, ?, ?, 'MANAGED_SESSION', ?, ?, ?, ?, 'ACTIVE', 1, ?, ?, ?, 0, 0, 0, 0, 0, 0, 'READY', ?, ?)", + com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository + .harnessDrainKey(tenantId), + com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository + .harnessDrainKey(sessionId), + sessionId, + tenantId, + request.workspaceId(), + sessionId, + JournalStoreRows.STORAGE_VERSION, + request.writerId(), + initialLeaseUntil.getTime(), + tokenHash, + createdAt.getTime(), + createdAt.getTime()); + return new WriterGrant(1, JournalStoreRows.epochMillis(initialLeaseUntil), 0, 0, + null, 0, false); + } catch (DuplicateKeyException ignored) { + // A prior acquire created the row; lock and inspect it below. + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + JournalFencing.requireCsiWriter(csiOriginal, head); + if ("DELETING".equals(head.state()) + || "DELETED".equals(head.state())) { + throw JournalStoreRows.conflict("managed_session_not_writable", + "The Managed Session does not accept a writer."); + } + if (!"ACTIVE".equals(head.state()) + && !"SEALED".equals(head.state())) { + throw JournalStoreRows.conflict("managed_session_not_writable", + "The Managed Session state is not writable."); + } + Timestamp now = storeRows.databaseNow(); + if ("ACTIVE".equals(head.state()) + && head.writerLeaseUntil() != null + && head.writerLeaseUntil().after(now)) { + if (!request.writerId().equals(head.writerId()) + || !JournalFencing.secureEquals(tokenHash, head.leaseTokenHash())) { + throw JournalFencing.writerConflict(); + } + Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), + plusMillis(now, request.leaseMillis())); + jdbc.update( + "UPDATE ma_session_head SET writer_lease_until = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", + leaseUntil.getTime(), + now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return grant(head, leaseUntil, true); + } + long generation = JournalStoreRows.increment(head.writerGeneration(), + "writer generation"); + if (csiOriginal != null) { + if (!"ACTIVE".equals(head.state()) || head.storageVersion() != JournalStoreRows.STORAGE_VERSION + || head.compactedThroughRevision() != 0 || !"READY".equals(head.recoveryStatus()) + || head.writerLeaseUntil() == null || head.journalRevision() < 2 || head.activationEpoch() < 1) { + throw JournalStoreRows.conflict("csi_original_writer_unavailable", "The original CSI writer cannot be replaced."); + } + fencing.requireCsiHistoryOwner(csiOriginal); + jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { + JdbcCsiActivationAdmission.qualifyColdWriter(connection, csiOriginal, csiBindings, + head.journalRevision(), head.committedSequence(), head.lastCommitDigest(), head.writerId(), + head.writerGeneration(), head.activationEpoch(), JournalStoreRows.epochMillis(head.writerLeaseUntil()), + request.writerId()); + return null; + }); + now = storeRows.databaseNow(); + } + Timestamp leaseUntil = plusMillis(now, request.leaseMillis()); + jdbc.update( + "UPDATE ma_session_head SET state = 'ACTIVE', writer_generation = ?, writer_id = ?, writer_lease_until = ?, lease_token_hash = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", + generation, + request.writerId(), + leaseUntil.getTime(), + tokenHash, + now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return new WriterGrant(generation, JournalStoreRows.epochMillis(leaseUntil), + head.journalRevision(), head.committedSequence(), + head.lastCommitDigest(), head.activationEpoch(), false); + } + + WriterGrant renewWriter(String tenantId, String sessionId, + String writerToken, RenewWriterRequest request) { + return renewWriter(tenantId, sessionId, writerToken, request, null); + } + + WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, + RenewWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + fencing.checkLifecycleWriter(tenantId, sessionId, authority); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + validateLeaseMillis(request.leaseMillis()); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + var continuation = fencing.lockCsiContinuation(tenantId, sessionId); + var csiOriginal = continuation == null ? null : continuation.original(); + if (csiOriginal != null) { + csiOriginal.requireContinuation(); + } + JournalStoreRows.HeadRow head = continuation == null ? storeRows.requireHeartbeatHead(tenantId, sessionId) : storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if (authority == null) { + fencing.requireNoLifecycleClaim(tenantId, sessionId); + } + JournalFencing.requireCsiWriter(csiOriginal, head); + Timestamp now = storeRows.databaseNow(); + boolean cleanup = authority != null && WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority); + // A resident cleanup writer may outlive its lease while the coordinator + // is down; its token and generation must still own the locked head. + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, !cleanup); + Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), + plusMillis(now, request.leaseMillis())); + fencing.requireCsiLease(continuation, JournalStoreRows.epochMillis(head.writerLeaseUntil()), JournalStoreRows.epochMillis(leaseUntil)); + jdbc.update( + "UPDATE ma_session_head SET writer_lease_until = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", + leaseUntil.getTime(), + now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + if (request.activation() != null) { + var activation = request.activation(); + JournalStoreRows.validateStableId(activation.activationId(), "activationId"); + JournalStoreRows.validateCounter(activation.epoch(), "activation epoch", 1); + JournalStoreRows.validateCounter(activation.renewalSeq(), "activation renewalSeq", 1); + validateLeaseMillis(activation.leaseDurationMs()); + if (!"READY".equals(head.recoveryStatus()) || !"active".equals(head.activationPhase()) + || !Objects.equals(head.activationHeadRevision(), head.journalRevision()) + || !activation.activationId().equals(head.activationId()) + || !Objects.equals(head.activationEventEpoch(), activation.epoch())) { + throw JournalStoreRows.conflict("managed_session_activation_conflict", "The activation is no longer current."); + } + var row = storeRows.activationLeaseRow(head); + if (!request.writerId().equals(row.workerId()) + || activation.renewalSeq() < row.lease().renewalSeq() + || activation.renewalSeq() > row.lease().renewalSeq() + 1) { + throw JournalStoreRows.conflict("managed_session_activation_conflict", "The activation renewal is stale or out of order."); + } + long until = row.lease().expiresAt(); + if (activation.renewalSeq() != row.lease().renewalSeq()) { + until = Math.max(until, plusMillis(now, activation.leaseDurationMs()).getTime()); + fencing.requireCsiLease(continuation, row.lease().expiresAt(), until); + jdbc.update("UPDATE ma_session_journal_tx SET activation_lease_until = ?, activation_renewal_seq = ?" + + " WHERE tenant_id = ? AND session_id = ? AND journal_revision = ?", + until, activation.renewalSeq(), tenantId, sessionId, row.revision()); + jdbc.update("UPDATE ma_session_head SET activation_expires_at = ?" + + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", + until, JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); + } + var lease = new ManagedSessionStoreModels.ActivationLease( + activation.activationId(), activation.epoch(), activation.renewalSeq(), until); + return new WriterGrant(head.writerGeneration(), JournalStoreRows.epochMillis(leaseUntil), head.journalRevision(), + head.committedSequence(), head.lastCommitDigest(), head.activationEpoch(), false, + com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED, true, lease); + } + return grant(head, leaseUntil, false); + } + + SealReceipt sealWriter(String tenantId, String sessionId, + String writerToken, SealWriterRequest request) { + return sealWriter(tenantId, sessionId, writerToken, request, null); + } + + SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, + SealWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + fencing.checkLifecycleWriter(tenantId, sessionId, authority); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + if (fencing.lockCsiOriginal(tenantId, sessionId) != null) { + throw JournalStoreRows.conflict( + "csi_finalize_required", + "The original CSI writer requires retirement finalization."); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + if (authority == null) { + fencing.requireNoLifecycleClaim(tenantId, sessionId); + } + if ("SEALED".equals(head.state()) + && request.writerGeneration() == head.writerGeneration() + && request.writerId().equals(head.writerId()) + && JournalFencing.secureEquals(JournalFencing.tokenHash(writerToken), + head.leaseTokenHash())) { + return new SealReceipt(head.writerGeneration(), "SEALED", true); + } + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, false); + jdbc.update( + "UPDATE ma_session_head SET state = 'SEALED', gmt_modified = ? WHERE tenant_key = ?" + + " AND harness_key = ? AND tenant_id = ? AND session_id = ?", + now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return new SealReceipt(head.writerGeneration(), "SEALED", false); + } + + void quiesceOwnership(String tenant, String session, String token, + ManagedSessionStoreModels.QuiesceOwnershipRequest request) { + JournalStoreRows.validateScope(tenant, request.workspaceId(), session); + fencing.requireCredential(tenant, request.workspaceId(), session, token); + credentials.requirePlacement(tenant, request.workspaceId(), session, request.instanceId(), request.bootId(), + request.placementEpoch(), token); + fencing.requirePlacement(tenant, request.workspaceId(), session, token, request.writerId(), false); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenant, session); + storeRows.requireHeadScope(head, tenant, request.workspaceId(), session); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), token, storeRows.databaseNow(), true); + jdbc.update("UPDATE ma_session SET harness_placement_state = 'QUIESCING' WHERE tenant_id = ? AND session_id = ?", tenant, session); + } + + JsonNode releaseOwnership(String tenantId, String sessionId, String writerToken, + ManagedSessionStoreModels.ReleaseOwnershipRequest request) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); + JournalStoreRows.validateStableId(request.operationId(), "operationId"); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + JournalStoreRows.validateCounter(request.journalRevision(), "journalRevision", 1); + JournalStoreRows.validateCounter(request.placementEpoch(), "placementEpoch", 1); + if (request.commitProof() == null || !request.bootId().equals(request.writerId())) { + throw JournalStoreRows.invalid("Ownership release requires the original boot and committed prefix."); + } + JournalStoreRows.validateDigest(request.commitProof().committedPrefixHash(), "committedPrefixHash", false); + credentials.requirePlacement(tenantId, request.workspaceId(), sessionId, + request.instanceId(), request.bootId(), request.placementEpoch(), writerToken); + JournalFencing.lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + JournalFencing.lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS)); + ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); + String recordId = HarnessPlacementStore.releaseId(request.placementEpoch()); + String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); + String recordKey = ManagedExtensionProjection.recordKey(sessionId, HarnessPlacementStore.RELEASE_DOMAIN, recordId); + ObjectNode receipt = JournalStoreRows.CSI_JSON.createObjectNode(); + receipt.put("format", "qwen-hosted-harness-release/1"); + receipt.put("tenantId", tenantId); + receipt.put("workspaceId", request.workspaceId()); + receipt.put("sessionId", sessionId); + receipt.put("operationId", request.operationId()); + receipt.put("instanceId", request.instanceId()); + receipt.put("bootId", request.bootId()); + receipt.put("placementEpoch", request.placementEpoch()); + receipt.put("writerGeneration", request.writerGeneration()); + receipt.put("journalRevision", request.journalRevision()); + receipt.put("committedSequence", request.commitProof().lastCommitSequence()); + receipt.set("commitProof", JournalStoreRows.CSI_JSON.valueToTree(request.commitProof())); + receipt.put("state", "RELEASED"); + // Normalize integer nodes to the representation read back from the stored JSON. + receipt = (ObjectNode) com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(receipt.toString()); + var saved = jdbc.query("SELECT record_resource_id FROM ma_session_record WHERE session_scope_key = ?" + + " AND record_key = ? AND tenant_id = ? AND session_id = ? AND domain = ? AND record_kind = 'DOMAIN'", + (row, index) -> row.getString(1), scope, recordKey, tenantId, sessionId, HarnessPlacementStore.RELEASE_DOMAIN); + if (!saved.isEmpty()) { + var resource = storeRows.findResource(scope, saved.getFirst()); + if (resource == null) { + throw HarnessPlacementStore.blocked("harness_release_receipt_missing"); + } + storeRows.verifyStoredResource(resource); + JsonNode original = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); + if (!receipt.equals(original)) { + throw HarnessPlacementStore.blocked("harness_release_operation_conflict"); + } + return original; + } + var placement = fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + if (placement == null || !"QUIESCING".equals(placement.state())) { + throw HarnessPlacementStore.blocked("harness_placement_not_quiescing"); + } + fencing.requireNoLifecycleClaim(tenantId, sessionId); + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, now, true); + if (head.journalRevision() != request.journalRevision() + || head.committedSequence() != request.commitProof().lastCommitSequence() + || !Objects.equals(head.lastCommitDigest(), request.commitProof().committedPrefixHash()) + || !"released".equals(head.activationPhase()) + || !Objects.equals(head.activationHeadRevision(), head.journalRevision())) { + throw HarnessPlacementStore.blocked("harness_release_proof_conflict"); + } + String resourceId = "harness-release:" + request.placementEpoch(); + byte[] bytes; + try { + bytes = JournalStoreRows.CSI_JSON.writeValueAsBytes(receipt); + } catch (com.fasterxml.jackson.core.JsonProcessingException failure) { + throw new IllegalStateException("Ownership release receipt cannot be encoded.", failure); + } + ManagedHarnessReleaseRecords.require(receipt); + String resourceKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, resourceId); + jdbc.update("INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, workspace_id, session_id," + + " resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, state, gmt_create, last_verified_at)" + + " VALUES (?, ?, ?, ?, ?, ?, 'managed-hosted_harness_release', 1, ?, ?, 'MYSQL_INLINE', ?, 'REFERENCED', ?, ?)", + resourceKey, scope, tenantId, request.workspaceId(), sessionId, resourceId, + bytes.length, JournalStoreRows.sha256(bytes), bytes, now.getTime(), now.getTime()); + jdbc.update("INSERT INTO ma_session_record (session_scope_key, record_key, tenant_id, workspace_id, session_id," + + " domain, record_id, operation_hash, revision, record_resource_id, gmt_create, settled_at, first_sequence)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", scope, recordKey, tenantId, + request.workspaceId(), sessionId, HarnessPlacementStore.RELEASE_DOMAIN, recordId, JournalStoreRows.sha256(request.operationId().getBytes(StandardCharsets.UTF_8)), + resourceId, now.getTime(), now.getTime(), head.committedSequence()); + jdbc.update("INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key," + + " tenant_id, workspace_id, session_id, resource_id, gmt_create) VALUES (?, 'SESSION_RECORD', ?, 1, ?, ?, ?, ?, ?, ?)", + scope, recordKey, resourceKey, tenantId, request.workspaceId(), sessionId, resourceId, now.getTime()); + jdbc.update("UPDATE ma_session_head SET state = 'SEALED', gmt_modified = ? WHERE tenant_key = ? AND harness_key = ?" + + " AND tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); + jdbc.update("UPDATE ma_session SET harness_placement_state = 'RELEASED' WHERE tenant_id = ? AND session_id = ?", + tenantId, sessionId); + return receipt; + } + + RecoveryStateReceipt blockRecovery(String tenantId, + String sessionId, String writerToken, + BlockRecoveryRequest request) { + JournalStoreRows.validateScope(tenantId, request.workspaceId(), sessionId); + fencing.requireCredential(tenantId, request.workspaceId(), sessionId, + writerToken); + JournalStoreRows.validateStableId(request.writerId(), "writerId"); + JournalStoreRows.validateCounter(request.writerGeneration(), "writerGeneration", 1); + if (!JournalStoreRows.RECOVERY_STATES.contains(request.recoveryStatus()) + || "READY".equals(request.recoveryStatus())) { + throw JournalStoreRows.invalid("recoveryStatus must be a blocked state."); + } + // The 128-character column width is enforced one layer up by + // @Size(max = 128) on BlockRecoveryRequest — byte-counting here + // would reject non-ASCII codes that legitimately fit VARCHAR(128). + JournalStoreRows.validateText(request.recoveryDetailCode(), "recoveryDetailCode", + JournalStoreRows.MAX_TEXT_BYTES); + fencing.requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); + var continuation = fencing.lockCsiContinuation(tenantId, sessionId); + var csiOriginal = continuation == null ? null : continuation.original(); + if (csiOriginal != null) { + csiOriginal.requireContinuation(); + } + JournalStoreRows.HeadRow head = storeRows.requireHeadForUpdate(tenantId, sessionId); + storeRows.requireHeadScope(head, tenantId, request.workspaceId(), sessionId); + JournalFencing.requireCsiWriter(csiOriginal, head); + Timestamp now = storeRows.databaseNow(); + fencing.requireWriter(head, request.writerId(), request.writerGeneration(), + writerToken, now, true); + fencing.requireCsiWriterCurrent( + continuation, head, request.writerId(), request.writerGeneration(), writerToken); + if (request.recoveryStatus().equals(head.recoveryStatus()) + && request.recoveryDetailCode() + .equals(head.recoveryDetailCode())) { + return new RecoveryStateReceipt(head.writerGeneration(), + head.recoveryStatus(), head.recoveryDetailCode(), true); + } + if (!"READY".equals(head.recoveryStatus())) { + throw JournalStoreRows.conflict( + ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT, + "The Managed Session already has a different recovery" + + " block."); + } + fencing.requireCsiWriterCurrent(continuation, head, request.writerId(), request.writerGeneration(), writerToken); + jdbc.update( + "UPDATE ma_session_head SET recovery_status = ?, recovery_detail_code = ?," + + " gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" + + " AND session_id = ?", + request.recoveryStatus(), + request.recoveryDetailCode(), + now.getTime(), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return new RecoveryStateReceipt(head.writerGeneration(), + request.recoveryStatus(), request.recoveryDetailCode(), + false); + } + + /** + * Whether a writer still holds the Session's journal under a lease that has not expired by + * database time. A Harness renews that lease while it holds the Session and seals the writer + * when it closes the Session. + */ + boolean hasLiveWriter(String tenantId, String sessionId) { + List leases = + jdbc.query( + "SELECT writer_lease_until FROM ma_session_head WHERE tenant_key = ? AND" + + " harness_key = ? AND tenant_id = ? AND session_id = ? AND state =" + + " 'ACTIVE' FOR UPDATE", + (result, row) -> ManagedSchemaTime.timestamp(result, "writer_lease_until"), + JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), + JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), + tenantId, + sessionId); + return !leases.isEmpty() && leases.getFirst() != null + && leases.getFirst().after(storeRows.databaseNow()); + } + + WriterGrant grant(JournalStoreRows.HeadRow head, Timestamp leaseUntil, + boolean replayed) { + return new WriterGrant(head.writerGeneration(), JournalStoreRows.epochMillis(leaseUntil), + head.journalRevision(), head.committedSequence(), + head.lastCommitDigest(), head.activationEpoch(), replayed); + } + + static Timestamp plusMillis(Timestamp timestamp, long millis) { + return Timestamp.from(timestamp.toInstant().plusMillis(millis)); + } + + static Timestamp laterOf(Timestamp left, Timestamp right) { + return left != null && left.after(right) ? left : right; + } + + static void validateLeaseMillis(Long leaseMillis) { + if (leaseMillis == null + || leaseMillis < ManagedSessionStoreModels.MIN_LEASE_MILLIS + || leaseMillis > ManagedSessionStoreModels.MAX_LEASE_MILLIS) { + throw JournalStoreRows.invalid("leaseMillis is outside its bound."); + } + } +} diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java index 230d7def9fb..de195b78d84 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStore.java @@ -1,20 +1,14 @@ package com.alibaba.qwen.code.managedagent.persistence; -import com.alibaba.qwen.code.managedagent.contracts.catalog.AgentDefinitionExtensions; import com.alibaba.qwen.code.managedagent.contracts.event.CommittedEventPublisher; import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; -import com.alibaba.qwen.code.managedagent.contracts.persistence.AgentDefinitionCompiler; -import com.alibaba.qwen.code.managedagent.contracts.persistence.EventIdentity; -import com.alibaba.qwen.code.managedagent.contracts.persistence.EventIdentity.Identity; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedConfigInstall; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords; import com.alibaba.qwen.code.managedagent.contracts.persistence.WorkspaceCsiRegistration; import com.alibaba.qwen.code.managedagent.contracts.session.AgentStateStore; import com.alibaba.qwen.code.managedagent.contracts.session.SessionCreationCommand; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.ContextCompressionState; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.CwdChangeOutcome; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionLifecycleStore.TaskCancelOutcome; import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection; -import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles; -import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules; import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellApprovalChangeResult; import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellApprovalSelection; @@ -33,8 +27,6 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.DispatchTarget; import com.alibaba.qwen.code.managedagent.model.StoreModels.EventPage; import com.alibaba.qwen.code.managedagent.model.StoreModels.EventRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.HarnessEvent; -import com.alibaba.qwen.code.managedagent.model.StoreModels.ItemPartRecord; -import com.alibaba.qwen.code.managedagent.model.StoreModels.ItemRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.MaterializationResult; import com.alibaba.qwen.code.managedagent.model.StoreModels.MaterializationTarget; import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationAdmission; @@ -42,7 +34,6 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationTarget; import com.alibaba.qwen.code.managedagent.model.StoreModels.ParkedOperation; -import com.alibaba.qwen.code.managedagent.model.StoreModels.ProjectedEvent; import com.alibaba.qwen.code.managedagent.model.StoreModels.RenameDelivery; import com.alibaba.qwen.code.managedagent.model.StoreModels.ReplayFloorTarget; import com.alibaba.qwen.code.managedagent.model.StoreModels.ReplayWindow; @@ -54,268 +45,42 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnPage; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnRecord; import com.alibaba.qwen.code.managedagent.model.StoreModels.TurnSummary; -import com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.ResolvedBinding; import com.alibaba.qwen.code.managedagent.settings.ManagedAgentSettings; -import com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile; import com.alibaba.qwen.code.runtimebroker.RuntimeProvisionRequest; -import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; -import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; -import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; import com.alibaba.qwen.code.runtimebroker.managedworkspace.ContextBinding; -import com.alibaba.qwen.code.runtimebroker.managedworkspace.WorkspaceAccess; -import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; -import com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.lang3.StringUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Lazy; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowCallbackHandler; -import org.springframework.jdbc.core.RowMapper; import org.springframework.lang.Nullable; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.UUID; -import java.util.regex.Pattern; @Repository @Qualifier("managedAgentTransactionManager") public class ManagedAgentStore implements AgentStateStore { - private final com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles worktreeFiles; - - private final HarnessRules harnessRules; - - private static final Logger LOG = LoggerFactory.getLogger( - ManagedAgentStore.class); - private static final TypeReference>> INPUT_TYPE = - new TypeReference<>() { - }; - private static final TypeReference> MAP_TYPE = - new TypeReference<>() { - }; - private static final TypeReference> PARTS_TYPE = new TypeReference<>() {}; - private static final long MESSAGE_PAGE_BYTES = 8L * 1024 * 1024; - private static final String INSERT_EVENT = "INSERT INTO ma_public_event" - + " (tenant_id, session_id, sequence_id, event_id, turn_id," - + " event_type, data_json, terminal, source_key, gmt_create," - + " schema_version, projection_version, item_id, content_part_id)" - + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; - private static final List ACTIVE_TURN_STATES = List.of( - "ACCEPTED", "RUNNING", "CANCELLING"); - private static final String TURN_SUMMARY_COLUMNS = "session_id," - + " turn_id, status, gmt_create, completed_at, error_code"; - // findLatestTurns joins ma_public_event, so the same projection - // must be table-qualified there. - private static final String QUALIFIED_TURN_SUMMARY_COLUMNS = - "turn_record." + String.join(", turn_record.", - TURN_SUMMARY_COLUMNS.split(", ")); - private final ManagedExtensionRecordStore configRecords; - private final JdbcTemplate jdbc; - private final WorkspaceLifecycleStore lifecycle; - - private final ObjectMapper objectMapper; - private final Clock clock; - private final String approvalMode; - private final CommittedEventPublisher eventPublisher; - private final ManagedWorkspaceRegistry workspaces; - private final String agentRevision; - private final List definitionToolProfiles; - private final List definitionModels; - private final List modelCatalog; - private final ManagedAgentDefinitionStore definitions; - private final ManagedSessionSkills creationSkills; - private final SessionWorktreeStore sessionWorktrees; - private final com.alibaba.qwen.code.managedagent.contracts.extensions.ExtensionPackages extensionPackages; - - @Override - public SessionWorktreeStore.Descriptor findSessionWorktree(String tenantId, String sessionId) { - return SessionWorktreeStore.descriptor(worktreeFiles, jdbc, tenantId, sessionId); - } - - @Override - public Map findSessionWorktrees(String tenantId, List sessionIds) { - return SessionWorktreeStore.descriptors(worktreeFiles, jdbc, tenantId, sessionIds); - } - - private static final Pattern DEFINITION_REVISION = Pattern.compile( - "^[1-9][0-9]{0,17}$"); - private final boolean workspaceFilesEnabled; - private final boolean workspaceShellEnabled; - private final boolean workspaceChildAgentsEnabled; - private final boolean managedExtensionExecutionEnabled; - private final long lifecycleHandoffMillis; - private final List workspaceMounts; - private final ResidentWorkspaces residentWorkspaces; - private final WorkspaceCreationRoots createdRoots; - private final JournalEventTransport journalEventTransport; - private final boolean journalEventTransportEnabled; - - private static final String SESSION_WITH_STREAM = - "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + ", es.last_sequence AS list_last_sequence, es.replay_floor_sequence AS list_floor_sequence FROM ma_session LEFT JOIN ma_event_stream es ON es.tenant_id = ma_session.tenant_id AND es.session_id = ma_session.session_id AND es.stream_kind = 'SESSION' AND es.stream_id = X''"; - private final RowMapper sessionMapper = (result, row) -> { - if (result.getObject("list_last_sequence") == null || result.getObject("list_floor_sequence") == null) { - throw new IllegalStateException("Session stream is unavailable."); - } - return readSession(result, new StreamCounters(result.getLong("list_last_sequence"), - result.getLong("list_floor_sequence"))); - }; - private final RowMapper sessionListMapper = - (result, row) -> - readSession( - result, - new StreamCounters( - result.getLong("list_last_sequence"), - result.getLong("list_floor_sequence"))); - - private SessionRecord readSession(java.sql.ResultSet result, StreamCounters counters) throws java.sql.SQLException { - return new SessionRecord( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("agent_id"), - result.getString("agent_revision"), - result.getString("title"), - result.getString("status"), - result.getString("harness_boot_id"), - result.getString("harness_event_epoch"), - result.getLong("harness_last_event_id"), - counters.lastSequence(), - counters.floorSequence(), - result.getLong("gmt_create"), - result.getLong("gmt_modified"), - nullableLong(result, "deleted_at"), - result.getLong("version"), - readBinding(result), - result.getString("approval_mode"), - result.getString("tool_profile"), - result.getString("agent_definition_digest"), result.getInt("public_child_agents_version")); - } - - private final RowMapper turnSummaryMapper = - (result, row) -> - new TurnSummary( - result.getString("session_id"), - result.getString("turn_id"), - result.getString("status"), - result.getLong("gmt_create"), - nullableLong(result, "completed_at"), - result.getString("error_code")); - private final RowMapper turnMapper = (result, row) -> - new TurnRecord(result.getString("tenant_id"), - result.getString("session_id"), - result.getString("turn_id"), - result.getString("prompt_id"), - readInput(result.getString("input_json")), - result.getString("payload_digest"), - result.getString("status"), - result.getBoolean("submission_attempted"), - result.getString("harness_event_epoch"), - nullableLong(result, "harness_last_event_id"), - result.getString("dispatch_owner"), - nullableLong(result, "dispatch_lease_until"), - result.getInt("retry_count"), - additiveInt(result, "consecutive_failures"), - additiveInt(result, "budget_exempt_failures"), - nullableLong(result, "retry_after"), - result.getString("error_code"), - result.getString("error_message"), - result.getLong("gmt_create"), - result.getLong("gmt_modified"), - nullableLong(result, "completed_at"), - result.getLong("version")); - private final RowMapper eventMapper = (result, row) -> - new EventRecord(result.getString("tenant_id"), - result.getString("session_id"), - result.getLong("sequence_id"), - result.getString("event_id"), - result.getString("turn_id"), - result.getString("event_type"), - readMap(result.getString("data_json")), - result.getBoolean("terminal"), - result.getString("source_key"), - result.getLong("gmt_create"), - result.getInt("schema_version"), - result.getInt("projection_version"), - result.getString("item_id"), - result.getString("content_part_id")); - private final RowMapper itemMapper = - (result, row) -> - new ItemRow( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("item_id"), - result.getString("turn_id"), - result.getString("item_type"), - result.getString("item_role"), - result.getString("item_status"), - readMap(result.getString("attributes_json")), - result.getLong("first_sequence"), - result.getLong("last_sequence"), - result.getLong("gmt_create"), - result.getLong("gmt_modified"), - result.getLong("revision"), - readParts( - result.getString("parts_json"), - result.getString("body_resource_key"), - result.getString("tenant_id"), - result.getString("session_id"))); - private final RowMapper operationMapper = - (result, row) -> new OperationRecord( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("operation_id"), - OperationKind.valueOf(result.getString("operation_kind")), - result.getString("request_digest"), - result.getString("state"), - result.getString("admission_stage"), - result.getString("delivery_state"), - result.getString("session_status_before"), - result.getString("receipt_id"), - result.getString("claim_owner"), - result.getLong("claim_generation"), - result.getInt("attempt_count"), - additiveString(result, "target_cwd_relative"), - additiveLong(result, "expected_context_revision"), - additiveLong(result, "result_context_revision"), - result.getString("failure_code"), - additiveInt(result, "budget_exempt_attempt"), - hasColumn(result, "lifecycle_protocol_version") - ? result.getInt("lifecycle_protocol_version") : 0, - // NULL on a pre-V56 operation: those operations settle - // against the creator-keyed facts alone. - hasColumn(result, "actor_key") - ? result.getBytes("actor_key") : null, - additiveString(result, "task_id")); - private final RowMapper operationTargetMapper = - (result, row) -> new OperationTarget( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("operation_id")); + private final AgentEventJournal eventJournal; + private final AgentSessionQueries sessionQueries; + private final AgentMessageProjection messageProjection; + private final AgentSessionPolicies sessionPolicies; + private final AgentSessionCommands sessionCommands; + private final AgentLifecycleOperations lifecycleOperations; + private final AgentSessionAdmissions sessionAdmissions; + private final AgentTurnExecution turnExecution; + private final AgentExecutionOperations executionOperations; public ManagedAgentStore(JdbcTemplate jdbc, ObjectMapper objectMapper, Clock clock, CommittedEventPublisher eventPublisher, ManagedWorkspaceRegistry workspaces, @@ -335,27 +100,13 @@ public class ManagedAgentStore implements AgentStateStore { @Nullable WorkspaceLifecycleStore lifecycle, @Nullable ManagedAgentDefinitionStore definitions, @Nullable SessionWorktreeStore sessionWorktrees, @Nullable JournalEventTransport journalEventTransport, @Lazy @Nullable ManagedExtensionRecordStore configRecords) { - this.harnessRules = harnessRules; - this.jdbc = jdbc; - this.objectMapper = objectMapper; - this.creationSkills = creationSkills; - this.worktreeFiles = worktreeFiles; - this.extensionPackages = extensionPackages; - this.lifecycle = lifecycle; - this.definitions = definitions; - this.sessionWorktrees = sessionWorktrees; - this.journalEventTransport = journalEventTransport; - this.configRecords = configRecords; - this.clock = clock; - this.approvalMode = + String approvalMode = properties.getHarness().getApprovalMode().toLowerCase(Locale.ROOT); - this.eventPublisher = eventPublisher; - this.workspaces = workspaces; - this.agentRevision = properties.getAgentRevision(); - this.definitionToolProfiles = properties.getDefinitionToolProfiles(); - this.definitionModels = properties.getDefinitionModels(); + String agentRevision = properties.getAgentRevision(); + List definitionToolProfiles = properties.getDefinitionToolProfiles(); + List definitionModels = properties.getDefinitionModels(); var modelIds = new HashSet(); - this.modelCatalog = properties.getModelCatalog().stream().map(option -> { + List modelCatalog = properties.getModelCatalog().stream().map(option -> { String id = option.getModelId(); String label = option.getLabel() == null ? id : option.getLabel(); if (StringUtils.isBlank(id) || id.getBytes(StandardCharsets.UTF_8).length > 512 @@ -366,21 +117,42 @@ public class ManagedAgentStore implements AgentStateStore { } return new WebShellModelOption(id, label); }).toList(); - this.journalEventTransportEnabled = properties.getEventTransport().isEnabled(); - this.workspaceFilesEnabled = properties.getHarness().isWorkspaceFilesEnabled(); - this.workspaceShellEnabled = properties.getHarness().isWorkspaceShellEnabled(); - this.workspaceChildAgentsEnabled = properties.admitsPublicChildAgents(); - this.managedExtensionExecutionEnabled = properties.getExtensions().isEnabled() && properties.getManagedExtensions().isEnabled(); - this.lifecycleHandoffMillis = properties.getSessionStore().getWriterLeaseDuration().toMillis(); - this.workspaceMounts = properties.getRuntimeBroker().getWorkspaceMounts(); - this.residentWorkspaces = new ResidentWorkspaces(properties.getRuntimeBroker().getResidentWorkspaces()); - this.createdRoots = new WorkspaceCreationRoots(jdbc, properties); + boolean journalEventTransportEnabled = properties.getEventTransport().isEnabled(); + boolean workspaceFilesEnabled = properties.getHarness().isWorkspaceFilesEnabled(); + boolean workspaceShellEnabled = properties.getHarness().isWorkspaceShellEnabled(); + boolean workspaceChildAgentsEnabled = properties.admitsPublicChildAgents(); + boolean managedExtensionExecutionEnabled = properties.getExtensions().isEnabled() && properties.getManagedExtensions().isEnabled(); + long lifecycleHandoffMillis = properties.getSessionStore().getWriterLeaseDuration().toMillis(); + List workspaceMounts = properties.getRuntimeBroker().getWorkspaceMounts(); + ResidentWorkspaces residentWorkspaces = new ResidentWorkspaces(properties.getRuntimeBroker().getResidentWorkspaces()); + WorkspaceCreationRoots createdRoots = new WorkspaceCreationRoots(jdbc, properties); if (StringUtils.isBlank(agentRevision) || agentRevision.length() > 128) { throw new IllegalArgumentException( "qwen.managed-agent.agent-revision must contain 1-128" + " characters"); } + + AgentStoreRows storeRows = new AgentStoreRows(harnessRules, jdbc, objectMapper); + this.eventJournal = new AgentEventJournal(storeRows, jdbc, clock, eventPublisher); + this.sessionQueries = new AgentSessionQueries(storeRows, worktreeFiles, jdbc, modelCatalog); + this.messageProjection = new AgentMessageProjection(eventJournal, storeRows, jdbc, objectMapper, clock); + this.sessionPolicies = new AgentSessionPolicies(eventJournal, sessionQueries, storeRows, worktreeFiles, harnessRules, configRecords, jdbc, objectMapper, clock, approvalMode, workspaces, agentRevision, definitionToolProfiles, definitionModels, modelCatalog, definitions, creationSkills, workspaceFilesEnabled, workspaceShellEnabled, residentWorkspaces); + this.sessionCommands = new AgentSessionCommands(eventJournal, sessionPolicies, storeRows, worktreeFiles, jdbc, clock, workspaces, workspaceFilesEnabled, workspaceShellEnabled); + this.lifecycleOperations = new AgentLifecycleOperations(eventJournal, sessionCommands, sessionPolicies, sessionQueries, storeRows, jdbc, lifecycle, objectMapper, clock, workspaces, workspaceFilesEnabled, lifecycleHandoffMillis); + this.sessionAdmissions = new AgentSessionAdmissions(eventJournal, sessionCommands, sessionPolicies, storeRows, configRecords, jdbc, clock, approvalMode, workspaces, agentRevision, modelCatalog, definitions, creationSkills, sessionWorktrees, extensionPackages, workspaceFilesEnabled, workspaceShellEnabled, workspaceChildAgentsEnabled, managedExtensionExecutionEnabled, workspaceMounts, residentWorkspaces, createdRoots, journalEventTransport, journalEventTransportEnabled); + this.turnExecution = new AgentTurnExecution(eventJournal, messageProjection, sessionCommands, storeRows, jdbc, objectMapper, clock); + this.executionOperations = new AgentExecutionOperations(eventJournal, lifecycleOperations, sessionPolicies, sessionQueries, storeRows, worktreeFiles, jdbc, clock, workspaces, workspaceFilesEnabled); + } + + @Override + public SessionWorktreeStore.Descriptor findSessionWorktree(String tenantId, String sessionId) { + return sessionQueries.findSessionWorktree(tenantId, sessionId); + } + + @Override + public Map findSessionWorktrees(String tenantId, List sessionIds) { + return sessionQueries.findSessionWorktrees(tenantId, sessionIds); } @Override @@ -389,194 +161,33 @@ public class ManagedAgentStore implements AgentStateStore { String operation, String idempotencyKey, String requestDigest, String agentId, String requestedRevision, String title, List> input, String payloadDigest) { - AgentPin pin = resolveAgentPin(tenantId, agentId, requestedRevision, - false); - requireCreationScope(tenantId, idempotencyKey, false); - return insertSession(tenantId, operation, idempotencyKey, - requestDigest, agentId, title, input, payloadDigest, - null, actorId, pin); + return sessionAdmissions.insertSessionCommand(tenantId, actorId, operation, idempotencyKey, requestDigest, agentId, requestedRevision, title, input, payloadDigest); } @Override @Transactional public Admission insertWorkspaceSessionCommand(SessionCreationCommand command) { - String tenantId = command.tenantId(); - String actorId = command.actorId(); - String idempotencyKey = command.idempotencyKey(); - String requestDigest = command.requestDigest(); - String agentId = command.agentId(); - String requestedRevision = command.requestedRevision(); - String title = command.title(); - List> input = command.input(); - String payloadDigest = command.payloadDigest(); - WorkspaceSelection selection = command.selection(); - String requestedApprovalMode = command.requestedApprovalMode(); - boolean approvalSelectionSupported = command.approvalSelectionSupported(); - Boolean requestedPlanMode = command.requestedPlanMode(); - boolean planModeSupported = command.planModeSupported(); - List> skills = command.skills(); - boolean publicChildrenSupported = command.publicChildrenSupported(); - String modelId = command.modelId(); - List extensionPins = - command.extensionPins(); - Set extensionLifecycleProfiles = command.extensionLifecycleProfiles(); - SessionWorktreeStore.Plan worktree = command.worktree(); - if (!input.isEmpty() && !workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - // InnoDB's first consistent read must follow the migration admission lock. - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - List existing = findWorkspaceCommand(tenantId, - actorId, idempotencyKey); - if (!existing.isEmpty()) { - return replayWorkspaceCommand(tenantId, actorId, - requestDigest, existing.getFirst(), false); - } - if (modelId != null && (!"qwen-code".equals(agentId) || !workspaceFilesEnabled - || modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId)))) { - throw new ManagedAgentException(400, "unsupported_model", - "The model is not published for this Agent."); - } - AgentPin pin = selectApprovalMode(resolveAgentPin(tenantId, agentId, - requestedRevision, true), requestedApprovalMode, approvalSelectionSupported); - boolean planMode = Boolean.TRUE.equals(requestedPlanMode); - if (planMode && (!planModeSupported || !approvalSelectionSupported - || pin.hooks() || pin.mcp() || !(WorkspaceToolProfiles.FILES.equals(pin.toolProfile()) - || WorkspaceToolProfiles.isPublicShell(pin.toolProfile())))) { - throw new ManagedAgentException(409, "unsupported_feature", "Plan mode is unavailable"); - } - if (!input.isEmpty() && WorkspaceToolProfiles.isPublicShell(pin.toolProfile()) - && !workspaceShellEnabled) { - throw workspaceExecutionUnavailable(); - } - requireCreationScope(tenantId, idempotencyKey, true); - ResolvedBinding workspace = workspaces.resolveForCreation( - tenantId, actorId, selection); - if (extensionPins != null) { - if (extensionPackages == null) { - throw workspaceExecutionUnavailable(); - } - if ((!WorkspaceToolProfiles.SHELL.equals(pin.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(pin.toolProfile())) - || !extensionLifecycleProfiles.contains(pin.toolProfile())) { - throw new ManagedAgentException(409, "unsupported_feature", - "Extension configuration requires a negotiated Shell or MCP Session lifecycle profile."); - } - extensionPackages.requireSelection(tenantId, actorId, workspace.binding().getWorkspaceId(), extensionPins); - } - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId()); - boolean sshWorkspace = isSshWorkspace(workspace.binding()); - if (sshWorkspace) { - requireSshToolProfile(pin.toolProfile()); - if (pin.mcp()) { - throw workspaceExecutionUnavailable(); - } - } - boolean resident = residentWorkspaces.find(workspace.binding()) != null; - if (resident) { - if (!workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - residentWorkspaces.requireCreation(jdbc, workspace.binding(), pin.toolProfile()); - } else if (!sshWorkspace) { - WorkspaceStorageKindGuard.requireLocalAlias(jdbc, tenantId, workspace.binding().getStorageId()); - } - if (!resident && !input.isEmpty() - && (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef()) - || !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef()) - || !sshWorkspace && !configuredStorage(tenantId, workspace.binding().getStorageId()))) { - throw workspaceExecutionUnavailable(); - } - List> merged = new ArrayList<>(); - if (!skills.isEmpty() && pin.digest() != null) { - merged.addAll(AgentDefinitionExtensions.skills(readMap(definitions.find(tenantId, agentId, - Long.parseLong(pin.revision())).orElseThrow().definitionJson()).get("skills"), true)); - } - merged.addAll(skills); - var effectiveSkills = AgentDefinitionExtensions.sessionSkills(merged); - if (worktree != null && sessionWorktrees == null) { - throw new ManagedAgentException(409, "worktree_unavailable", "Session worktrees are unavailable."); - } - ResolvedBinding target = worktree == null ? workspace : worktree.target(workspace); - Admission admission = insertSession(tenantId, "CREATE_SESSION", idempotencyKey, - requestDigest, agentId, title, input, payloadDigest, - target, actorId, pin, null, planMode, modelId); - if (worktree != null) { - sessionWorktrees.admit(tenantId, actorId, admission.sessionId(), worktree, workspace); - } - if (workspaceChildAgentsEnabled && publicChildrenSupported && !resident && (!sshWorkspace || managedExtensionExecutionEnabled) - && (managedExtensionExecutionEnabled || "qwen-code".equals(agentId) && pin.digest() == null) - && WorkspaceToolProfiles.SHELL.equals(pin.toolProfile())) { - jdbc.update("UPDATE ma_session SET public_child_agents_version=1 WHERE tenant_id=? AND session_id=?", - tenantId, admission.sessionId()); - } - if (!skills.isEmpty()) { - creationSkills.save(tenantId, admission.sessionId(), effectiveSkills); - } - if (extensionPins != null) { - creationSkills.saveInitialSelection(tenantId, admission.sessionId(), extensionPins); - } - return admission; + return sessionAdmissions.insertWorkspaceSessionCommand(command); } - private boolean configuredStorage(String tenant, String storage) { - return workspaceMounts.stream().anyMatch(mount -> tenant.equals(mount.tenantId()) - && storage.equals(mount.storageId())) || createdRoots.resolve(tenant, storage, false) != null; - } - - /** Trusted internal creation; public requests and definitions cannot select this policy. */ @Transactional public Admission insertInternalTeamLeadSessionCommand(String tenantId, String actorId, String idempotencyKey, String requestDigest, String title, WorkspaceSelection selection) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - List existing = findWorkspaceCommand(tenantId, - actorId, idempotencyKey); - if (!existing.isEmpty()) { - return replayWorkspaceCommand(tenantId, actorId, - requestDigest, existing.getFirst(), true); - } - if (!workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - requireCreationScope(tenantId, idempotencyKey, true); - ResolvedBinding workspace = workspaces.resolveForCreation(tenantId, actorId, selection); - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, workspace.binding().getStorageId()); - if (!WorkspaceExecutionProfile.CONFIG_REF.equals(workspace.configRef()) - || !WorkspaceExecutionProfile.POLICY_REF.equals(workspace.policyRef()) - || !configuredStorage(tenantId, workspace.binding().getStorageId())) { - throw workspaceExecutionUnavailable(); - } - return insertSession(tenantId, "CREATE_SESSION", idempotencyKey, requestDigest, - "qwen-code", title, List.of(), null, workspace, actorId, - new AgentPin(agentRevision, null, approvalMode, WorkspaceToolProfiles.INTERNAL_TEAM_LEAD, false, false)); + return sessionAdmissions.insertInternalTeamLeadSessionCommand(tenantId, actorId, idempotencyKey, requestDigest, title, selection); } @Override public Optional findWorkspaceCreateReplay(String tenantId, String actorId, String idempotencyKey, String requestDigest) { - List existing = findWorkspaceCommand(tenantId, - actorId, idempotencyKey); - if (existing.isEmpty()) { - return Optional.empty(); - } - // The pre-gate probe re-runs the sibling's re-checks — a replay must - // not outlive the Workspace binding or the caller's read grant. - return Optional.of(replayWorkspaceCommand(tenantId, actorId, - requestDigest, existing.getFirst(), false)); + return sessionAdmissions.findWorkspaceCreateReplay(tenantId, actorId, idempotencyKey, requestDigest); } @Override @Transactional public Admission replayWorkspaceSessionCommand(String tenantId, String actorId, String idempotencyKey, String requestDigest) { - List existing = findWorkspaceCommand(tenantId, - actorId, idempotencyKey); - if (existing.isEmpty()) { - throw new ManagedAgentException(409, - "idempotency_conflict", "The creation command is missing."); - } - return replayWorkspaceCommand(tenantId, actorId, requestDigest, - existing.getFirst(), false); + return sessionAdmissions.replayWorkspaceSessionCommand(tenantId, actorId, idempotencyKey, requestDigest); } Admission insertCsiSessionCommand( @@ -587,79 +198,12 @@ public class ManagedAgentStore implements AgentStateStore { String requestedRevision, String title, WorkspaceSelection selection) { - requireCsiJournalIsolation(); - if (!TransactionSynchronizationManager.isActualTransactionActive()) { - throw new IllegalStateException("Private CSI CREATE requires a transaction"); - } - String tenantId = registration.tenantId(); - requireCreationScope(tenantId, idempotencyKey, true); - List existing = findWorkspaceCommand(tenantId, actorId, idempotencyKey); - if (!existing.isEmpty()) { - Admission replay = - replayWorkspaceCommand( - tenantId, actorId, requestDigest, existing.getFirst(), false); - requireCsiRequest(registration, replay.sessionId()); - return replay; - } - AgentPin pin = resolveAgentPin(tenantId, "qwen-code", requestedRevision, true); - ResolvedBinding workspace = workspaces.resolveForCreation(tenantId, actorId, selection); - if (!registration.storageId().equals(workspace.binding().getStorageId()) - || !CsiFilesRetirementProfile.CONFIG_REF.equals(workspace.configRef()) - || !CsiFilesRetirementProfile.POLICY_REF.equals(workspace.policyRef())) { - throw workspaceExecutionUnavailable(); - } - return insertSession( - tenantId, - "CREATE_SESSION", - idempotencyKey, - requestDigest, - "qwen-code", - title, - List.of(), - null, - workspace, - actorId, - pin, - registration); - } - - private void requireCsiJournalIsolation() { - if (journalEventTransportEnabled || journalEventTransport != null) { - throw new ManagedAgentException(409, "csi_journal_transport_unsupported", - "The private CSI lane requires an isolated journal without an event transport."); - } + return sessionAdmissions.insertCsiSessionCommand(registration, actorId, idempotencyKey, requestDigest, requestedRevision, title, selection); } RuntimeProvisionRequest requireCsiRequest( WorkspaceCsiRegistration registration, String sessionId) { - requireCsiJournalIsolation(); - SessionRecord session = requireSessionForUpdate(registration.tenantId(), sessionId); - if (!CsiFilesRetirementProfile.PROFILE.equals(session.toolProfile()) - || session.workspace() == null - || !"qwen-code".equals(session.agentId()) - || !CsiFilesRetirementProfile.CONTEXT_CONFIG_REF.equals( - session.workspace().getContextConfigRef()) - || !registration.storageId().equals(session.workspace().getStorageId())) { - throw workspaceExecutionUnavailable(); - } - RuntimeProvisionRequest request = - CsiFilesRetirementProfile.request( - session.workspace(), registration.mountRoot(), session.sessionId()); - String saved = - jdbc.queryForObject( - "SELECT runtime_request_key FROM ma_session" - + " WHERE tenant_id = ? AND session_id = ? FOR UPDATE", - String.class, - session.tenantId(), - session.sessionId()); - if (!request.requestKey().equals(saved)) { - throw workspaceExecutionUnavailable(); - } - return request; - } - - private static String childActorOf(String parentSessionId) { - return "child:" + parentSessionId; + return sessionAdmissions.requireCsiRequest(registration, sessionId); } @Override @@ -668,8 +212,7 @@ public class ManagedAgentStore implements AgentStateStore { String parentSessionId, String idempotencyKey, String requestDigest, String title, List> input, String payloadDigest, StoreModels.SessionLineage lineage) { - return insertChildSession(tenantId, parentSessionId, idempotencyKey, - requestDigest, title, input, payloadDigest, lineage, null, null); + return sessionAdmissions.insertChildSessionCommand(tenantId, parentSessionId, idempotencyKey, requestDigest, title, input, payloadDigest, lineage); } @Override @@ -679,23 +222,13 @@ public class ManagedAgentStore implements AgentStateStore { String requestDigest, String title, List> input, String payloadDigest, StoreModels.SessionLineage lineage, String childCwdRelative) { - if (childCwdRelative == null) { - throw new IllegalArgumentException("An isolated child needs its Workspace directory"); - } - return insertChildSession(tenantId, parentSessionId, idempotencyKey, - requestDigest, title, input, payloadDigest, lineage, - childCwdRelative, null); + return sessionAdmissions.insertChildSessionCommand(tenantId, parentSessionId, idempotencyKey, requestDigest, title, input, payloadDigest, lineage, childCwdRelative); } @Override public String findChildWorkspaceCwd(String tenantId, String parentSessionId, String childRunId) { - List rows = jdbc.queryForList( - "SELECT child_cwd_relative FROM ma_workspace WHERE workspace_kind =" - + " 'CHILD_GIT' AND tenant_id = ? AND parent_session_id = ? AND" - + " child_run_id = ?", - String.class, tenantId, parentSessionId, childRunId); - return rows.isEmpty() ? null : rows.getFirst(); + return sessionQueries.findChildWorkspaceCwd(tenantId, parentSessionId, childRunId); } @Override @@ -703,248 +236,7 @@ public class ManagedAgentStore implements AgentStateStore { public Admission insertPublicChildSessionCommand(String tenantId, String parentSessionId, String idempotencyKey, String requestDigest, String title, List> input, String payloadDigest, StoreModels.SessionLineage lineage, com.fasterxml.jackson.databind.JsonNode snapshot) { - return insertChildSession(tenantId, parentSessionId, idempotencyKey, requestDigest, - title, input, payloadDigest, lineage, null, snapshot); - } - - private Admission insertChildSession(String tenantId, - String parentSessionId, String idempotencyKey, - String requestDigest, String title, List> input, - String payloadDigest, StoreModels.SessionLineage lineage, - String childCwdRelative, com.fasterxml.jackson.databind.JsonNode snapshot) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - List existing = findWorkspaceCommand(tenantId, - childActorOf(parentSessionId), idempotencyKey); - if (existing.isEmpty()) { - WorkspaceMigrationAdmission.requireSessionOpen(jdbc, tenantId, parentSessionId); - } - SessionRecord parent = requireSessionForUpdate(tenantId, - parentSessionId); - if (parent.workspace() == null || residentWorkspaces.find(parent.workspace()) != null || !"ACTIVE".equals(parent.status()) - || CsiFilesRetirementProfile.PROFILE.equals(parent.toolProfile())) { - throw new ManagedAgentException(409, - "child_parent_unavailable", - "The parent Session cannot admit a child."); - } - if (parent.publicChildAgentsVersion() == 1) { - if (snapshot == null || !snapshot.path("version").isIntegralNumber() || !Set.of(1, 2).contains(snapshot.path("version").asInt()) || lineage.depth() != 1 - || childCwdRelative != null || snapshot.size() != (snapshot.path("version").asInt() == 2 ? 9 : 8) - || !snapshot.path("sourcePromptId").isTextual() || snapshot.path("sourcePromptId").asText().isBlank() - || snapshot.path("sourcePromptId").asText().length() > 144 - || !Set.of("default", "auto-edit", "yolo").contains(snapshot.path("approvalMode").asText()) - || !snapshot.path("approvalRevision").isIntegralNumber() || snapshot.path("approvalRevision").asLong(-1) < 0 - || !snapshot.path("approvalRevision").canConvertToLong() || snapshot.path("approvalRevision").asLong() > 9_007_199_254_740_991L - || !snapshot.path("modelId").isTextual() || snapshot.path("modelId").asText().isBlank() - || snapshot.path("modelId").asText().getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 512 - || !snapshot.path("modelRevision").isIntegralNumber() || snapshot.path("modelRevision").asLong(-1) < 0 - || !snapshot.path("modelRevision").canConvertToLong() || snapshot.path("modelRevision").asLong() > 9_007_199_254_740_991L - || !snapshot.has("skillsRef") || snapshot.path("modelRoute").size() != (snapshot.path("modelRoute").has("origin") ? 4 : 3) - || !snapshot.path("modelRoute").path("authType").isTextual() - || !snapshot.path("modelRoute").path("authType").asText().matches("[a-z][a-z0-9_-]{0,63}") - || !snapshot.path("modelRoute").path("endpointDigest").isTextual() - || !snapshot.path("modelRoute").path("endpointDigest").asText().matches("[a-f0-9]{64}") - || !snapshot.path("modelRoute").path("providerPin").isTextual() - || !snapshot.path("modelRoute").path("providerPin").asText().matches("[a-f0-9]{64}")) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The public child launch snapshot is invalid"); - } - if (snapshot.path("version").asInt() == 2) { - if (configRecords == null) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); - } - ManagedExtensionAgentLaunch.require(jdbc, configRecords, parent, snapshot); - } - if (snapshot.path("modelRoute").has("origin")) { - var origin = snapshot.path("modelRoute").get("origin"); - if (origin.size() != 4 || !origin.path("modelId").isTextual() || origin.path("modelId").asText().isBlank() - || origin.path("modelId").asText().getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 512 - || !origin.path("authType").isTextual() - || !origin.path("authType").asText().matches("[a-z][a-z0-9_-]{0,63}") - || !origin.path("endpointDigest").isTextual() - || !origin.path("endpointDigest").asText().matches("[a-f0-9]{64}") - || !origin.path("providerPin").isTextual() - || !origin.path("providerPin").asText().matches("[a-f0-9]{64}")) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The public child model origin is invalid"); - } - } - } else if (snapshot != null) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "The parent has no public child admission"); - } - requireLegacyMutation(tenantId, parentSessionId); - if (!input.isEmpty() && !workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - if (!existing.isEmpty()) { - WorkspaceCommand command = existing.getFirst(); - if (!command.requestDigest().equals(requestDigest)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new Admission(command.sessionId(), command.turnId(), true, - false); - } - // The terminal truth fence: the relay can give up on a stalled - // creation and commit the run's terminal verdict (creation_failed - // among them), then retire — while a lease-losing worker that - // paused before this call is about to mint the Session anyway. - // The row lock serializes that verdict against this read, so a - // settled record refuses the mint inside its own transaction — - // addressed by the parent's launch row's own primary key, which - // is the same key the idempotent admission derives, so the - // locking read never becomes a cross-tenant table scan under - // REPEATABLE READ. - String fenceScopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, - parentSessionId); - String fenceRecordKey = ManagedExtensionProjection.recordKey( - parentSessionId, "child_run", lineage.parentChildRunId()); - List runState = jdbc.query("SELECT task_state FROM" - + " ma_session_record" - + " WHERE session_scope_key = ? AND record_key = ?" - + " FOR UPDATE", - (result, row) -> result.getString(1), fenceScopeKey, - fenceRecordKey); - if (!runState.isEmpty() - && ("completed".equals(runState.getFirst()) - || "failed".equals(runState.getFirst()) - || "cancelled".equals(runState.getFirst()))) { - throw new ManagedAgentException(409, "child_run_settled", - "The child run already settled; creation owes no more Session."); - } - // A child bound to a snapshot reads with the read-only profile - // whatever its parent's is (#13753 I3): the mode comes from the row - // this transaction locks, so no caller can bind a snapshot copy to - // a profile that writes. Only the profile differs from the parent. - String toolProfile = null; - if (childCwdRelative != null - && ChildWorkspaceStore.SNAPSHOT.equals(requireReadyChildWorkspace( - tenantId, parentSessionId, lineage.parentChildRunId(), - parent.workspace(), childCwdRelative))) { - toolProfile = WorkspaceToolProfiles.READ; - } - long now = clock.millis(); - String sessionId = UUID.randomUUID().toString(); - String turnId = input.isEmpty() ? null : publicId("turn"); - String promptId = input.isEmpty() ? null : UUID.randomUUID().toString(); - jdbc.update("INSERT INTO ma_session (tenant_id," - + " session_id, agent_id, agent_revision," - + " agent_definition_digest, title," - + " status, gmt_create, gmt_modified, workspace_id," - + " workspace_generation, workspace_storage_id," - + " cwd_relative, context_config_ref," - + " context_revision, workspace_config_ref," - + " workspace_policy_ref, tool_profile," - + " creator_actor_key, approval_mode," - + " parent_session_id, root_session_id," - + " parent_child_run_id, child_depth)" - + " SELECT tenant_id, ?, agent_id," - + " agent_revision, agent_definition_digest," - + " ?, 'ACTIVE', ?, ?," - + " workspace_id, workspace_generation," - + " workspace_storage_id, " - + (childCwdRelative == null ? "cwd_relative" : "?") - + ", context_config_ref, context_revision," - + " workspace_config_ref, workspace_policy_ref, " - + (toolProfile == null ? "tool_profile" : "?") - + ", creator_actor_key, approval_mode," - + " ?, ?, ?, ?" - + " FROM ma_session" - + " WHERE tenant_id = ? AND session_id = ?", - childInsertArgs(sessionId, title, now, childCwdRelative, - toolProfile, lineage, tenantId, parentSessionId)); - jdbc.update("INSERT INTO ma_event_stream" - + " (tenant_id, session_id, stream_kind, stream_id," - + " covered_sequence, gmt_modified) VALUES" - + " (?, ?, 'SESSION', '', 0, ?)", - tenantId, sessionId, now); - if (snapshot != null) { - byte[] owner = jdbc.queryForObject("SELECT COALESCE(owner_actor_key, creator_actor_key) FROM ma_session WHERE tenant_id=? AND session_id=?", byte[].class, tenantId, parentSessionId); - jdbc.update("UPDATE ma_session SET public_child_agents_version=1, owner_actor_key=?, approval_mode=?, selected_approval_mode=NULL, approval_revision=0, model_id=?, model_revision=1 WHERE tenant_id=? AND session_id=?", - owner, snapshot.path("approvalMode").asText(), snapshot.path("modelId").asText(), tenantId, sessionId); - } - if (turnId != null) { - insertTurn(tenantId, sessionId, turnId, promptId, input, - payloadDigest, now); - } - ManagedReceipts.insertCreation(jdbc, tenantId, - com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, childActorOf(parentSessionId)), - idempotencyKey, requestDigest, sessionId, turnId, now); - var inheritedSkills = snapshot == null ? creationSkills.snapshot(tenantId, parentSessionId) - : creationSkills.fromLaunchReference(tenantId, parentSessionId, snapshot.path("skillsRef")); - if (inheritedSkills != null) { - creationSkills.save(tenantId, sessionId, inheritedSkills); - } - appendEvent(tenantId, sessionId, null, "session.created", - Map.of("sessionId", sessionId), false, null, now); - if (turnId != null) { - appendEvent(tenantId, sessionId, turnId, "turn.accepted", - acceptedData(turnId, input), false, null, now); - } - return new Admission(sessionId, turnId, false, true); - } - - private static Object[] childInsertArgs(String sessionId, String title, - long now, String childCwdRelative, String toolProfile, - StoreModels.SessionLineage lineage, String tenantId, - String parentSessionId) { - List args = new ArrayList<>(List.of(sessionId, title, now, now)); - if (childCwdRelative != null) { - args.add(childCwdRelative); - } - if (toolProfile != null) { - args.add(toolProfile); - } - args.addAll(List.of(lineage.parentSessionId(), lineage.rootSessionId(), - lineage.parentChildRunId(), lineage.depth(), tenantId, - parentSessionId)); - return args.toArray(); - } - - /** - * Decision 13 of the isolation slice: an isolated child binds only to a ready, unfinished child - * Workspace prepared from the parent's current Workspace and storage. The locking read - * serializes the creation against a finish request, which locks the same row. Answers - * the row's mode (#13753 I3), which its operation's request fixed when it was admitted. - */ - private String requireReadyChildWorkspace(String tenantId, - String parentSessionId, String childRunId, ContextBinding parent, - String childCwdRelative) { - List ready = - jdbc.query( - "SELECT child_state AS state, finish_request, parent_workspace_id AS" - + " workspace_id, workspace_generation, storage_id, child_cwd_relative" - + " FROM ma_workspace WHERE workspace_kind = 'CHILD_GIT' AND" - + " parent_session_id = ? AND child_run_id = ? AND tenant_id = ? FOR" - + " UPDATE", - (row, index) -> - "ready".equals(row.getString("state")) - && row.getString("finish_request") == null - && parent.getWorkspaceId() - .equals(row.getString("workspace_id")) - && parent.getWorkspaceGeneration() - == row.getLong("workspace_generation") - && parent.getStorageId().equals(row.getString("storage_id")) - && childCwdRelative.equals( - row.getString("child_cwd_relative")), - parentSessionId, - childRunId, - tenantId); - if (ready.size() != 1 || !ready.getFirst()) { - throw new ManagedAgentException(409, "child_workspace_not_ready", - "The child run's Workspace is not ready to bind a child Session."); - } - // The request is written once, when the row is admitted, so the - // locked row's mode needs no lock of its own. - List requests = jdbc.queryForList("SELECT o.request_json FROM ma_workspace w" - + " JOIN ma_operation o ON o.tenant_id = w.tenant_id" - + " AND o.operation_id = w.child_workspace_id AND o.operation_kind = 'CHILD_WORKSPACE'" - + " WHERE w.workspace_kind = 'CHILD_GIT' AND w.parent_session_id = ?" - + " AND w.child_run_id = ? AND w.tenant_id = ?", - String.class, parentSessionId, childRunId, tenantId); - if (requests.size() != 1) { - throw new ManagedAgentException(409, "child_workspace_not_ready", - "The child run's Workspace is not ready to bind a child Session."); - } - return ChildWorkspaceStore.workspaceMode(requests.getFirst()); + return sessionAdmissions.insertPublicChildSessionCommand(tenantId, parentSessionId, idempotencyKey, requestDigest, title, input, payloadDigest, lineage, snapshot); } @Override @@ -952,1285 +244,167 @@ public class ManagedAgentStore implements AgentStateStore { public Admission replayChildSessionCommand(String tenantId, String parentSessionId, String idempotencyKey, String requestDigest) { - SessionRecord parent = requireSessionForUpdate(tenantId, parentSessionId); - if (CsiFilesRetirementProfile.PROFILE.equals(parent.toolProfile())) { - throw new ManagedAgentException( - 409, - "child_parent_unavailable", - "The parent Session cannot admit a child."); - } - requireLegacyMutation(tenantId, parentSessionId); - List existing = findWorkspaceCommand(tenantId, - childActorOf(parentSessionId), idempotencyKey); - if (existing.isEmpty()) { - throw new ManagedAgentException(409, - "idempotency_conflict", "The creation command is missing."); - } - WorkspaceCommand command = existing.getFirst(); - if (!command.requestDigest().equals(requestDigest)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new Admission(command.sessionId(), command.turnId(), true, - false); + return sessionAdmissions.replayChildSessionCommand(tenantId, parentSessionId, idempotencyKey, requestDigest); } @Override public StoreModels.SessionLineage findChildLineage(String tenantId, String sessionId) { - // The mapper yields null for a root row, and Stream.findFirst() - // throws on a null element — fetch and read the one row directly. - List rows = jdbc.query( - "SELECT parent_session_id, root_session_id," - + " parent_child_run_id, child_depth" - + " FROM ma_session" - + " WHERE tenant_id = ? AND session_id = ?", - (result, row) -> { - String parent = result.getString("parent_session_id"); - if (parent == null) { - return null; - } - return new StoreModels.SessionLineage(parent, - result.getString("root_session_id"), - result.getString("parent_child_run_id"), - result.getInt("child_depth")); - }, tenantId, sessionId); - return rows.isEmpty() ? null : rows.get(0); - } - - private Admission replayWorkspaceCommand(String tenantId, String actorId, - String requestDigest, WorkspaceCommand command, boolean internalTeamLead) { - SessionRecord session = requireSession(tenantId, command.sessionId()); - if (!command.requestDigest().equals(requestDigest) - || internalTeamLead != WorkspaceToolProfiles.INTERNAL_TEAM_LEAD.equals(session.toolProfile()) - || internalTeamLead && (!"qwen-code".equals(session.agentId()) - || session.agentDefinitionDigest() != null)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - ContextBinding bound = session.workspace(); - if (bound == null) { - throw new ManagedAgentException(409, - "workspace_unavailable", "Workspace binding is missing."); - } - if (!workspaces.canRead(tenantId, actorId, bound.getWorkspaceId())) { - throw new ManagedAgentException(404, - "workspace_not_found", "Workspace not found."); - } - return new Admission(command.sessionId(), command.turnId(), true, - false); - } - - // H2 truncates bare BINARY casts; the suffix also prevents NUL-padding aliases. - private List findWorkspaceCommand(String tenantId, - String actorId, String idempotencyKey) { - ManagedReceipts.Receipt row = ManagedReceipts.creation(jdbc, tenantId, - com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId), idempotencyKey); - return row == null - ? List.of() - : List.of( - new WorkspaceCommand( - row.requestDigest(), row.sessionId(), row.turnId())); - } - - private record WorkspaceCommand(String requestDigest, String sessionId, - String turnId) { - } - - // A bound Session may be renamed under the Workspace-files opt-in - // (the service checks the caller's Workspace role and the Session's - // creator-keyed execution facts); unarchive stays gated. - private boolean boundRenameAllowed(SessionMutationKind kind) { - return kind == SessionMutationKind.RENAME && workspaceFilesEnabled; - } - - private static ManagedAgentException workspaceExecutionUnavailable() { - return new ManagedAgentException(409, - "workspace_unavailable", - "Hosted Workspace execution is not available."); - } - - private void requireCreationScope(String tenantId, String idempotencyKey, boolean workspaceBound) { - var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_CREATE_MODE; - var scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.TENANT; - try { - ManagedReceipts.insert(jdbc, namespace, tenantId, scope, tenantId, idempotencyKey, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(Boolean.toString(workspaceBound)), - null, null, null, null, "RESERVED", null, workspaceBound, clock.millis()); - } catch (DuplicateKeyException existing) { - // The independently persisted creation-mode claim cannot be overwritten by replay. - } - ManagedReceipts.Receipt existing = ManagedReceipts.find(jdbc, namespace, tenantId, scope, - tenantId, idempotencyKey, true); - if (existing == null - || !Boolean.valueOf(workspaceBound).equals(existing.workspaceBound())) { - throw new ManagedAgentException(409, "idempotency_conflict", - "The idempotency key was reused with different content."); - } - } - - /** - * The execution identity a new Session pins (D8b): for the built-in {@code qwen-code} agent the - * deployment revision with no definition digest; for a stored definition the resolved revision, - * its digest and the approval mode and tool profile compiled from its content (D8c-1). - */ - private record AgentPin(String revision, String digest, - String approvalMode, String toolProfile, boolean hooks, boolean mcp) { - } - - private boolean isSshWorkspace(ContextBinding binding) { - return jdbc.queryForObject("SELECT COUNT(*) FROM ma_storage WHERE alias_key = ? AND storage_kind = 'SSH_LOCAL'", - Long.class, JdbcRuntimeBindingRepository.storageAliasKey(binding.getTenantId(), binding.getStorageId())) == 1; - } - - private static void requireSshToolProfile(String profile) { - if (!WorkspaceToolProfiles.FILES.equals(profile) && !WorkspaceToolProfiles.SHELL.equals(profile)) { - throw workspaceExecutionUnavailable(); - } + return sessionQueries.findChildLineage(tenantId, sessionId); } @Override @Transactional public WebShellSessionOptions workspaceSessionOptions( String tenantId, String agentId) { - AgentPin pin = resolveAgentPin(tenantId, agentId, null, true); - return new WebShellSessionOptions( - pin.approvalMode(), availableApprovalModes(pin), - !pin.hooks() && !pin.mcp() && (WorkspaceToolProfiles.FILES.equals(pin.toolProfile()) - || WorkspaceToolProfiles.isPublicShell(pin.toolProfile())), false); - } - - private static List availableApprovalModes(AgentPin pin) { - return pin.digest() != null ? List.of(pin.approvalMode()) - : WorkspaceToolProfiles.isPublicShell(pin.toolProfile()) - ? List.of("default", "auto-edit") - : List.of("default", "auto-edit", "yolo"); + return sessionPolicies.workspaceSessionOptions(tenantId, agentId); } @Override public boolean supportsPlanMode(SessionRecord session) { - return (WorkspaceToolProfiles.FILES.equals(session.toolProfile()) - || WorkspaceToolProfiles.isPublicShell(session.toolProfile())) - && WorkspaceToolProfiles.supportsApprovalSelection(session) - && findChildLineage(session.tenantId(), session.sessionId()) == null - && !hasMcpServers(session) - && (session.agentDefinitionDigest() == null - || !definitions.pinnedContent(session).containsKey("hook_catalog")); - } - - private static AgentPin selectApprovalMode(AgentPin pin, String requested, boolean supported) { - if (requested == null) { - return pin; - } - if (!availableApprovalModes(pin).contains(requested) - && !(supported && pin.digest() == null && "yolo".equals(requested) - && WorkspaceToolProfiles.isPublicShell(pin.toolProfile()))) { - throw new ManagedAgentException(400, "unsupported_approval_mode", - "The selected approval mode is not available for this Agent."); - } - return new AgentPin(pin.revision(), pin.digest(), requested, pin.toolProfile(), pin.hooks(), pin.mcp()); - } - - // Called only for a new admission; a retry has already replayed and - // never resolves a definition again. - private AgentPin resolveAgentPin(String tenantId, String agentId, - String requested, boolean workspaceBound) { - if ("qwen-code".equals(agentId)) { - if (requested != null && !requested.equals(agentRevision)) { - throw new ManagedAgentException(400, - "unsupported_feature", - "Only the current agent revision can be selected."); - } - String toolProfile = workspaceBound - ? (workspaceShellEnabled ? WorkspaceToolProfiles.SHELL - : WorkspaceToolProfiles.FILES) - : null; - return new AgentPin(agentRevision, null, approvalMode, toolProfile, false, false); - } - if (definitions == null) { - throw com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore.notFound(); - } - ManagedAgentDefinitionStore.DefinitionRevision revision = - resolveDefinitionRevision(tenantId, agentId, requested); - AgentDefinitionCompiler.Compiled compiled = - AgentDefinitionCompiler.compile(revision.definitionJson(), - workspaceBound, approvalMode, definitionToolProfiles, - definitionModels); - Map content = readMap(revision.definitionJson()); - return new AgentPin(Long.toString(revision.revision()), - revision.digest(), compiled.approvalMode(), - compiled.toolProfile(), content.containsKey("hook_catalog"), - !AgentDefinitionExtensions.mcpServers(content.get("mcp_servers"), compiled.toolProfile()).isEmpty()); + return sessionPolicies.supportsPlanMode(session); } @Override public boolean hasMcpServers(SessionRecord session) { - if (WorkspaceToolProfiles.READ.equals(session.toolProfile())) { - return false; - } - boolean pinned = session.agentDefinitionDigest() != null && !AgentDefinitionExtensions.mcpServers( - definitions.pinnedContent(session).get("mcp_servers"), session.toolProfile()).isEmpty(); - return ManagedMcpSettings.find(jdbc, session.tenantId(), session.sessionId()) != null || pinned; + return sessionPolicies.hasMcpServers(session); } @Override @Transactional public SessionRecord requireMcpManagement(String tenantId, String actorId, String sessionId, String operationId) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null || "DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); - if (!access.canRead()) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - if (!access.atLeast(WorkspaceAccess.OPERATOR)) { - throw new ManagedAgentException(403, "session_operation_forbidden", - "Only a Workspace operator may manage MCP servers."); - } - if (!WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(session.toolProfile()) - || !workspaceFilesEnabled || !workspaceShellEnabled && WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) - || isSshWorkspace(session.workspace()) || residentWorkspaces.find(session.workspace()) != null - || findChildLineage(tenantId, sessionId) != null) { - throw new ManagedAgentException(409, "unsupported_feature", "MCP management is unavailable."); - } - requireSessionStatus(session.status(), "ACTIVE"); - requireCwdChangeRegistryFacts(session); - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - boolean replay = operationId != null && !jdbc.queryForList("SELECT 1 FROM ma_session_journal_tx" - + " WHERE tenant_id=? AND session_id=? AND command_id=? AND operation='manageMcp' LIMIT 1", - tenantId, sessionId, operationId).isEmpty(); - if (operationId != null && !replay) { - ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); - requireMcpManagementIdle(session); - } - return session; + return sessionPolicies.requireMcpManagement(tenantId, actorId, sessionId, operationId); } @Override @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void requireMcpSettingsCommit(String tenantId, String workspaceId, String sessionId) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null || !workspaceId.equals(session.workspace().getWorkspaceId()) - || !WorkspaceToolProfiles.SHELL.equals(session.toolProfile()) && !WorkspaceToolProfiles.MCP.equals(session.toolProfile()) - || findChildLineage(tenantId, sessionId) != null) { - throw new ManagedAgentException(409, "unsupported_feature", "MCP management is unavailable."); - } - requireSessionStatus(session.status(), "ACTIVE"); - requireMcpManagementIdle(session); + sessionPolicies.requireMcpSettingsCommit(tenantId, workspaceId, sessionId); } @Override @Transactional public SessionRecord requireExtensionManagement(String tenantId, String actorId, String sessionId, String operationId, boolean mutation) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null || "DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); - if (!access.canRead()) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - if (mutation && !access.atLeast(WorkspaceAccess.OPERATOR)) { - throw new ManagedAgentException(403, - "session_operation_forbidden", "Only a Workspace operator may apply extensions."); - } - var lineage = findChildLineage(tenantId, sessionId); - JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); - boolean same = ManagedConfigInstall.isPackageConfig(current) && operationId != null - && operationId.equals(current.path("operationId").asText()); - if (lineage != null && mutation && (!same || !childExtensionBootstrapCommand(session, lineage).equals(current.path("command"))) - || !workspaceFilesEnabled - || !Set.of(WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP, WorkspaceToolProfiles.FILES, WorkspaceToolProfiles.READ) - .contains(session.toolProfile())) { - throw new ManagedAgentException(409, "unsupported_feature", "Extension management is unavailable."); - } - if (mutation) { - requireSessionStatus(session.status(), "ACTIVE"); - requireCwdChangeRegistryFacts(session); - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - if (!same) { - ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); - requireMcpManagementIdle(session); - } - } - return session; + return sessionPolicies.requireExtensionManagement(tenantId, actorId, sessionId, operationId, mutation); } @Override @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void requireConfigurationCommit(String tenantId, String workspaceId, String sessionId, JsonNode body) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null || !workspaceId.equals(session.workspace().getWorkspaceId())) { - throw new ManagedAgentException(409, "unsupported_feature", "Extension management is unavailable."); - } - requireSessionStatus(session.status(), "ACTIVE"); - var lineage = findChildLineage(tenantId, sessionId); - if (lineage != null) { - requireChildExtensionBootstrap(session, lineage, body); - return; - } - Map initial = creationSkills.initialSelection(tenantId, sessionId); - if (initial != null) { - if (initial.get("operationId").equals(body.path("command").path("operationId").asText())) { - boolean beforeModel = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, - tenantId, sessionId) <= 1 - && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", - tenantId, sessionId).isEmpty(); - if (!beforeModel || !isBootstrapRevision(body) - || !objectMapper.valueToTree(initial).equals(body.path("command"))) { - throw new ManagedAgentException(409, "extension_initialization_conflict", "Initial extension selection conflicts with its creation receipt."); - } - return; - } - JsonNode configuration = ManagedMcpSettings.find(jdbc, tenantId, sessionId); - if (configuration == null || !Set.of("active", "failed").contains(configuration.path("phase").asText())) { - throw new ManagedAgentException(409, "extension_initialization_required", "Initial extension configuration must settle first."); - } - } - // Every phase observes the same locked Session row as Turn admission. - // A precommit stage may lose its idle window; it cannot commit over a new Turn. - requireMcpManagementIdle(session); - } - - private void requireChildExtensionBootstrap(SessionRecord child, StoreModels.SessionLineage lineage, JsonNode body) { - JsonNode command = childExtensionBootstrapCommand(child, lineage); - boolean onlyInitialTurn = jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id=? AND session_id=?", Integer.class, - child.tenantId(), child.sessionId()) <= 1 - && jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=? AND submission_attempted=TRUE LIMIT 1 FOR UPDATE", - child.tenantId(), child.sessionId()).isEmpty(); - if (!onlyInitialTurn || !isBootstrapRevision(body) || !command.equals(body.path("command"))) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Child extension bootstrap conflicts with its launch snapshot"); - } - } - - private static boolean isBootstrapRevision(JsonNode body) { - return "apply".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 0 - && body.path("configRevision").asLong(-1) == 1 - || "recoverPrevious".equals(body.path("mode").asText()) && body.path("previousConfigRevision").asLong(-1) == 1 - && body.path("configRevision").asLong(-1) == 2; - } - - private JsonNode childExtensionBootstrapCommand(SessionRecord child, StoreModels.SessionLineage lineage) { - if (configRecords == null) { - throw new ManagedAgentException(409, "child_launch_snapshot_invalid", "Package agent validation is unavailable"); - } - var run = configRecords.findRecord(child.tenantId(), lineage.parentSessionId(), "child_run", lineage.parentChildRunId()).orElseThrow(); - var envelope = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), run.required("inputRef")); - var launch = envelope.path("publicLaunch"); - if (launch.path("version").asInt() != 2) { - throw new ManagedAgentException(409, "unsupported_feature", "Child extension updates are unavailable"); - } - var ref = launch.required("extensionAgent").required("bundleRef"); - var bundle = configRecords.readRecordResource(child.tenantId(), lineage.parentSessionId(), ref); - var pins = objectMapper.createArrayNode(); - for (var entry : bundle.required("packages")) { - pins.add(entry.required("pin")); - } - String operation = ManagedConfigInstall.derivedOperationId("managed-extension-child:", child.sessionId() + ":" + ref.required("digest").asText()); - return objectMapper.createObjectNode().put("operationId", operation) - .put("actorKey", DigestUtils.sha256Hex("managed-extension-child:" + child.sessionId())) - .put("expectedRevision", 0).set("extensions", pins); - } - - private void requireMcpManagementIdle(SessionRecord session) { - String tenantId = session.tenantId(); - String sessionId = session.sessionId(); - // The journal may already have a consistent-read snapshot; these reads must observe the - // Plan/Turn admission that won the same Session row before this settings commit. - if (Boolean.TRUE.equals(jdbc.queryForObject("SELECT plan_enabled FROM ma_session WHERE tenant_id=? AND session_id=? FOR UPDATE", - Boolean.class, tenantId, sessionId))) { - throw new ManagedAgentException(409, "unsupported_feature", "Disable Plan mode before managing MCP servers."); - } - if (!jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id=? AND session_id=?" - + " AND status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') LIMIT 1 FOR UPDATE", tenantId, sessionId).isEmpty()) { - throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval."); - } - if (!jdbc.queryForList("SELECT record_id FROM ma_session_record WHERE record_kind='DOMAIN'" - + " AND session_scope_key=? AND tenant_id=? AND session_id=? AND domain='child_run'" - + " AND settled_at IS NULL LIMIT 1 FOR UPDATE", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - tenantId, sessionId).isEmpty()) { - throw new ManagedAgentException(409, "session_context_busy", "The Session has an unsettled child run."); - } - long now = lifecycleDatabaseTime(); - for (String options : jdbc.queryForList("SELECT options_json FROM ma_session_record WHERE session_scope_key=?" - + " AND record_kind='ACTION' AND state='requested' AND tenant_id=? AND session_id=? FOR UPDATE", - String.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), tenantId, sessionId)) { - Object expiresAt = readMap(options).get("expiresAt"); - if (expiresAt instanceof Number expiration && now < expiration.longValue()) { - throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval."); - } - } - if (!jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE scope_key=? AND target_key=?" - + " AND operation_kind IN " + SESSION_OPERATION_KINDS - + " AND (state IN ('PENDING', 'RUNNING', 'UNKNOWN') OR state='RECOVERY_BLOCKED' AND operation_kind <> 'TASK_CANCEL')" - + " LIMIT 1 FOR UPDATE", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), sessionOperationTarget(sessionId)).isEmpty() - || !jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE operation_kind='RENAME'" - + " AND target_key=? AND tenant_id=? AND session_id=? AND state IN ('PENDING', 'RUNNING') LIMIT 1 FOR UPDATE", - renameTarget(tenantId, sessionId), tenantId, sessionId).isEmpty() - || !jdbc.queryForList("SELECT receipt_key FROM ma_request_receipt WHERE namespace='SESSION_COMMAND'" - + " AND tenant_key=? AND tenant_id=? AND session_id=? AND state='PENDING' LIMIT 1 FOR UPDATE", - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(tenantId), tenantId, sessionId).isEmpty()) { - throw new ManagedAgentException(409, "session_context_busy", "The Session has an active operation."); - } - } - - private ManagedAgentDefinitionStore.DefinitionRevision resolveDefinitionRevision( - String tenantId, String agentId, String requested) { - if (requested == null) { - return definitions.latestForUpdate(tenantId, agentId) - .orElseThrow(com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore::notFound); - } - if (!DEFINITION_REVISION.matcher(requested).matches()) { - throw com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore.notFound(); - } - return definitions - .find(tenantId, agentId, Long.parseLong(requested)) - .orElseThrow(com.alibaba.qwen.code.managedagent.repository.ManagedAgentDefinitionStore::notFound); - } - - private Admission insertSession(String tenantId, String operation, - String idempotencyKey, String requestDigest, String agentId, - String title, List> input, - String payloadDigest, ResolvedBinding resolved, - String actorId, AgentPin pin) { - return insertSession( - tenantId, - operation, - idempotencyKey, - requestDigest, - agentId, - title, - input, - payloadDigest, - resolved, - actorId, - pin, - null); - } - - private Admission insertSession( - String tenantId, - String operation, - String idempotencyKey, - String requestDigest, - String agentId, - String title, - List> input, - String payloadDigest, - ResolvedBinding resolved, - String actorId, - AgentPin pin, - WorkspaceCsiRegistration registration) { - return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title, - input, payloadDigest, resolved, actorId, pin, registration, false); - } - - private Admission insertSession(String tenantId, String operation, String idempotencyKey, - String requestDigest, String agentId, String title, List> input, - String payloadDigest, ResolvedBinding resolved, String actorId, AgentPin pin, - WorkspaceCsiRegistration registration, boolean planMode) { - return insertSession(tenantId, operation, idempotencyKey, requestDigest, agentId, title, - input, payloadDigest, resolved, actorId, pin, registration, planMode, null); - } - - private Admission insertSession(String tenantId, String operation, String idempotencyKey, - String requestDigest, String agentId, String title, List> input, - String payloadDigest, ResolvedBinding resolved, String actorId, AgentPin pin, - WorkspaceCsiRegistration registration, boolean planMode, String modelId) { - ContextBinding workspace = resolved == null ? null - : resolved.binding(); - long now = clock.millis(); - String sessionId = UUID.randomUUID().toString(); - String runtimeRequestKey = - registration == null - ? null - : CsiFilesRetirementProfile.request( - workspace, registration.mountRoot(), sessionId) - .requestKey(); - String turnId = input.isEmpty() ? null : publicId("turn"); - String promptId = input.isEmpty() ? null - : UUID.randomUUID().toString(); - byte[] actorKey = actorId == null ? null - : com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId); - jdbc.update("INSERT INTO ma_session (tenant_id," - + " session_id, agent_id, agent_revision," - + " agent_definition_digest, title," - + " status, gmt_create, gmt_modified, workspace_id," - + " workspace_generation, workspace_storage_id," - + " cwd_relative, context_config_ref," - + " context_revision, workspace_config_ref," - + " workspace_policy_ref, tool_profile," - + " creator_actor_key, owner_actor_key, runtime_request_key, approval_mode," - + " plan_enabled, selected_approval_mode, approval_revision, model_id, model_revision) VALUES" - + " (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?, ?, ?, ?, ?," - + " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - tenantId, sessionId, agentId, pin.revision(), pin.digest(), - title, now, now, - workspace == null ? null : workspace.getWorkspaceId(), - workspace == null ? null : workspace.getWorkspaceGeneration(), - workspace == null ? null : workspace.getStorageId(), - workspace == null ? null : workspace.getCwdRelative(), - workspace == null ? null : workspace.getContextConfigRef(), - workspace == null ? null : workspace.getContextRevision(), - resolved == null ? null : resolved.configRef(), - resolved == null ? null : resolved.policyRef(), - registration != null - ? CsiFilesRetirementProfile.PROFILE - : workspace == null ? null : pin.toolProfile(), - actorKey, actorKey, - runtimeRequestKey, workspace != null || pin.digest() != null ? pin.approvalMode() : "yolo", - planMode, planMode ? pin.approvalMode() : null, planMode ? 1 : 0, modelId, modelId == null ? 0 : 1); - jdbc.update("INSERT INTO ma_event_stream" - + " (tenant_id, session_id, stream_kind, stream_id," - + " covered_sequence, gmt_modified) VALUES" - + " (?, ?, 'SESSION', '', 0, ?)", - tenantId, sessionId, now); - if (turnId != null) { - insertTurn(tenantId, sessionId, turnId, promptId, input, - payloadDigest, now); - } - if (resolved == null) { - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, now); - } else { - ManagedReceipts.insertCreation(jdbc, tenantId, actorKey, - idempotencyKey, requestDigest, sessionId, turnId, now); - } - appendEvent(tenantId, sessionId, null, "session.created", - Map.of("sessionId", sessionId), false, null, now); - if (turnId != null) { - appendEvent(tenantId, sessionId, turnId, "turn.accepted", - acceptedData(turnId, input), false, null, now); - } - return new Admission(sessionId, turnId, false, true); + sessionPolicies.requireConfigurationCommit(tenantId, workspaceId, sessionId, body); } @Transactional public Admission insertTurnCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, List> input, String payloadDigest) { - return insertTurnCommand(tenantId, operation, idempotencyKey, - requestDigest, sessionId, input, payloadDigest, null); + return sessionCommands.insertTurnCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, input, payloadDigest); } - // The actor-carrying variant records the caller on the command - // receipt, so the receipt's replay answers the actor who recorded it — - // an OPERATOR's own retry of his own key — and not only the creator. @Transactional public Admission insertTurnCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, List> input, String payloadDigest, String actorId) { - // A per-turn admission: a long holder of the tenant's placement row - // answers tenant_guard_contended within the hot-path bound instead - // of parking the submit past the caller's budget (#13800). - ManagedSessionStore.lockBounded(() -> WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId, - ManagedSessionStore.GUARD_ACQUIRE_TIMEOUT_SECONDS)); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - requirePublicChildImmutable(session); - // A bound Session's later Turn needs the same deployment opt-in as - // its initial one; the service admits any caller holding OPERATOR - // on the bound Workspace while the Session's creator-keyed - // execution facts hold. - if (session.workspace() != null && !workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - // The replay probe must not lock: a FOR UPDATE read that misses - // next-key-locks a gap of the tenant-shared command index, and - // concurrent admissions holding such gap locks deadlock when they - // all try to insert into the same gap. The PRIMARY KEY deduplicates - // racing inserts instead, and the service replays on - // DuplicateKeyException. - Optional existing = findCommand(tenantId, operation, - idempotencyKey); - if (existing.isPresent()) { - return replayCommand(tenantId, operation, idempotencyKey, - requestDigest); - } - if (WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) { - throw workspaceExecutionUnavailable(); - } - if (session.workspace() != null) { - if (isSshWorkspace(session.workspace())) { - requireSshToolProfile(session.toolProfile()); - if (hasMcpServers(session)) { - throw workspaceExecutionUnavailable(); - } - } - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - } - if (!"ACTIVE".equals(session.status())) { - throw new ManagedAgentException(409, "session_not_active", - "The Session does not accept new Turns."); - } - SessionWorktreeStore.requireReady(worktreeFiles, jdbc, tenantId, sessionId); - ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); - if (hasActiveTurn(tenantId, sessionId)) { - throw new ManagedAgentException(409, "turn_active", - "The Session already has an active Turn."); - } - record QueuedInput(String messageId, String state, String turnId, String actorId, String inputJson) { } - var queuedInputs = jdbc.query("SELECT message_id, state, turn_id, actor_id, input_json FROM ma_user_message" - + " WHERE tenant_id = ? AND session_id = ? AND state IN ('queued', 'claimed', 'recovery_blocked')" - + " ORDER BY sequence_number LIMIT 1", - (row, index) -> new QueuedInput(row.getString(1), row.getString(2), row.getString(3), row.getString(4), row.getString(5)), tenantId, sessionId); - if (!queuedInputs.isEmpty()) { - var first = queuedInputs.getFirst(); - if (!"claimed".equals(first.state()) || first.turnId() != null - || !("user-queue:" + first.messageId()).equals(idempotencyKey) || !Objects.equals(actorId, first.actorId()) - || !readInput(first.inputJson()).equals(input)) { - throw new ManagedAgentException(409, "user_message_queue_pending", "Send new input through the Session user message queue."); - } - } - // The W2 handshake: an open execution operation on a bound Session - // is the busy barrier the settlement re-verifies at commit; a - // later Turn must not slide in underneath it. Narrower than - // requireNoOpenOperation on purpose: a stuck display mutation - // (PENDING rename command) or an in-flight ACTION_RESPONSE would - // otherwise wedge every Turn admission without any recovery scan - // reading those tables — while the operation-ledger barriers that - // intentionally cover them stay untouched. - if (session.workspace() != null - && hasOpenExecutionOperation(tenantId, sessionId)) { - throw new ManagedAgentException(409, - "session_context_busy", - "The Session has an open operation."); - } - long now = clock.millis(); - String turnId = publicId("turn"); - insertTurn(tenantId, sessionId, turnId, - UUID.randomUUID().toString(), input, payloadDigest, now); - byte[] actorKey = commandActorKey(tenantId, actorId); - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, "COMPLETED", null, actorKey, now); - String operationId = insertTurnCommandOperation(tenantId, sessionId, - turnId, OperationKind.SUBMIT_INPUT, operation, idempotencyKey, - requestDigest, actorKey, session.status(), now); - appendEvent(tenantId, sessionId, turnId, "turn.accepted", - acceptedData(turnId, input), false, null, now); - return new Admission(sessionId, turnId, false, true, operationId); + return sessionCommands.insertTurnCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, input, payloadDigest, actorId); } @Transactional public Admission insertCancelCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, String turnId) { - return insertCancelCommand(tenantId, operation, idempotencyKey, - requestDigest, sessionId, turnId, null); + return sessionCommands.insertCancelCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, turnId); } - // The actor-carrying twin of insertTurnCommand's overload. @Transactional public Admission insertCancelCommand(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, String turnId, String actorId) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() != null && !workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - TurnRecord turn = requireTurn(tenantId, sessionId, turnId); - long now = clock.millis(); - byte[] actorKey = commandActorKey(tenantId, actorId); - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, "COMPLETED", null, actorKey, now); - String operationId = insertTurnCommandOperation(tenantId, sessionId, - turnId, OperationKind.CANCEL, operation, idempotencyKey, - requestDigest, actorKey, session.status(), now); - boolean commandEffect = ACTIVE_TURN_STATES.contains(turn.status()) - && !"CANCELLING".equals(turn.status()); - if (commandEffect) { - // D7-2: a user cancel is due at once, never behind the Runtime - // recovery park's recheck window. - int updated = jdbc.update("UPDATE ma_turn SET status =" - + " 'CANCELLING', retry_after = NULL, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND status IN" - + " ('ACCEPTED', 'RUNNING')", - now, tenantId, sessionId, turnId); - commandEffect = updated == 1; - if (commandEffect) { - appendEvent(tenantId, sessionId, turnId, - "turn.cancel.requested", Map.of("turnId", turnId), - false, null, now); - } - } - if (!commandEffect) { - // The Turn read before insertCommand can be a stale snapshot - // while another transaction already made it terminal. Re-read - // it FOR UPDATE and settle this new cancel from the current - // row; a CANCELLING Turn still carries it with the earlier one. - TurnRecord current = requireTurnForUpdate(tenantId, sessionId, - turnId); - boolean cancelled = "CANCELLED".equals(current.status()); - if (cancelled || !ACTIVE_TURN_STATES.contains(current.status())) { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.CANCEL, - cancelled ? "COMPLETED" : "FAILED", - cancelled && current.harnessEventEpoch() != null - ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", - cancelled ? null : "turn_already_settled", now); - } - } - return new Admission(sessionId, turnId, false, commandEffect, - operationId); + return sessionCommands.insertCancelCommand(tenantId, operation, idempotencyKey, requestDigest, sessionId, turnId, actorId); } @Override @Transactional public SessionMutationCommand beginSessionRename(String tenantId, String key, String digest, String sessionId, String title) { - return beginSessionRename(tenantId, key, digest, sessionId, title, - null); + return sessionCommands.beginSessionRename(tenantId, key, digest, sessionId, title); } @Override @Transactional public SessionMutationCommand beginSessionRename(String tenantId, String key, String digest, String sessionId, String title, String actorId) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - // Tenant and Session locks serialize admission without gap-locking missing index entries. - Optional previous = findCommand(tenantId, "RENAME_SESSION", key, false); - if (previous.isPresent() - && sessionId.equals(previous.get().sessionId())) { - requireRenameReplayOwner(session, previous.get(), actorId); - } - var retained = - jdbc.queryForList( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_kind = 'RENAME' AND target_key" - + " = ? AND idempotency_digest = ? AND tenant_id = ? AND session_id = ?" - + " ORDER BY target_revision DESC LIMIT 1 FOR UPDATE", - renameTarget(tenantId, sessionId), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - key), - tenantId, - sessionId); - if (!retained.isEmpty()) { - Map row = retained.getFirst(); - if (!key.equals(row.get("idempotency_key")) || !digest.equals(row.get("request_digest")) - || !title.equals(readMap((String) row.get("request_json")).get("title"))) { - throw new ManagedAgentException( - 409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - if ("SUPERSEDED".equals(row.get("state"))) { - throw new ManagedAgentException( - 409, - "session_mutation_superseded", - "A later Session title attempt has been admitted."); - } - // A parked delivery awaits the reconciler and is never re-sent. - if ("RECOVERY_BLOCKED".equals(row.get("state"))) { - throw new ManagedAgentException( - 409, - "operation_outcome_unknown", - "The original title delivery is awaiting reconciliation."); - } - } - // A delivery the reconciler proved never landed is FAILED: the key - // retries under a new revision, as any failed title key does. - if (!retained.isEmpty() && !"FAILED".equals(retained.getFirst().get("state"))) { - Map row = retained.getFirst(); - return new SessionMutationCommand( - sessionId, - "COMPLETED".equals(row.get("state")) ? "COMPLETED" : "PENDING", - true, - ((Number) row.get("target_revision")).longValue()); - } - SessionMutationCommand command = - beginSessionMutation( - tenantId, - "RENAME_SESSION", - key, - digest, - sessionId, - SessionMutationKind.RENAME, - actorId); - if ("COMPLETED".equals(command.status())) { - return command; - } - Long revision = - jdbc.queryForObject( - "SELECT title_revision FROM ma_session WHERE tenant_id = ?" - + " AND session_id = ? AND session_kind = 'PUBLIC'", - Long.class, - tenantId, - sessionId); - long next = Math.addExact(revision, 1); - long now = clock.millis(); - jdbc.update( - "UPDATE ma_session SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", title_revision = ? WHERE tenant_id = ? AND session_id = ?", - next, - tenantId, - sessionId); - jdbc.update( - "UPDATE ma_operation SET state = 'SUPERSEDED', delivery_state = 'COMPLETED', phase" - + " = 'SUPERSEDED', gmt_modified = ? WHERE operation_kind = 'RENAME' AND" - + " target_key = ? AND target_revision < ? AND state IN ('PENDING', 'RUNNING'," - + " 'RECOVERY_BLOCKED')", - now, - renameTarget(tenantId, sessionId), - next); - jdbc.update( - "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," - + " operation_id, operation_kind, tenant_id, session_id, target_kind," - + " target_key, target_revision, idempotency_key, idempotency_digest," - + " request_digest, request_json, state, phase, delivery_state, available_at," - + " gmt_create, gmt_modified) VALUES (?, ?, ?, ?, 'RENAME', ?, ?, 'SESSION', ?," - + " ?, ?, ?, ?, ?, 'PENDING', 'ADMITTED', 'PENDING', ?, ?, ?)", - renameKey(tenantId, sessionId, next), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - "rename:" + next, - tenantId, - sessionId, - renameTarget(tenantId, sessionId), - next, - key, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(key), - digest, - writeJson(Map.of("title", title)), - now, - now, - now); - return new SessionMutationCommand(sessionId, "PENDING", command.replayed(), next); - } - - // The discovery arm of the replay's recorder/creator rule: once this - // method finds an existing RENAME command receipt under the Session - // lock, the request may only reuse it when the caller is its recorder - // (the receipt's actor) or the Session's creator. The alternatives — - // claiming a still-claimable delivery for the recorder, or failing a - // digest comparison against a different title — would hand the - // recorder's pending work to the racer or name the live key before - // authorization. - // A legacy Session has no per-actor mutation gate at all; its key's - // outcome stays answerable through the same contract as the pre-read - // probe. A tombstoned Session answers the reads' 404 instead of the - // live-Session refusal. - private void requireRenameReplayOwner(SessionRecord session, - CommandRecord previous, String actorId) { - if (!previous.admitsReplayBy(actorId) && session.workspace() != null - && !workspaces.createdSession(session.tenantId(), actorId, - session.sessionId())) { - if ("DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", - "The Session was not found."); - } - throw new ManagedAgentException(409, "workspace_unavailable", - "Hosted Workspace execution is not available."); - } + return sessionCommands.beginSessionRename(tenantId, key, digest, sessionId, title, actorId); } @Override @Transactional public SessionRecord completeSessionRename(String tenantId, String key, String sessionId, String title, String bootId, long revision) { - return settleRename(new RenameDelivery(tenantId, sessionId, key, title, revision, 0), null, bootId); + return sessionCommands.completeSessionRename(tenantId, key, sessionId, title, bootId, revision); } @Override @Transactional public SessionRecord completeSessionRename(RenameDelivery delivery, String owner, String bootId) { - return settleRename(delivery, owner, bootId); - } - - private SessionRecord settleRename(RenameDelivery delivery, String owner, String bootId) { - String tenantId = delivery.tenantId(); - String sessionId = delivery.sessionId(); - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - requireLegacySessionForUpdate(tenantId, sessionId); - var rows = - jdbc.queryForList( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_key = ? AND operation_kind =" - + " 'RENAME' AND tenant_id = ? AND session_id = ? FOR UPDATE", - renameKey(tenantId, sessionId, delivery.revision()), - tenantId, - sessionId); - Long currentRevision = - jdbc.queryForObject( - "SELECT title_revision FROM ma_session WHERE tenant_id = ?" - + " AND session_id = ?", - Long.class, - tenantId, - sessionId); - if (rows.size() != 1 - || currentRevision != delivery.revision() - || !delivery.idempotencyKey().equals(rows.getFirst().get("idempotency_key")) - || !delivery.title() - .equals( - readMap((String) rows.getFirst().get("request_json")) - .get("title"))) { - throw new ManagedAgentException( - 409, - "session_mutation_superseded", - "A later Session title attempt has been admitted."); - } - Map operation = rows.getFirst(); - if (owner == null - ? !"PENDING".equals(operation.get("state")) - : !owner.equals(operation.get("claim_owner")) - || delivery.claimGeneration() - != ((Number) operation.get("claim_generation")).longValue() - || !List.of("RUNNING", "RECOVERY_BLOCKED") - .contains(operation.get("state"))) { - throw new ManagedAgentException( - 409, - "operation_claim_lost", - "The title delivery claim was lost."); - } - jdbc.update( - "UPDATE ma_request_receipt SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", state = 'PENDING' WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND state = 'FAILED'", - ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", delivery.idempotencyKey())); - SessionRecord session = - completeSessionMutation( - tenantId, - "RENAME_SESSION", - delivery.idempotencyKey(), - sessionId, - SessionMutationKind.RENAME, - delivery.title(), - bootId); - // A parked delivery answered late by its own claim was confirmed by - // the Harness after all. - boolean parked = !"RUNNING".equals(operation.get("state")) - && !"PENDING".equals(operation.get("state")); - jdbc.update( - "UPDATE ma_operation SET state = 'COMPLETED', phase = 'COMPLETED', delivery_state =" - + " 'COMPLETED', claim_owner = NULL, claim_until = NULL, gmt_modified = ?," - + " completed_at = ?" - + (parked ? ", admission_stage = 'HARNESS_CONFIRMED', failure_code = NULL" : "") - + " WHERE operation_key = ?", - clock.millis(), - clock.millis(), - renameKey(tenantId, sessionId, delivery.revision())); - return session; + return sessionCommands.completeSessionRename(delivery, owner, bootId); } @Override @Transactional public void abandonSessionRename(String tenantId, String key, String sessionId, long revision, String owner) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - requireLegacySessionForUpdate(tenantId, sessionId); - if (jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_operation WHERE operation_key = ? AND operation_kind = 'RENAME' AND state = 'RUNNING' AND claim_owner = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - Integer.class, - renameKey(tenantId, sessionId, revision), - owner) - == 1) { - abandonSessionMutation(tenantId, "RENAME_SESSION", key, sessionId); - } + sessionCommands.abandonSessionRename(tenantId, key, sessionId, revision, owner); } @Override @Transactional public List deliverableRenames(long now) { - // A submitted delivery whose claim expired parks for the reconciler. - String unanswered = "(state = 'RUNNING' AND phase = 'SUBMITTED' AND claim_until <= " - + ManagedSchemaTime.SQL_NOW + ")"; - for (Map row : jdbc.queryForList( - "SELECT operation_key, tenant_id, session_id, idempotency_key FROM ma_operation WHERE operation_kind = 'RENAME' AND " + unanswered + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) LIMIT 100")) { - parkRename(row, unanswered); - } - jdbc.update( - "UPDATE ma_operation SET state = 'PENDING', delivery_state = 'PENDING', phase = 'ADMITTED', available_at = ?, claim_owner = NULL, claim_until = NULL, gmt_modified = ? WHERE operation_kind = 'RENAME' AND state = 'RUNNING' AND phase = 'PREPARING' AND claim_until <= " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) LIMIT 100", - now, - now); - return jdbc.query( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_kind = 'RENAME' AND delivery_state = 'PENDING' AND state = 'PENDING' AND available_at <= ? AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY available_at LIMIT 100", - this::renameDelivery, - now); + return sessionCommands.deliverableRenames(now); } @Override @Transactional public Optional claimRename(RenameDelivery delivery, String owner, Duration lease) { - WorkspaceMigrationAdmission.lockTenant(jdbc, delivery.tenantId()); - requireLegacySessionForUpdate(delivery.tenantId(), delivery.sessionId()); - long now = clock.millis(); - int claimed = - jdbc.update( - "UPDATE ma_operation SET state = 'RUNNING', delivery_state = 'LEASED', phase = 'PREPARING', claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), claim_generation = claim_generation + 1, attempt_count = attempt_count + 1, gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND ((state = 'PENDING' AND available_at <= ?) OR (state = 'RUNNING' AND phase = 'PREPARING' AND claim_until <= " + ManagedSchemaTime.SQL_NOW + "))", - owner, - lease.toMillis(), - now, - renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()), - now); - if (claimed == 0) { - return Optional.empty(); - } - return jdbc - .query( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE operation_key = ? AND operation_kind =" - + " 'RENAME'", - this::renameDelivery, - renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision())) - .stream() - .findFirst(); + return sessionCommands.claimRename(delivery, owner, lease); } @Override public void markRenameSubmitted(RenameDelivery delivery, String owner) { - int changed = - jdbc.update( - "UPDATE ma_operation SET phase = 'SUBMITTED', gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND state = 'RUNNING' AND phase = 'PREPARING' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - clock.millis(), - renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()), - owner, - delivery.claimGeneration()); - if (changed != 1) { - throw new ManagedAgentException( - 409, - "operation_claim_lost", - "The title delivery claim was lost."); - } + sessionCommands.markRenameSubmitted(delivery, owner); } @Override @Transactional public void retryRename(RenameDelivery delivery, String owner, long availableAt) { - WorkspaceMigrationAdmission.lockTenant(jdbc, delivery.tenantId()); - requireLegacyMutation(delivery.tenantId(), delivery.sessionId()); - String key = renameKey(delivery.tenantId(), delivery.sessionId(), delivery.revision()); - parkRename(Map.of("operation_key", key, "tenant_id", delivery.tenantId(), - "session_id", delivery.sessionId(), "idempotency_key", delivery.idempotencyKey()), - "state = 'RUNNING' AND phase = 'SUBMITTED' AND claim_owner = ? AND claim_generation = ?", - owner, delivery.claimGeneration()); - jdbc.update( - "UPDATE ma_operation SET state = 'PENDING', delivery_state = 'PENDING', phase =" - + " 'ADMITTED', available_at = ?, claim_owner = NULL, claim_until = NULL," - + " gmt_modified = ? WHERE operation_key = ? AND operation_kind = 'RENAME' AND" - + " state = 'RUNNING' AND phase = 'PREPARING' AND claim_owner = ? AND" - + " claim_generation = ?", - availableAt, - clock.millis(), - key, - owner, - delivery.claimGeneration()); - } - - // Parks one delivery whose submission went unanswered, if it still - // matches the guard. Its receipt retires as an abandoned delivery's - // does, so the parked title holds neither later titles nor the Session - // lifecycle hostage; the claim's own late reply and the reconciler both - // complete a retired receipt. - private void parkRename(Map row, String guard, Object... guardArguments) { - List arguments = new ArrayList<>(List.of(lifecycleDatabaseTime(), clock.millis(), - row.get("operation_key"))); - arguments.addAll(List.of(guardArguments)); - if (jdbc.update("UPDATE ma_operation SET " + PARK_RENAME + ", available_at = ?, gmt_modified = ?" - + " WHERE operation_key = ? AND operation_kind = 'RENAME' AND " + guard, - arguments.toArray()) != 1) { - return; - } - String tenantId = (String) row.get("tenant_id"); - jdbc.update( - "UPDATE ma_request_receipt SET state = 'FAILED', gmt_modified = ? WHERE receipt_key = ?" - + " AND namespace = 'SESSION_COMMAND' AND session_id = ? AND state = 'PENDING'", - clock.millis(), - ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", (String) row.get("idempotency_key")), - row.get("session_id")); - } - - private RenameDelivery renameDelivery(java.sql.ResultSet row, int index) throws java.sql.SQLException { - return new RenameDelivery( - row.getString("tenant_id"), - row.getString("session_id"), - row.getString("idempotency_key"), - (String) readMap(row.getString("request_json")).get("title"), - row.getLong("target_revision"), - row.getInt("attempt_count"), - row.getLong("claim_generation")); - } - - private static String renameTarget(String tenant, String session) { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - "SESSION", tenant, session); - } - private static String renameKey(String tenant, String session, long revision) { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract - .OperationKind.RENAME, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), - renameTarget(tenant, session), - "rename:" + revision); - } - // A parked title (RECOVERY_BLOCKED) is not - // pending: nothing sends it again, and the reconciler settles it in any - // Session status, so it never holds the lifecycle (D7-2). - private boolean hasPendingRenameDelivery(String tenantId, String sessionId) { - return jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_operation WHERE operation_kind = 'RENAME' AND" - + " target_key = ? AND tenant_id = ? AND session_id = ? AND state IN" - + " ('PENDING', 'RUNNING')", - Integer.class, - renameTarget(tenantId, sessionId), - tenantId, - sessionId) - > 0; + sessionCommands.retryRename(delivery, owner, availableAt); } @Transactional public SessionMutationCommand beginSessionMutation(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, SessionMutationKind kind) { - return beginSessionMutation(tenantId, operation, idempotencyKey, - requestDigest, sessionId, kind, null); + return sessionCommands.beginSessionMutation(tenantId, operation, idempotencyKey, requestDigest, sessionId, kind); } - // The actor-carrying twin of insertTurnCommand's overload. @Transactional public SessionMutationCommand beginSessionMutation(String tenantId, String operation, String idempotencyKey, String requestDigest, String sessionId, SessionMutationKind kind, String actorId) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() != null && !boundRenameAllowed(kind)) { - throw workspaceExecutionUnavailable(); - } - Optional existing = findCommand(tenantId, operation, - idempotencyKey, kind != SessionMutationKind.RENAME); - if (existing.isPresent()) { - CommandRecord command = existing.get(); - if (!command.requestDigest().equals(requestDigest) - || !command.sessionId().equals(sessionId)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - if ("FAILED".equals(command.status())) { - if (session.workspace() != null) { - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - } - requireNoOpenOperation(tenantId, sessionId); - validateMutationStatus(session, kind); - jdbc.update( - "UPDATE ma_request_receipt SET state = 'PENDING', mutation_attempt_sequence" - + " = ?, gmt_modified = ? WHERE receipt_key = ? AND namespace =" - + " 'SESSION_COMMAND'", - session.lastSequence(), - clock.millis(), - ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); - return new SessionMutationCommand(sessionId, "PENDING", true); - } - return new SessionMutationCommand(sessionId, command.status(), - true); - } - if (session.workspace() != null) { - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - } - requireNoOpenOperation(tenantId, sessionId); - validateMutationStatus(session, kind); - long now = clock.millis(); - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, null, "PENDING", session.status(), - commandActorKey(tenantId, actorId), now); - jdbc.update("UPDATE ma_request_receipt SET gmt_modified = " + ManagedSchemaTime.SQL_NOW - + ", mutation_attempt_sequence" - + " = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'", - session.lastSequence(), ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); - // Older retired commands may have left their requested event behind. - String requestedSource = mutationSource(operation, idempotencyKey, - "requested"); - if (!hasSourceEvent(tenantId, sessionId, requestedSource)) { - appendEvent(tenantId, sessionId, null, - mutationEvent(kind, "requested"), - Map.of("sessionId", sessionId), false, - requestedSource, now); - } - return new SessionMutationCommand(sessionId, "PENDING", false); + return sessionCommands.beginSessionMutation(tenantId, operation, idempotencyKey, requestDigest, sessionId, kind, actorId); } @Transactional public SessionRecord completeSessionMutation(String tenantId, String operation, String idempotencyKey, String sessionId, SessionMutationKind kind, String title, String harnessBootId) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() != null && !boundRenameAllowed(kind)) { - throw workspaceExecutionUnavailable(); - } - CommandRecord command = findCommand(tenantId, operation, - idempotencyKey, true).orElseThrow(() -> - new IllegalStateException( - "Session mutation command is unavailable")); - if (!command.sessionId().equals(sessionId)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key belongs to another Session."); - } - if ("COMPLETED".equals(command.status())) { - return session; - } - if (!"PENDING".equals(command.status()) - && !"FAILED".equals(command.status())) { - throw new IllegalStateException( - "Session mutation command has an unknown status"); - } - // A retired receipt still completes for a sibling that entered the - // Harness before the retirement, but never over a later mutation: - // while it was FAILED another key could begin and complete, and - // completing this one now would revert that newer outcome. - if ("FAILED".equals(command.status()) && supersededByLaterMutation( - tenantId, sessionId, operation, idempotencyKey, kind)) { - throw new ManagedAgentException(409, - "session_mutation_superseded", - "A later change to the Session completed after this" - + " request was retired."); - } - if (session.workspace() != null) { - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - } - validateMutationStatus(session, kind); - long now = clock.millis(); - Map data = Map.of("sessionId", sessionId); - switch (kind) { - case RENAME -> { - jdbc.update("UPDATE ma_session SET title = ?," - + " harness_boot_id =" - + " COALESCE(harness_boot_id, ?)," - + " gmt_modified = ?, version = version + 1" - + " WHERE tenant_id = ? AND session_id = ?", - title, harnessBootId, now, tenantId, sessionId); - data = Map.of("sessionId", sessionId, - "metadata", Map.of("title", title)); - } - // An archived Session was closed first, so it stays closed. - case UNARCHIVE -> jdbc.update("UPDATE ma_session SET" - + " status = 'CLOSED', gmt_modified = ?, version =" - + " version + 1 WHERE tenant_id = ? AND" - + " session_id = ?", - now, tenantId, sessionId); - } - jdbc.update( - "UPDATE ma_request_receipt SET state = 'COMPLETED', gmt_modified = ? WHERE" - + " receipt_key = ? AND namespace = 'SESSION_COMMAND'", - now, - ManagedReceipts.commandKey(tenantId, operation, idempotencyKey)); - appendEvent(tenantId, sessionId, null, - mutationEvent(kind, "completed"), data, false, - mutationSource(operation, idempotencyKey, - "completed"), now); - return requireSessionForUpdate(tenantId, sessionId); + return sessionCommands.completeSessionMutation(tenantId, operation, idempotencyKey, sessionId, kind, title, harnessBootId); } @Override @Transactional public void abandonSessionMutation(String tenantId, String operation, String idempotencyKey, String sessionId) { - requireLegacyMutation(tenantId, sessionId); - // Keep the digest and receipt for replay and concurrent completion. - // A completed outcome must never be overwritten by a failing sibling. - jdbc.update("UPDATE ma_request_receipt SET state = 'FAILED'," - + " gmt_modified = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'" - + " AND session_id = ? AND state = 'PENDING'", - clock.millis(), ManagedReceipts.commandKey(tenantId, operation, idempotencyKey), sessionId); + sessionCommands.abandonSessionMutation(tenantId, operation, idempotencyKey, sessionId); } @Override @@ -2238,691 +412,72 @@ public class ManagedAgentStore implements AgentStateStore { public OperationAdmission beginOperation(String tenantId, String sessionId, OperationKind kind, String actorDigest, String idempotencyKey, String requestDigest) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - if (requireLegacySessionForUpdate(tenantId, sessionId).workspace() != null) { - throw workspaceExecutionUnavailable(); - } - return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, null, false); + return lifecycleOperations.beginOperation(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest); } @Override public boolean workspaceFilesEnabled() { - return workspaceFilesEnabled; + return sessionPolicies.workspaceFilesEnabled(); } @Override public boolean workspaceShellEnabled() { - return workspaceShellEnabled; + return sessionPolicies.workspaceShellEnabled(); } @Override @Transactional public OperationAdmission beginWorkspaceClose(String tenantId, String sessionId, String actorId, String actorDigest, String key, String digest, boolean supported) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - return beginLifecycle(tenantId, sessionId, OperationKind.CLOSE, actorDigest, key, digest, actorId, supported); + return lifecycleOperations.beginWorkspaceClose(tenantId, sessionId, actorId, actorDigest, key, digest, supported); } @Override @Transactional public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean closeSupported) { - // H4b child close is the only production caller. Public lifecycle uses - // the protocol overload and requires v2 for an active Shell or MCP Session. - return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, closeSupported, 0, false); + return lifecycleOperations.beginWorkspaceLifecycle(tenantId, sessionId, kind, actorId, actorDigest, key, digest, closeSupported); } @Override @Transactional public OperationAdmission beginWorkspaceLifecycle(String tenantId, String sessionId, OperationKind kind, String actorId, String actorDigest, String key, String digest, boolean supported, int protocolVersion) { - return beginLifecycle(tenantId, sessionId, kind, actorDigest, key, digest, actorId, supported, protocolVersion, true); + return lifecycleOperations.beginWorkspaceLifecycle(tenantId, sessionId, kind, actorId, actorDigest, key, digest, supported, protocolVersion); } @Override public boolean hasCompletedWorkspaceClose(String tenantId, String sessionId) { - return completedWorkspaceCloses(tenantId, List.of(sessionId)) - .contains(sessionId); + return sessionQueries.hasCompletedWorkspaceClose(tenantId, sessionId); } - /** The given Sessions with a completed workspace close, in one read. */ @Override public Set completedWorkspaceCloses(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Set.of(); - } - List arguments = new ArrayList<>(sessionIds.size() + 1); - arguments.add(tenantId); - sessionIds.forEach(id -> arguments.add(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, id))); - return new HashSet<>(jdbc.queryForList("SELECT session_id FROM" - + " ma_operation WHERE tenant_id = ?" - + " AND scope_key IN (" + placeholders(sessionIds.size()) - + ") AND operation_kind = 'CLOSE' AND state = 'COMPLETED'" - + " AND receipt_id IS NOT NULL", - String.class, arguments.toArray())); - } - - // The lifecycle owner gate: the Session's recorded owner (with the - // pre-V40 creator fallback) holding a current read grant. Unreadable is - // invisible; a readable non-owner gets the sibling family's 403. - private void requireWorkspaceCreator(SessionRecord session, String actorId) { - if (!workspaces.canRead(session.tenantId(), actorId, session.workspace().getWorkspaceId())) { - throw new ManagedAgentException( - 404, "session_not_found", "The Session was not found."); - } - if (!workspaces.isSessionOwner(session.tenantId(), actorId, session.sessionId())) { - throw new ManagedAgentException(403, "session_operation_forbidden", - "Only the Session owner may manage it."); - } + return sessionQueries.completedWorkspaceCloses(tenantId, sessionIds); } @Override @Transactional public SessionMutation unarchiveWorkspaceSession(String tenantId, String sessionId, String actorId, String scopedKey, String requestDigest) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - requireWorkspaceCreator(session, actorId); - String namespace = "UNARCHIVE_WORKSPACE_SESSION"; - // Replay first, like every other mutation path: a recorded success - // must not 404 after a later delete. - Optional existing = findCommand(tenantId, namespace, scopedKey, true); - if (existing.isPresent()) { - if (!existing.get().requestDigest().equals(requestDigest) || !existing.get().sessionId().equals(sessionId)) { - throw new ManagedAgentException(409, "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new SessionMutation(session, true); - } - // The tombstone 404 precedes the migration fence and the shell - // refusal: a fresh key against a deleted Session must not answer - // a retryable 409 for a Session that will never come back. - if ("DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - if (WorkspaceToolProfiles.isShellOrMcp(session.toolProfile()) - && (WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) != 2 - || !List.of("CLOSED", "ARCHIVED").contains(session.status()))) { - throw workspaceExecutionUnavailable(); - } - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - requireSessionStatus(session.status(), "ARCHIVED"); - requireNoOpenOperation(tenantId, sessionId); - if (!hasCompletedWorkspaceClose(tenantId, sessionId)) { - throw workspaceExecutionUnavailable(); - } - long now = lifecycleDatabaseTime(); - insertCommand( - tenantId, - namespace, - scopedKey, - requestDigest, - sessionId, - null, - "COMPLETED", - "ARCHIVED", - null, - now); - Map data = Map.of("sessionId", sessionId); - appendEvent(tenantId, sessionId, null, "session.unarchive.requested", data, false, - mutationSource(namespace, scopedKey, "requested"), now); - jdbc.update("UPDATE ma_session SET status = 'CLOSED', gmt_modified = ?, version = version + 1" - + " WHERE tenant_id = ? AND session_id = ?", now, tenantId, sessionId); - appendEvent(tenantId, sessionId, null, "session.unarchived", data, false, - mutationSource(namespace, scopedKey, "completed"), now); - return new SessionMutation(requireSessionForUpdate(tenantId, sessionId), false); + return lifecycleOperations.unarchiveWorkspaceSession(tenantId, sessionId, actorId, scopedKey, requestDigest); } - private static final String SESSION_OPERATION_KINDS = - "('CLOSE', 'ARCHIVE', 'DELETE', 'ACTION_RESPONSE', 'CWD_CHANGE', 'TASK_CANCEL', 'CONTEXT_COMPRESSION')"; - // D7-1: a Turn input or cancel command targets its Turn, never the - // Session, so no Session barrier above counts it: the Turn row is its - // barrier, and the Turn dispatcher, which holds the only lease that may - // act on the Turn, is its only deliverer. - private static final String TURN_COMMAND_KINDS = "('SUBMIT_INPUT', 'CANCEL')"; - // D7-2: RECOVERY_BLOCKED carries two meanings, decided by kind. For - // these kinds it means the command's acceptance is unknown: no claim - // ever takes the row, nothing re-sends it, and only the reconciler - // settles it, from the kind's own keyed evidence. - private static final String ACCEPTANCE_UNKNOWN_KINDS = - "('TASK_CANCEL', 'SUBMIT_INPUT', 'CANCEL', 'RENAME')"; - // For these it is a published wait on a precondition of an operation - // whose acceptance is known: the lifecycle coordinator re-drives it, and - // keyed effect receipts keep the re-drive from repeating an effect. - private static final String LIFECYCLE_WAIT_KINDS = - "('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE')"; - // A Turn CANCEL parked cancel_unconfirmed is in ACCEPTANCE_UNKNOWN_KINDS - // (decision 11): the reconciler settles it from CancelEvidence, which - // reads the original prompt's retained durable terminal event, and the - // row is never claimed or delivered again. - /** Turn input whose submission no Harness answered (D7-2). */ - static final String SUBMIT_INPUT_UNCONFIRMED = "submit_input_unconfirmed"; - /** A Turn cancel whose effect on an unobserved execution no Harness - * answered (D7-2). */ - static final String CANCEL_UNCONFIRMED = "cancel_unconfirmed"; - /** A Session title whose submitted delivery no Harness answered (D7-2). */ - static final String SESSION_TITLE_UNCONFIRMED = "session_title_unconfirmed"; - /** A parked Session title the sealed journal proves never landed. */ - static final String SESSION_TITLE_UNDELIVERED = "session_title_undelivered"; - // A RENAME parks with the same columns either way: acceptance unknown, - // never re-sent, settled only by the reconciler or by the same claim's - // own late reply. Its claim fields stay, so that late reply still settles. - private static final String PARK_RENAME = "state = 'RECOVERY_BLOCKED'," - + " phase = 'RECOVERY_BLOCKED', admission_stage = 'JAVA_DURABLE'," - + " delivery_state = 'BLOCKED', failure_code = '" - + SESSION_TITLE_UNCONFIRMED + "'"; + static final String SUBMIT_INPUT_UNCONFIRMED = AgentSessionCommands.SUBMIT_INPUT_UNCONFIRMED; - private String insertTurnCommandOperation(String tenantId, - String sessionId, String turnId, OperationKind kind, - String command, String idempotencyKey, String requestDigest, - byte[] actorKey, String sessionStatus, long now) { - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - String target = turnOperationTarget(turnId); - String operationId = publicId("op"); - String operationKey = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.OperationKind.valueOf(kind.name()), - scope, target, operationId); - jdbc.update( - "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," - + " target_kind, target_key, target_id, phase, request_json," - + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," - + " actor_key, idempotency_key, request_digest, state, admission_stage," - + " delivery_state, session_status_before, available_at, gmt_create," - + " gmt_modified) VALUES (?, ?, ?, 'TURN', ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?," - + " ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?)", - operationKey, - scope, - scope, - target, - turnId, - writeJson(Map.of("schemaVersion", 1, "kind", kind.name(), - "sessionId", sessionId, "turnId", turnId)), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - idempotencyKey), - tenantId, - sessionId, - operationId, - kind.name(), - actorKey, - idempotencyKey, - requestDigest, - sessionStatus, - now, - now, - now); - ManagedReceipts.linkCommandOperation(jdbc, tenantId, command, - idempotencyKey, operationKey); - return operationId; - } + static final String CANCEL_UNCONFIRMED = AgentSessionCommands.CANCEL_UNCONFIRMED; - private static String turnOperationTarget(String turnId) { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(turnId); - } + static final String SESSION_TITLE_UNCONFIRMED = AgentSessionCommands.SESSION_TITLE_UNCONFIRMED; - /** - * Settles the open input and cancel operations of a Turn from the Turn's - * own committed facts, in the transaction that commits them. Input is - * accepted once the Turn holds a Harness event epoch, the fact its - * dispatcher records when a Harness admits the Turn; until then a - * cancelled or failed Turn ends the input undelivered. A cancel - * completes when the Turn is cancelled — by the Harness, or by Java - * before any Harness admitted it — and fails turn_already_settled when - * the Turn ended otherwise. One whose admitted Turn failed with - * managed_runtime_recovery_blocked parks recovery_blocked with - * cancel_unconfirmed: the cancel's effect on the unobserved execution - * is itself unknown. A Turn that has not ended leaves its cancels - * pending, so cancels of one Turn coalesce. - */ - private void settleTurnCommands(String tenantId, String sessionId, - String turnId, long now) { - // Current reads only: every caller holds the Session row, but its - // snapshot may predate that lock and miss a cancel committed since. - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - boolean admitted = turn.harnessEventEpoch() != null; - if (admitted) { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.SUBMIT_INPUT, "COMPLETED", "HARNESS_CONFIRMED", - null, now); - } else if ("CANCELLED".equals(turn.status())) { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.SUBMIT_INPUT, "CANCELLED", "JAVA_DURABLE", - null, now); - } else if ("FAILED".equals(turn.status()) - && turn.submissionAttempted()) { - // A null epoch does not prove non-admission (R10-4): the submit - // may have been admitted and its reply lost. The input parks - // until the Session journal proves which (D7-2). - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.SUBMIT_INPUT, "RECOVERY_BLOCKED", - "JAVA_DURABLE", SUBMIT_INPUT_UNCONFIRMED, now); - } else if ("FAILED".equals(turn.status())) { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.SUBMIT_INPUT, "FAILED", "JAVA_DURABLE", - turn.errorCode(), now); - } - if ("CANCELLED".equals(turn.status())) { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.CANCEL, "COMPLETED", - admitted ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", null, now); - } else if (!ACTIVE_TURN_STATES.contains(turn.status())) { - if ((admitted || turn.submissionAttempted()) - && "managed_runtime_recovery_blocked".equals( - turn.errorCode())) { - // Under the Session row lock, every cancel still pending - // here predates the failure; a cancel admitted afterwards - // settles itself in insertCancelCommand. A submission mark - // is enough: a null epoch does not prove non-admission - // (R10-4). - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.CANCEL, "RECOVERY_BLOCKED", - "JAVA_DURABLE", CANCEL_UNCONFIRMED, now); - } else { - settleTurnCommand(tenantId, sessionId, turnId, - OperationKind.CANCEL, "FAILED", "JAVA_DURABLE", - "turn_already_settled", now); - } - } - } - - // Only a completed operation is delivered and carries a receipt; a - // failed or cancelled one stays java_durable and blocked, so it is never - // delivered again. A parked submit_input or Turn cancel is due for the - // reconciler at once (decision 11). - private void settleTurnCommand(String tenantId, String sessionId, - String turnId, OperationKind kind, String state, - String admissionStage, String failureCode, long now) { - boolean completed = "COMPLETED".equals(state); - boolean parked = "RECOVERY_BLOCKED".equals(state); - // The identity index confines this locking scan to the Turn's own - // operations. Left to the optimizer, a long settled history steers it - // to a (kind, state) index whose range holds every Session's pending - // rows, and two Sessions' settles then deadlock. H2 skips the hint. - for (String operationKey : jdbc.queryForList("SELECT operation_key" - + " FROM ma_operation /*! FORCE INDEX (uq_ma_operation_identity) */" - + " WHERE scope_key = ? AND" - + " operation_kind = ? AND target_key = ? AND state =" - + " 'PENDING' FOR UPDATE", - String.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - kind.name(), turnOperationTarget(turnId))) { - jdbc.update("UPDATE ma_operation SET state = ?, phase = ?," - + " admission_stage = ?, delivery_state = ?," - + " receipt_id = ?, failure_code = ?," - + " available_at = ?, gmt_modified = ?," - + " completed_at = ? WHERE" - + " operation_key = ? AND state = 'PENDING'", - state, state, admissionStage, - completed ? "CONFIRMED" : "BLOCKED", - completed ? publicId("rcpt") : null, failureCode, now, - now, parked ? null : now, operationKey); - } - } - - private Optional findTurnCommandOperation( - String tenantId, String sessionId, String operationId) { - return jdbc - .query( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " - + TURN_COMMAND_KINDS - + " AND operation_id = ?", - (result, row) -> { - if (!tenantId.equals(result.getString("tenant_id")) - || !sessionId.equals(result.getString("session_id"))) { - throw new IllegalStateException( - "Session operation identity does not match its index key"); - } - return operationMapper.mapRow(result, row); - }, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - operationId) - .stream() - .findFirst(); - } - - private static String sessionOperationTarget(String sessionId) { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(sessionId); - } - - private static String sessionOperationKey(String tenantId, String sessionId, OperationKind kind, String operationId) { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.operationKey( - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.OperationKind.valueOf(kind.name()), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), sessionOperationTarget(sessionId), operationId); - } - - private Optional replayOperation(String tenantId, String sessionId, - OperationKind kind, String actorDigest, String idempotencyKey) { - return jdbc - .query( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind = ? AND" - + " target_key = ? AND actor_digest = ? AND idempotency_digest = ?", - (result, row) -> { - if (!tenantId.equals(result.getString("tenant_id")) - || !sessionId.equals(result.getString("session_id")) - || !idempotencyKey.equals( - result.getString("idempotency_key"))) { - throw new IllegalStateException( - "Session operation identity does not match its index key"); - } - return operationMapper.mapRow(result, row); - }, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - kind.name(), - sessionOperationTarget(sessionId), - actorDigest, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - idempotencyKey)) - .stream() - .findFirst(); - } - - private Optional findSessionOperation(String tenantId, String sessionId, String operationId, boolean lock) { - return jdbc - .query( - "SELECT " + ManagedSchemaProjection.OPERATION.columns() + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND operation_id = ?" - + (lock ? " FOR UPDATE" : ""), - (result, row) -> { - if (!tenantId.equals(result.getString("tenant_id")) - || !sessionId.equals(result.getString("session_id"))) { - throw new IllegalStateException( - "Session operation identity does not match its index key"); - } - return operationMapper.mapRow(result, row); - }, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - operationId) - .stream() - .findFirst(); - } + static final String SESSION_TITLE_UNDELIVERED = AgentExecutionOperations.SESSION_TITLE_UNDELIVERED; @Override @Transactional public OperationAdmission beginChildClose(String tenantId, String parentSessionId, String childSessionId, String childRunId, String actorDigest, String key, String digest, boolean supported) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - requireSessionForUpdate(tenantId, parentSessionId); - byte[] creator = - jdbc.queryForObject( - "SELECT creator_actor_key FROM ma_session WHERE tenant_id = ? AND" - + " session_id = ?", - byte[].class, - tenantId, - parentSessionId); - var lineage = - jdbc.queryForList( - "SELECT parent_session_id, parent_child_run_id, creator_actor_key FROM" - + " ma_session WHERE tenant_id = ? AND session_id = ? AND session_kind" - + " = 'PUBLIC' FOR UPDATE", - tenantId, - childSessionId); - if (lineage.size() != 1 - || !parentSessionId.equals(lineage.getFirst().get("parent_session_id")) - || !childRunId.equals(lineage.getFirst().get("parent_child_run_id")) - || !(lineage.getFirst().get("creator_actor_key") instanceof byte[] actor) - || actor.length == 0 - || !java.util.Arrays.equals(actor, creator)) { - throw new ManagedAgentException( - 404, - "session_not_found", - "The child Session lineage was not found."); - } - return beginLifecycle(tenantId, childSessionId, OperationKind.CLOSE, actorDigest, key, digest, - null, supported, requireSession(tenantId, childSessionId).publicChildAgentsVersion() == 1 - ? WorkspaceToolProfiles.lifecycleProtocol(requireSession(tenantId, childSessionId).toolProfile()) : 0, true, true); - } - - private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, - String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported) { - return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, 0, true); - } - - private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, - String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion) { - return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, actorId, supported, protocolVersion, true); - } - - private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, - String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion, - boolean publicLifecycle) { - return beginLifecycle(tenantId, sessionId, kind, actorDigest, idempotencyKey, requestDigest, - actorId, supported, protocolVersion, publicLifecycle, false); - } - - private OperationAdmission beginLifecycle(String tenantId, String sessionId, OperationKind kind, - String actorDigest, String idempotencyKey, String requestDigest, String actorId, boolean supported, int protocolVersion, - boolean publicLifecycle, boolean trustedChildClose) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - // A resumed admission pins the new operation to the failed one's - // protocol (below), so the tenant lock keys on the prospect of - // that pin too — otherwise the resumed lifecycle admission's - // journal-idleness read would race the retention sweep. - if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion) - || latestFailedLifecycleOperation(tenantId, sessionId, kind) - .filter(failed -> WorkspaceToolProfiles.lifecycleProtocol( - failed.lifecycleProtocolVersion())) - .isPresent()) { - ToolPublicationRetentionStore.lockTenant(jdbc, tenantId); - } - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() != null && !trustedChildClose) { - requireWorkspaceCreator(session, actorId); - } - Optional existing = replayOperation(tenantId, sessionId, - kind, actorDigest, idempotencyKey); - if (existing.isPresent()) { - if (!existing.get().requestDigest().equals(requestDigest)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new OperationAdmission(existing.get(), true); - } - if ("DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", - "The Session was not found."); - } - // A terminally FAILED operation leaves the Session in the pending - // status, which the workspace status gate and validateOperationStart - // refuse forever: the Session would be un-closable and un-deletable - // without database surgery (review round 9, R9-3; the workspace - // DELETE case surfaced in review round 13, R13-2). The permitted - // resume is recognized before those gates, and the failed - // operation's protocol is pinned up front — the service computes - // the caller's protocol from the Session's CURRENT status, and a - // Session parked in the pending status by the terminal fail no - // longer passes the lifecycle gate, so an unpinned resume would run - // as protocol 0 while the parked fence row is never rebound (review - // round 9 re-review, R9-3). The no-open-operation guard below still - // runs before the insert: a Session with a live operation cannot - // double-close. - var failedBefore = - kind != OperationKind.ACTION_RESPONSE - && kind != OperationKind.CWD_CHANGE - && pendingStatus(kind).equals(session.status()) - ? latestFailedLifecycleOperation(tenantId, sessionId, kind) - : Optional.empty(); - boolean resumed = failedBefore.isPresent(); - if (resumed) { - protocolVersion = failedBefore.get().lifecycleProtocolVersion(); - } - if (protocolVersion < 0 || protocolVersion > 2 - || protocolVersion != 0 && protocolVersion != WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile())) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); - } - if (publicLifecycle && WorkspaceToolProfiles.isShellOrMcp(session.toolProfile()) - && (WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) != 2 - || protocolVersion != 2 && (!List.of("CLOSED", "ARCHIVED").contains(session.status()) - || !hasCompletedWorkspaceClose(tenantId, sessionId)))) { - throw workspaceExecutionUnavailable(); - } - if (hasPendingRenameDelivery(tenantId, sessionId)) { - throw new ManagedAgentException(409, "session_operation_active", - "The Session has a title delivery awaiting completion."); - } - if (session.workspace() != null) { - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - } - if (kind == OperationKind.DELETE && List.of("CLOSED", "ARCHIVED").contains(session.status())) { - protocolVersion = 0; - } - if (session.workspace() != null && !resumed) { - if (kind == OperationKind.CLOSE || kind == OperationKind.DELETE && "ACTIVE".equals(session.status()) && WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { - if (!supported || protocolVersion == 1 && !workspaceFilesEnabled) { - throw workspaceExecutionUnavailable(); - } - if ("ACTIVE".equals(session.status())) { - validateOperationStart(session, kind); - } - } else { - if (kind == OperationKind.ARCHIVE) { - requireSessionStatus(session.status(), "CLOSED"); - } else if (!List.of("CLOSED", "ARCHIVED").contains(session.status())) { - throw sessionStateConflict(session.status()); - } - requireNoOpenOperation(tenantId, sessionId); - if (!hasCompletedWorkspaceClose(tenantId, sessionId)) { - throw workspaceExecutionUnavailable(); - } - } - } - requireNoOpenOperation(tenantId, sessionId); - String sessionStatusBefore = session.status(); - if (resumed) { - // The new operation carries the failed one's original - // pre-operation status, so its settle runs the steps the failed - // attempt was owed: an ACTIVE-before close asks the Harness to - // stop again, and a CLOSED-before delete still makes no Runtime - // calls. - sessionStatusBefore = failedBefore.get().sessionStatusBefore(); - } else { - validateOperationStart(session, kind); - } - if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { - WorkspaceLifecycleStore.requireIdleJournal(jdbc, objectMapper, tenantId, sessionId, true, protocolVersion == 2); - } - long now = lifecycleDatabaseTime(); - String operationId = publicId("op"); - Map data = Map.of("sessionId", sessionId, - "operationId", operationId); - // Java is the only authority an archive needs, so it completes here. - boolean archive = kind == OperationKind.ARCHIVE; - jdbc.update( - "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," - + " target_kind, target_key, target_id, phase, request_json," - + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," - + " actor_digest, idempotency_key, request_digest, state, admission_stage," - + " delivery_state, session_status_before, receipt_id, available_at," - + " gmt_create, gmt_modified, completed_at, lifecycle_protocol_version) VALUES" - + " (?, ?, ?, 'SESSION', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'JAVA_DURABLE'," - + " ?, ?, ?, ?, ?, ?, ?, ?)", - sessionOperationKey(tenantId, sessionId, kind, operationId), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - sessionId, - archive ? "COMPLETED" : "PENDING", - writeJson( - Map.of( - "schemaVersion", - 1, - "kind", - kind.name(), - "sessionId", - sessionId, - "sessionStatusBefore", - sessionStatusBefore, - "protocolVersion", - protocolVersion)), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - idempotencyKey), - tenantId, - sessionId, - operationId, - kind.name(), - actorDigest, - idempotencyKey, - requestDigest, - archive ? "COMPLETED" : "PENDING", - archive ? "CONFIRMED" : "PENDING", - sessionStatusBefore, - archive ? publicId("rcpt") : null, - now, - now, - now, - archive ? now : null, - protocolVersion); - if (resumed && WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { - // failOperation kept the fence row and released the dead - // operation's claim on it, so the re-admitted operation rebinds - // the row rather than inserting a duplicate. The release only - // reaches a fence still LIFECYCLE_ONLY: once effects were saved - // the fence moved to DRAINING with the dead operation still - // named, and a rebind limited to the LIFECYCLE_ONLY shape - // matches nothing — every later claim check reads the fence as - // another operation's and refuses with - // workspace_lifecycle_claim_fenced (review round 13, R13-1). - // Both parked shapes rebind here: the claim-released - // LIFECYCLE_ONLY row, and the DRAINING row that still names the - // failed operation whose validated effects the resume inherits. - jdbc.update("UPDATE ma_session_head SET" - + " operation_id = ? WHERE tenant_key = ? AND" - + " harness_key = ? AND tenant_id = ? AND" - + " harness_session_id = ? AND (phase =" - + " 'LIFECYCLE_ONLY' AND operation_id IS NULL OR" - + " phase = 'DRAINING' AND operation_id = ?)", - operationId, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, sessionId, failedBefore.get().operationId()); - // The effects the failed operation validated stay valid for its - // resume: the receipt moves to the new operation with them, and - // verification reads the hook-execution namespace from the - // receipt's own operation id. - String carriedEffects = jdbc.queryForObject( - "SELECT lifecycle_effects_receipt_json FROM" - + " ma_operation WHERE tenant_id = ?" - + " AND session_id = ? AND operation_id = ?", - String.class, tenantId, sessionId, - failedBefore.get().operationId()); - if (carriedEffects != null) { - jdbc.update("UPDATE ma_operation SET" - + " lifecycle_effects_receipt_json = ? WHERE" - + " tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", - carriedEffects, tenantId, sessionId, operationId); - } - } else if (WorkspaceToolProfiles.lifecycleProtocol(protocolVersion)) { - jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { - com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository.beginHarnessLifecycle(connection, tenantId, sessionId, operationId); - return null; - }); - if (protocolVersion == 2) { - // Admission fences ordinary renewals before the Harness receives its claim. - // Preserve a live writer once, without reviving an expired owner on retries. - jdbc.update("UPDATE ma_session_head SET writer_lease_until = GREATEST(writer_lease_until, (" - + ManagedSchemaTime.SQL_NOW + ") + ?), gmt_modified = " + ManagedSchemaTime.SQL_NOW - + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?" - + " AND head_kind = 'MANAGED_SESSION' AND phase = 'LIFECYCLE_ONLY' AND operation_id = ? AND state = 'ACTIVE'" - + " AND writer_lease_until > (" + ManagedSchemaTime.SQL_NOW + ")", - lifecycleHandoffMillis, JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId, operationId); - } - } - jdbc.update("UPDATE ma_session SET status = ?," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ?", - archive ? "ARCHIVED" : pendingStatus(kind), now, tenantId, - sessionId); - appendEvent(tenantId, sessionId, null, - archive ? completedEvent(kind) : requestedEvent(kind), data, - false, operationSource(operationId, - archive ? "completed" : "requested"), now); - return new OperationAdmission(findOperation(tenantId, sessionId, - operationId).orElseThrow(), false); + return lifecycleOperations.beginChildClose(tenantId, parentSessionId, childSessionId, childRunId, actorDigest, key, digest, supported); } @Override @@ -2930,358 +485,65 @@ public class ManagedAgentStore implements AgentStateStore { public OperationAdmission beginContextCompression(String tenantId, String sessionId, String actorId, String actorDigest, String key, String digest, long expectedCompactionRevision, boolean supported) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - ContextBinding binding = session.workspace(); - if (binding == null) { - throw new ManagedAgentException(400, "unsupported_feature", - "Context compression requires a Workspace Session."); - } - WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, binding.getWorkspaceId()); - if (!access.canRead()) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found."); - } - Optional replay = replayOperation(tenantId, sessionId, - OperationKind.CONTEXT_COMPRESSION, actorDigest, key); - if (replay.isPresent()) { - if (!digest.equals(replay.get().requestDigest())) { - throw new ManagedAgentException(409, "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new OperationAdmission(replay.get(), true); - } - if (!access.atLeast(WorkspaceAccess.OPERATOR)) { - throw new ManagedAgentException(403, "session_operation_forbidden", - "Only a Workspace operator may compress Session context."); - } - if (!supported || !workspaceFilesEnabled) { - throw new ManagedAgentException(409, "unsupported_feature", - "Hosted context compression is unavailable."); - } - requireSessionStatus(session.status(), "ACTIVE"); - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, binding.getStorageId()); - requireCwdChangeRegistryFacts(session); - if (contextCompressionState(tenantId, sessionId).compactionRevision() != expectedCompactionRevision) { - throw new ManagedAgentException(409, "compaction_revision_conflict", - "The Session compaction revision has changed."); - } - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - if (hasOpenOperation(tenantId, sessionId) || hasPendingRenameDelivery(tenantId, sessionId) - || hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session) - || !jdbc.queryForList("SELECT 1 FROM ma_session_record WHERE session_scope_key = ?" - + " AND record_kind = 'DOMAIN' AND domain IN ('hook_execution', 'history_maintenance')" - + " AND settled_at IS NULL LIMIT 1", scope).isEmpty() - || !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?" - + " AND status = 'RECOVERY_BLOCKED' LIMIT 1", tenantId, sessionId).isEmpty() - || !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ? AND session_id = ?" - + " AND head_kind = 'MANAGED_SESSION' AND (state <> 'ACTIVE' OR recovery_status <> 'READY')", - tenantId, sessionId).isEmpty()) { - throw new ManagedAgentException(409, "session_context_busy", - "The Session has an active Turn, approval, Hook, recovery or context operation."); - } - String operationId = publicId("op"); - long now = lifecycleDatabaseTime(); - Map request = new LinkedHashMap<>(); - request.put("operationId", operationId); - request.put("expectedCompactionRevision", expectedCompactionRevision); - var model = findModelSelections(tenantId, List.of(sessionId)).get(sessionId); - if (model != null && model.modelId() != null) { - request.put("modelSelection", Map.of("modelId", model.modelId(), "revision", model.revision())); - } - jdbc.update("INSERT INTO ma_operation (operation_key, scope_key, session_scope_key, target_kind," - + " target_key, target_id, phase, request_json, idempotency_digest, tenant_id, session_id," - + " operation_id, operation_kind, actor_digest, idempotency_key, request_digest, state," - + " admission_stage, delivery_state, session_status_before, actor_key, available_at," - + " gmt_create, gmt_modified) VALUES (?, ?, ?, 'SESSION', ?, ?, 'PENDING', ?, ?, ?, ?, ?," - + " 'CONTEXT_COMPRESSION', ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?, ?)", - sessionOperationKey(tenantId, sessionId, OperationKind.CONTEXT_COMPRESSION, operationId), - scope, scope, sessionOperationTarget(sessionId), sessionId, writeJson(request), - ManagedSchemaKeys.key(key), tenantId, sessionId, operationId, actorDigest, key, digest, - session.status(), com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId), now, now, now); - return new OperationAdmission(findOperation(tenantId, sessionId, operationId).orElseThrow(), false); + return executionOperations.beginContextCompression(tenantId, sessionId, actorId, actorDigest, key, digest, expectedCompactionRevision, supported); } @Override public ContextCompressionState contextCompressionState(String tenantId, String sessionId) { - ensureCompactionProjection(tenantId, sessionId); - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - List revisions = jdbc.queryForList("SELECT revision FROM ma_session_record WHERE" - + " session_scope_key = ? AND record_key = ? AND record_kind = 'DOMAIN'", Long.class, - scope, ManagedExtensionProjection.recordKey(sessionId, "hosted_context_compaction", "latest")); - List operations = jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE" - + " scope_key = ? AND operation_kind = 'CONTEXT_COMPRESSION' AND target_key = ?" - + " AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') ORDER BY gmt_create LIMIT 1", - String.class, scope, sessionOperationTarget(sessionId)); - return new ContextCompressionState(revisions.isEmpty() ? 0 : revisions.getFirst(), - operations.isEmpty() ? null : operations.getFirst()); + return executionOperations.contextCompressionState(tenantId, sessionId); } @Override public Map contextCompressionStates(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(); - arguments.add(tenantId); - sessionIds.forEach(id -> arguments.add(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, id))); - Map result = new HashMap<>(); - Set indexed = new HashSet<>(); - sessionIds.forEach(id -> result.put(id, new ContextCompressionState(0, null))); - jdbc.query("SELECT session_id, revision FROM ma_session_record WHERE tenant_id = ?" - + " AND session_scope_key IN (" + placeholders(sessionIds.size()) + ")" - + " AND record_kind = 'DOMAIN' AND domain = 'hosted_context_compaction'", - (RowCallbackHandler) row -> { - indexed.add(row.getString("session_id")); - result.put(row.getString("session_id"), new ContextCompressionState(row.getLong("revision"), null)); - }, arguments.toArray()); - List missing = sessionIds.stream().filter(id -> !indexed.contains(id)).toList(); - if (!missing.isEmpty()) { - List heads = new ArrayList<>(); - heads.add(tenantId); - heads.addAll(missing); - for (String id : jdbc.queryForList("SELECT session_id FROM ma_session_head WHERE tenant_id = ?" - + " AND session_id IN (" + placeholders(missing.size()) + ") AND head_kind = 'MANAGED_SESSION'", - String.class, heads.toArray())) { - try { - result.put(id, contextCompressionState(tenantId, id)); - } catch (ManagedAgentException unavailable) { - if (!"context_compression_state_unavailable".equals(unavailable.getCode())) { - throw unavailable; - } - result.put(id, new ContextCompressionState(0, null, false)); - } - } - } - jdbc.query("SELECT session_id, operation_id FROM ma_operation WHERE tenant_id = ?" - + " AND scope_key IN (" + placeholders(sessionIds.size()) + ")" - + " AND operation_kind = 'CONTEXT_COMPRESSION' AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED')", - (RowCallbackHandler) row -> { - String id = row.getString("session_id"); - result.put(id, new ContextCompressionState(result.get(id).compactionRevision(), - row.getString("operation_id"), result.get(id).available())); - }, arguments.toArray()); - return result; - } - - private void ensureCompactionProjection(String tenantId, String sessionId) { - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - String key = ManagedExtensionProjection.recordKey(sessionId, "hosted_context_compaction", "latest"); - if (!jdbc.queryForList("SELECT 1 FROM ma_session_record WHERE session_scope_key = ? AND record_key = ?", - scope, key).isEmpty()) { - return; - } - var transactions = new org.springframework.transaction.support.TransactionTemplate( - new org.springframework.jdbc.datasource.DataSourceTransactionManager(jdbc.getDataSource())); - transactions.executeWithoutResult(status -> backfillCompactionProjection(tenantId, sessionId, scope, key)); - } - - private void backfillCompactionProjection(String tenantId, String sessionId, String scope, String key) { - var heads = jdbc.queryForList("SELECT workspace_id, journal_revision, compacted_through_revision FROM" - + " ma_session_head WHERE tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", - tenantId, sessionId); - if (heads.isEmpty() || ((Number) heads.getFirst().get("journal_revision")).longValue() == 0) { - return; - } - var head = heads.getFirst(); - if (((Number) head.get("compacted_through_revision")).longValue() != 0) { - throw compressionStateUnavailable(); - } - long through = ((Number) head.get("journal_revision")).longValue(); - long scanned = 0; - com.fasterxml.jackson.databind.JsonNode latest = null; - while (scanned < through) { - var page = jdbc.queryForList("SELECT journal_revision, record_bytes, byte_length, record_digest FROM" - + " ma_session_journal_tx WHERE tenant_id = ? AND session_id = ? AND journal_revision > ?" - + " AND journal_revision <= ? ORDER BY journal_revision LIMIT 16", tenantId, sessionId, scanned, through); - if (page.isEmpty()) { - throw compressionStateUnavailable(); - } - for (var row : page) { - byte[] bytes = (byte[]) row.get("record_bytes"); - long revision = ((Number) row.get("journal_revision")).longValue(); - if (revision != scanned + 1 || bytes == null - || bytes.length != ((Number) row.get("byte_length")).longValue() - || !DigestUtils.sha256Hex(bytes).equals(row.get("record_digest"))) { - throw compressionStateUnavailable(); - } - for (String line : new String(bytes, StandardCharsets.UTF_8).split("\n")) { - var record = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(line); - if (record == null) { - throw compressionStateUnavailable(); - } - var event = record.path("managedSession"); - if ("context.compacted".equals(event.path("kind").asText())) { - latest = event; - } - } - scanned = revision; - } - } - String workspace = (String) head.get("workspace_id"); - long revision = latest == null ? 0 : latest.path("sequence").asLong(); - String resourceId; - if (latest == null) { - byte[] empty = "{}".getBytes(StandardCharsets.UTF_8); - String digest = DigestUtils.sha256Hex(empty); - resourceId = "compaction-state:" + digest; - jdbc.update("INSERT IGNORE INTO ma_resource (resource_key, session_scope_key, tenant_id, workspace_id," - + " session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes," - + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 2, ?, 'MYSQL_INLINE', ?," - + " 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", - ManagedSchemaKeys.key(scope, resourceId), scope, tenantId, workspace, sessionId, resourceId, - "managed-context-compaction-empty", digest, empty); - } else { - var ref = latest.path("payload").path("summaryRef"); - try { - ManagedExtensionRecords.durableRef(ref, "compaction.summaryRef"); - } catch (IllegalArgumentException error) { - throw compressionStateUnavailable(); - } - resourceId = ref.path("resourceId").asText(); - var resources = jdbc.queryForList("SELECT sha256, byte_length, kind FROM ma_resource WHERE" - + " session_scope_key = ? AND resource_id = ? AND state IN ('REFERENCED', 'PUBLISHED')", - scope, resourceId); - if (resources.size() != 1 || !ref.path("digest").asText().equals(resources.getFirst().get("sha256")) - || !ref.path("kind").asText().equals(resources.getFirst().get("kind")) - || ref.path("byteLength").asLong() != ((Number) resources.getFirst().get("byte_length")).longValue()) { - throw compressionStateUnavailable(); - } - } - long now = lifecycleDatabaseTime(); - int inserted = jdbc.update("INSERT IGNORE INTO ma_session_record (session_scope_key, record_key, tenant_id," - + " workspace_id, session_id, domain, record_id, revision, record_resource_id, gmt_create, settled_at," - + " first_sequence) VALUES (?, ?, ?, ?, ?, 'hosted_context_compaction', ?, ?, ?, ?, ?, ?)", - scope, key, tenantId, workspace, sessionId, "latest", revision, resourceId, now, now, revision); - if (inserted == 1) { - jdbc.update("INSERT IGNORE INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision," - + " resource_key, tenant_id, workspace_id, session_id, resource_id, gmt_create) VALUES" - + " (?, 'SESSION_RECORD', ?, ?, ?, ?, ?, ?, ?, ?)", scope, key, revision, - ManagedSchemaKeys.key(scope, resourceId), tenantId, workspace, sessionId, resourceId, now); - } - } - - private static ManagedAgentException compressionStateUnavailable() { - return new ManagedAgentException(409, "context_compression_state_unavailable", - "The retained journal cannot establish the Session compaction revision."); + return executionOperations.contextCompressionStates(tenantId, sessionIds); } @Override public Map contextCompressionRequest(String tenantId, String sessionId, String operationId) { - return jdbc.queryForList("SELECT request_json FROM ma_operation WHERE scope_key = ?" - + " AND operation_kind = 'CONTEXT_COMPRESSION' AND operation_id = ?", String.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), operationId).stream() - .findFirst().map(this::readMap).orElse(Map.of()); + return executionOperations.contextCompressionRequest(tenantId, sessionId, operationId); } @Override public List findContextCompressions(int limit) { - return jdbc.query("SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" - + " operation_kind = 'CONTEXT_COMPRESSION' AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED')" - + " AND available_at <= " + ManagedSchemaTime.SQL_NOW + " AND (claim_owner IS NULL OR claim_until < " - + ManagedSchemaTime.SQL_NOW + ") ORDER BY available_at LIMIT ?", operationTargetMapper, limit); + return executionOperations.findContextCompressions(limit); } @Override @Transactional public Optional claimContextCompression(String tenantId, String sessionId, String operationId, String owner, Duration duration) { - requireLegacyMutation(tenantId, sessionId); - int changed = jdbc.update("UPDATE ma_operation SET state = 'RUNNING', delivery_state = 'LEASED'," - + " claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?)," - + " claim_generation = claim_generation + 1, attempt_count = attempt_count + 1 WHERE" - + " scope_key = ? AND operation_kind = 'CONTEXT_COMPRESSION' AND operation_id = ?" - + " AND state IN ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') AND available_at <= " - + ManagedSchemaTime.SQL_NOW + " AND (claim_owner IS NULL OR claim_until < " + ManagedSchemaTime.SQL_NOW + ")", - owner, duration.toMillis(), com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), operationId); - return changed == 1 ? findOperation(tenantId, sessionId, operationId) : Optional.empty(); + return executionOperations.claimContextCompression(tenantId, sessionId, operationId, owner, duration); } @Override public void requireContextCompressionCommit(String tenantId, String sessionId, String operationId) { - OperationRecord operation = findOperation(tenantId, sessionId, operationId).orElse(null); - if (operation == null || operation.kind() != OperationKind.CONTEXT_COMPRESSION) { - return; - } - SessionRecord session = requireSession(tenantId, sessionId); - if (!List.of("PENDING", "RUNNING", "RECOVERY_BLOCKED").contains(operation.state()) - || session.workspace() == null || !"ACTIVE".equals(session.status()) - || operation.actorKey() == null || !initiatorKeepsOperate(session, operation)) { - throw new ManagedAgentException(409, "context_compression_authorization_revoked", - "The compression initiator no longer has execution authority."); - } - requireCwdChangeRegistryFacts(session); + executionOperations.requireContextCompressionCommit(tenantId, sessionId, operationId); } @Override @Transactional public boolean markContextCompressionSubmitted(OperationRecord operation, String owner) { - SessionRecord session = requireLegacySessionForUpdate(operation.tenantId(), operation.sessionId()); - if (!"ACTIVE".equals(session.status()) || session.workspace() == null - || !initiatorKeepsOperate(session, operation) - || !hasExecutionRegistryFacts(session.tenantId(), session.sessionId())) { - settleContextCompression(operation, owner, "failed", null, "workspace_unavailable"); - return false; - } - return jdbc.update("UPDATE ma_operation SET admission_stage = 'HARNESS_SUBMITTED' WHERE" - + compressionClaim(), compressionArguments(operation, owner)) == 1; + return executionOperations.markContextCompressionSubmitted(operation, owner); } @Override @Transactional public boolean settleContextCompression(OperationRecord operation, String owner, String outcome, Long revision, String failureCode) { - boolean failed = "failed".equals(outcome); - Map result = new LinkedHashMap<>(); - result.put("outcome", outcome); - if (revision != null) { - result.put("compactionRevision", revision); - } - List args = new ArrayList<>(List.of(failed ? "FAILED" : "COMPLETED", - failed ? "FAILED" : "COMPLETED", writeJson(result), publicId("rcpt"))); - args.add(failureCode); - args.addAll(List.of(compressionArguments(operation, owner))); - return jdbc.update("UPDATE ma_operation SET state = ?, phase = ?, result_json = ?, receipt_id = ?," - + " failure_code = ?, admission_stage = 'HARNESS_CONFIRMED', delivery_state = 'CONFIRMED'," - + " claim_owner = NULL, claim_until = NULL, completed_at = " + ManagedSchemaTime.SQL_NOW - + ", gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim(), args.toArray()) == 1; + return executionOperations.settleContextCompression(operation, owner, outcome, revision, failureCode); } @Override @Transactional public void retryContextCompressionPreparation(OperationRecord operation, String owner, long retryAt) { - List args = new ArrayList<>(); - args.add(retryAt); - args.addAll(List.of(compressionArguments(operation, owner))); - jdbc.update("UPDATE ma_operation SET state = 'PENDING', admission_stage = 'JAVA_DURABLE'," - + " delivery_state = 'BLOCKED', available_at = ?, claim_owner = NULL, claim_until = NULL," - + " gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim() - + " AND admission_stage = 'JAVA_DURABLE'", args.toArray()); + executionOperations.retryContextCompressionPreparation(operation, owner, retryAt); } @Override @Transactional public void awaitContextCompression(OperationRecord operation, String owner, boolean acceptanceKnown, String failureCode, long retryAt) { - List args = new ArrayList<>(); - args.add(acceptanceKnown && failureCode == null ? "RUNNING" : "RECOVERY_BLOCKED"); - args.add(acceptanceKnown ? "HARNESS_ACCEPTED" : "HARNESS_SUBMITTED"); - args.add(acceptanceKnown ? failureCode : "context_compression_acceptance_unknown"); - args.add(retryAt); - args.addAll(List.of(compressionArguments(operation, owner))); - jdbc.update("UPDATE ma_operation SET state = ?, admission_stage = ?, failure_code = ?," - + " delivery_state = 'BLOCKED', available_at = ?, claim_owner = NULL, claim_until = NULL," - + " gmt_modified = " + ManagedSchemaTime.SQL_NOW + " WHERE" + compressionClaim(), args.toArray()); - } - - private static String compressionClaim() { - return " operation_kind = 'CONTEXT_COMPRESSION' AND tenant_id = ? AND session_id = ?" - + " AND operation_id = ? AND state = 'RUNNING' AND claim_owner = ? AND claim_generation = ?" - + " AND claim_until > " + ManagedSchemaTime.SQL_NOW; - } - - private static Object[] compressionArguments(OperationRecord operation, String owner) { - return new Object[] {operation.tenantId(), operation.sessionId(), operation.operationId(), owner, - operation.claimGeneration()}; + executionOperations.awaitContextCompression(operation, owner, acceptanceKnown, failureCode, retryAt); } @Override @@ -3289,116 +551,7 @@ public class ManagedAgentStore implements AgentStateStore { public CwdChangeOutcome completeCwdChangeOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - OperationRecord operation = findSessionOperation(tenantId, sessionId, operationId, true) - .orElseThrow(() -> new IllegalStateException("Session operation is unavailable")); - if (operation.kind() != OperationKind.CWD_CHANGE) { - throw new IllegalStateException("Operation " + operationId - + " is not a cwd change"); - } - Boolean leaseLive = - jdbc.queryForObject( - "SELECT claim_until > " + ManagedSchemaTime.SQL_NOW + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ? FOR UPDATE", - Boolean.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - if (!Boolean.TRUE.equals(leaseLive) - || !"RUNNING".equals(operation.state()) - || !"LEASED".equals(operation.deliveryState()) - || !owner.equals(operation.leaseOwner()) - || operation.claimGeneration() != claimGeneration) { - return null; - } - long now = lifecycleDatabaseTime(); - ContextBinding binding = session.workspace(); - long expected = operation.expectedContextRevision() == null ? -1 - : operation.expectedContextRevision(); - String failure = null; - if (binding == null || !"ACTIVE".equals(session.status()) - || WorkspaceMigrationAdmission.owner(jdbc, tenantId, - binding.getStorageId()) != null) { - failure = "workspace_unavailable"; - } else if (binding.getContextRevision() != expected) { - failure = "context_revision_conflict"; - } else if (hasOpenOperation(tenantId, sessionId, operationId) - || hasActiveTurn(tenantId, sessionId) - || hasDecidableAction(session) - || hasRetainedRuntimeSession(tenantId, sessionId)) { - failure = "session_context_busy"; - } else if (!hasExecutionRegistryFacts(session.tenantId(), - session.sessionId()) || !initiatorKeepsOperate(session, - operation)) { - failure = "workspace_unavailable"; - } - if (failure == null) { - try { - SessionWorktreeStore.requireReady(worktreeFiles, jdbc, tenantId, sessionId); - SessionWorktreeStore.requireCwd(jdbc, tenantId, sessionId, operation.targetCwdRelative()); - } catch (ManagedAgentException error) { - failure = error.getCode(); - } - } - if (failure != null) { - jdbc.update( - "UPDATE ma_operation SET phase = 'FAILED', state =" - + " 'FAILED', delivery_state = 'CONFIRMED'," - + " failure_code = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " completed_at = ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", - failure, - now, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - return new CwdChangeOutcome(false, failure, null); - } - long result = expected + 1; - jdbc.update("UPDATE ma_session SET cwd_relative = ?," - + " context_revision = ?, gmt_modified = ?, version =" - + " version + 1 WHERE tenant_id = ? AND" - + " session_id = ?", - operation.targetCwdRelative(), result, now, tenantId, - sessionId); - jdbc.update( - "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED', delivery_state =" - + " 'CONFIRMED', receipt_id = ?, result_context_revision = ?, claim_owner =" - + " NULL, claim_until = NULL, gmt_modified = ?, completed_at = ? WHERE scope_key" - + " = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", - publicId("rcpt"), - result, - now, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - Map data = new LinkedHashMap<>(); - data.put("sessionId", sessionId); - data.put("operationId", operationId); - data.put("workspaceId", binding.getWorkspaceId()); - data.put("cwdRelative", operation.targetCwdRelative()); - data.put("contextRevision", result); - appendEvent(tenantId, sessionId, null, "session.context.changed", - Map.copyOf(data), false, operationSource(operationId, - "completed"), now); - return new CwdChangeOutcome(true, null, result); + return executionOperations.completeCwdChangeOperation(tenantId, sessionId, operationId, owner, claimGeneration); } @Override @@ -3406,27 +559,7 @@ public class ManagedAgentStore implements AgentStateStore { public boolean failCwdChangeOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, String failureCode) { - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - return jdbc.update( - "UPDATE ma_operation SET phase = 'FAILED', state =" - + " 'FAILED', delivery_state = 'CONFIRMED'," - + " failure_code = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " completed_at = ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - failureCode, - now, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - owner, - claimGeneration) - == 1; + return executionOperations.failCwdChangeOperation(tenantId, sessionId, operationId, owner, claimGeneration, failureCode); } @Override @@ -3434,369 +567,42 @@ public class ManagedAgentStore implements AgentStateStore { public OperationAdmission beginTaskCancelOperation(String tenantId, String sessionId, String taskId, String actorDigest, String idempotencyKey, String requestDigest) { - // The caller has already validated the key and checked current - // access (404, then 403 task_forbidden): those run first by - // contract, so a revoked caller never replays. Under the Session - // lock the retained key replays before any new-request check, so - // a lost 202 survives capability and state changes; only a new - // request rechecks the Session state, the task's cancel action - // and the one-open-operation rule, atomically with its insert. - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - Optional existing = replayOperation(tenantId, sessionId, - OperationKind.TASK_CANCEL, actorDigest, idempotencyKey); - if (existing.isPresent()) { - if (!existing.get().requestDigest().equals(requestDigest)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - return new OperationAdmission(existing.get(), true); - } - if ("DELETED".equals(session.status())) { - throw new ManagedAgentException(404, "session_not_found", - "The Session was not found."); - } - if (!"ACTIVE".equals(session.status())) { - throw new ManagedAgentException(409, "session_not_active", - "The Session is " + session.status().toLowerCase( - java.util.Locale.ROOT) - + " and accepts no task cancellation."); - } - // The task row is the projection its own commits update, so the - // locking read serializes this admission with the transition that - // would settle the task. - List task = jdbc.query("SELECT task_kind, task_state FROM" - + " ma_session_record WHERE" - + " session_scope_key = ? AND record_key = ?" - + " AND task_kind IS NOT NULL FOR UPDATE", - (result, row) -> new String[] {result.getString("task_kind"), - result.getString("task_state")}, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - taskId.substring("task_".length())); - if (task.isEmpty()) { - throw new ManagedAgentException(404, "task_not_found", - "The task was not found."); - } - if (!ManagedExtensionProjection.taskActions(task.getFirst()[0], - task.getFirst()[1]).contains("cancel")) { - throw new ManagedAgentException(409, - "task_action_unavailable", - "The task does not accept a cancellation now."); - } - // A bound Session under storage migration admits no new work, as - // every sibling bound admission refuses it: a cancel admitted - // behind the fence would wedge the migration's idle checks. - if (session.workspace() != null) { - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, - session.workspace().getStorageId()); - } - requireNoOpenOperation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - String operationId = publicId("op"); - jdbc.update( - "INSERT INTO ma_operation (operation_key, scope_key, session_scope_key," - + " target_kind, target_key, target_id, phase, request_json," - + " idempotency_digest, tenant_id, session_id, operation_id, operation_kind," - + " actor_digest, idempotency_key, request_digest, state, admission_stage," - + " delivery_state, session_status_before, task_id, available_at, gmt_create," - + " gmt_modified) VALUES (?, ?, ?, 'SESSION', ?, ?, 'PENDING', ?, ?, ?, ?, ?, ?," - + " ?, ?, ?, 'PENDING', 'JAVA_DURABLE', 'PENDING', ?, ?, ?, ?, ?)", - sessionOperationKey(tenantId, sessionId, OperationKind.TASK_CANCEL, operationId), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - sessionId, - writeJson( - Map.of( - "schemaVersion", - 1, - "kind", - "TASK_CANCEL", - "sessionId", - sessionId, - "taskId", - taskId)), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - idempotencyKey), - tenantId, - sessionId, - operationId, - OperationKind.TASK_CANCEL.name(), - actorDigest, - idempotencyKey, - requestDigest, - session.status(), - taskId, - now, - now, - now); - return new OperationAdmission(findOperation(tenantId, sessionId, - operationId).orElseThrow(), false); + return executionOperations.beginTaskCancelOperation(tenantId, sessionId, taskId, actorDigest, idempotencyKey, requestDigest); } @Override public List findDeliverableTaskCancels(int limit) { - long now = lifecycleDatabaseTime(); - List targets = - new ArrayList<>( - jdbc.query( - "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" - + " operation_kind = 'TASK_CANCEL' AND state IN ('PENDING'," - + " 'RUNNING') AND delivery_state = 'PENDING' AND available_at" - + " <= ? AND EXISTS (SELECT 1 FROM ma_session private_scope" - + " WHERE private_scope.tenant_id = ma_operation.tenant_id AND" - + " private_scope.session_id = ma_operation.session_id AND" - + " private_scope.csi_guard = FALSE) ORDER BY available_at" - + " LIMIT ?", - operationTargetMapper, - now, - limit)); - if (targets.size() < limit) { - targets.addAll(jdbc.query( - "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind = 'TASK_CANCEL' AND delivery_state = 'LEASED' AND claim_until < " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY claim_until LIMIT ?", - operationTargetMapper, limit - targets.size())); - } - return List.copyOf(targets); + return executionOperations.findDeliverableTaskCancels(limit); } @Override public List findParkedOperations(int limit) { - // Kind and state lead the due index, so the per-second scan reads - // only parked rows, never a kind's settled history. - return jdbc.query( - "SELECT tenant_id, session_id, operation_id, operation_kind FROM ma_operation" - + " WHERE operation_kind IN " + ACCEPTANCE_UNKNOWN_KINDS + " AND state =" - + " 'RECOVERY_BLOCKED' AND available_at <= ? AND delivery_state = 'BLOCKED'" - + " AND EXISTS (SELECT 1 FROM ma_session s WHERE s.tenant_id =" - + " ma_operation.tenant_id AND s.session_id = ma_operation.session_id AND" - + " s.csi_guard = FALSE) ORDER BY available_at LIMIT ?", - (result, row) -> new ParkedOperation(result.getString("tenant_id"), - result.getString("session_id"), result.getString("operation_id"), - result.getString("operation_kind")), - lifecycleDatabaseTime(), limit); + return executionOperations.findParkedOperations(limit); } @Override @Transactional public boolean settleParkedOperation(ParkedOperation parked, AcceptanceVerdict verdict, long retryAt) { - requireLegacyMutation(parked.tenantId(), parked.sessionId()); - long now = lifecycleDatabaseTime(); - List arguments = new ArrayList<>(); - String update = switch (verdict.acceptance()) { - case COMMITTED -> { - arguments.add(publicId("rcpt")); - arguments.add(now); - arguments.add(now); - yield "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED'," - + " admission_stage = 'HARNESS_CONFIRMED'," - + " delivery_state = 'CONFIRMED', receipt_id = ?," - + " failure_code = NULL, gmt_modified = ?, completed_at = ?"; - } - case NOT_COMMITTED -> { - // Terminal yet blocked, as the contract has it: available_at - // moves past every scan of the blocked range for good. - arguments.add(verdict.failureCode()); - arguments.add(Long.MAX_VALUE); - arguments.add(now); - arguments.add(now); - yield "UPDATE ma_operation SET phase = 'FAILED', state = 'FAILED'," - + " admission_stage = 'JAVA_DURABLE'," - + " delivery_state = 'BLOCKED', failure_code = ?," - + " available_at = ?, gmt_modified = ?, completed_at = ?"; - } - case UNKNOWN -> { - arguments.add(Math.addExact(now, - Math.max(0, retryAt - clock.millis()))); - arguments.add(now); - yield "UPDATE ma_operation SET available_at = ?, gmt_modified = ?," - + " attempt_count = attempt_count + 1"; - } - }; - arguments.addAll(List.of(parked.kind(), parked.operationId(), - parked.tenantId(), parked.sessionId())); - // Only a still-parked row: a reconciliation never rewrites an - // outcome already recorded, and no claim holds a parked row. - return jdbc.update(update + " WHERE operation_kind = ? AND operation_id = ?" - + " AND tenant_id = ? AND session_id = ? AND operation_kind IN " - + ACCEPTANCE_UNKNOWN_KINDS + " AND state = 'RECOVERY_BLOCKED' AND" - + " delivery_state = 'BLOCKED' AND claim_owner IS NULL", - arguments.toArray()) == 1; + return executionOperations.settleParkedOperation(parked, verdict, retryAt); } - /** - * The Harness admits a prompt by committing {@code submitInput} to the - * Session journal under the prompt's id, so the journal row keyed by that - * command is the admission's own evidence. Its absence proves nothing - * while a writer may still commit it; once the head refuses every writer - * (DELETING or DELETED) and compaction has not started, the journal is - * complete and the absence is proof. Both facts come from one statement, - * whose single read view holds under any isolation level: a commit that - * preceded the deletion is visible beside it, and no commit can land - * between the two reads, as it could between two statements under READ - * COMMITTED and turn an admitted input into a failed one. - */ @Override public AcceptanceVerdict turnInputAcceptance(String tenantId, String sessionId, String operationId) { - String turnId = jdbc.queryForList("SELECT target_id FROM ma_operation WHERE" - + " scope_key = ? AND operation_kind = 'SUBMIT_INPUT' AND operation_id = ?", - String.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - operationId).stream().findFirst().orElse(null); - TurnRecord turn = turnId == null ? null - : findTurn(tenantId, sessionId, turnId).orElse(null); - if (turn == null) { - return AcceptanceVerdict.unknown(); - } - if (turn.harnessEventEpoch() != null) { - return AcceptanceVerdict.committed(); - } - JournalEvidence evidence = journalEvidence(tenantId, sessionId, - "submitInput", turn.promptId()); - if (evidence.admittedDigest() != null) { - if (("sha256:" + evidence.admittedDigest()).equals(turn.payloadDigest())) { - return AcceptanceVerdict.committed(); - } - LOG.warn("Journal admission digest differs from its Turn tenant={}" - + " session={} turn={}", tenantId, sessionId, turnId); - return AcceptanceVerdict.unknown(); - } - return evidence.sealed() ? AcceptanceVerdict.notCommitted(turn.errorCode()) - : AcceptanceVerdict.unknown(); + return executionOperations.turnInputAcceptance(tenantId, sessionId, operationId); } - private record JournalEvidence(String admittedDigest, boolean sealed) { - } - - /** - * The Session journal's own record of one keyed command: the content - * digest it was admitted with, if any, and whether the journal is sealed - * complete (no writer can still commit, compaction has not started). - */ - private JournalEvidence journalEvidence(String tenantId, String sessionId, - String operation, String commandId) { - return jdbc.queryForObject("SELECT (SELECT content_digest" - + " FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" - + " AND command_key_hash = ?) AS admitted_digest, (SELECT COUNT(*) FROM" - + " ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" - + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'" - + " AND state IN ('DELETING', 'DELETED') AND" - + " COALESCE(compacted_through_revision, 0) = 0) AS sealed", - (result, row) -> new JournalEvidence(result.getString("admitted_digest"), - result.getLong("sealed") > 0), - tenantId, sessionId, - ManagedSessionStore.commandKeyHash(operation, commandId), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, - sessionId); - } - - /** - * The Hosted Harness commits a fenced title to the Session journal as - * {@code renameSession} under {@code hosted-title:}, with the - * title's SHA-256 as its content digest. That commit is the delivery's - * own evidence, read in one statement beside the head exactly as a Turn - * input's admission is. - */ @Override public Optional renameAcceptance(ParkedOperation parked) { - var rows = jdbc.queryForList("SELECT target_revision, request_json FROM ma_operation" - + " WHERE scope_key = ? AND operation_kind = 'RENAME' AND operation_id = ?" - + " AND tenant_id = ? AND session_id = ? AND state = 'RECOVERY_BLOCKED'", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(parked.tenantId(), parked.sessionId()), - parked.operationId(), parked.tenantId(), parked.sessionId()); - if (rows.isEmpty()) { - return Optional.empty(); - } - long revision = ((Number) rows.getFirst().get("target_revision")).longValue(); - String title = (String) readMap((String) rows.getFirst().get("request_json")).get("title"); - JournalEvidence evidence = journalEvidence(parked.tenantId(), parked.sessionId(), - "renameSession", "hosted-title:" + revision); - // A row without its title can still be proven undelivered, never - // matched (review of #14475). - if (evidence.admittedDigest() != null) { - if (title != null && DigestUtils.sha256Hex(title).equals(evidence.admittedDigest())) { - return Optional.of(AcceptanceVerdict.committed()); - } - LOG.warn("Journal title digest differs from its delivery tenant={}" - + " session={} revision={}", parked.tenantId(), parked.sessionId(), revision); - return Optional.of(AcceptanceVerdict.unknown()); - } - return Optional.of(evidence.sealed() - ? AcceptanceVerdict.notCommitted(SESSION_TITLE_UNDELIVERED) - : AcceptanceVerdict.unknown()); + return executionOperations.renameAcceptance(parked); } @Override @Transactional public boolean settleParkedRename(ParkedOperation parked, AcceptanceVerdict verdict, long retryAt) { - String tenantId = parked.tenantId(); - String sessionId = parked.sessionId(); - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - // Only a still-parked row: the claim's own late reply or a later - // title may have settled it since the evidence was read. - var rows = jdbc.queryForList("SELECT operation_key, idempotency_key, request_json" - + " FROM ma_operation WHERE scope_key = ? AND operation_kind = 'RENAME'" - + " AND operation_id = ? AND tenant_id = ? AND session_id = ? AND state =" - + " 'RECOVERY_BLOCKED' AND delivery_state = 'BLOCKED' FOR UPDATE", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), parked.operationId(), - tenantId, sessionId); - if (rows.isEmpty()) { - return false; - } - String operationKey = (String) rows.getFirst().get("operation_key"); - String key = (String) rows.getFirst().get("idempotency_key"); - String receipt = ManagedReceipts.commandKey(tenantId, "RENAME_SESSION", key); - long now = clock.millis(); - switch (verdict.acceptance()) { - case COMMITTED -> { - // The Harness holds the title, so the Session shows it in - // whatever status a lifecycle has since reached, unless a - // deletion took the Session away. A later title would have - // superseded this row, so it is still the newest. - String title = (String) readMap((String) rows.getFirst().get("request_json")) - .get("title"); - if (title != null && !List.of("DELETING", "DELETED").contains(session.status())) { - jdbc.update("UPDATE ma_session SET title = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND session_id = ?", - title, now, tenantId, sessionId); - appendEvent(tenantId, sessionId, null, - mutationEvent(SessionMutationKind.RENAME, "completed"), - Map.of("sessionId", sessionId, "metadata", Map.of("title", title)), - false, mutationSource("RENAME_SESSION", key, "completed"), now); - } - jdbc.update("UPDATE ma_request_receipt SET state = 'COMPLETED', gmt_modified = ?" - + " WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'", - now, receipt); - jdbc.update("UPDATE ma_operation SET state = 'COMPLETED', phase = 'COMPLETED'," - + " admission_stage = 'HARNESS_CONFIRMED', delivery_state = 'COMPLETED'," - + " failure_code = NULL, claim_owner = NULL, claim_until = NULL," - + " gmt_modified = ?, completed_at = ? WHERE operation_key = ?", - now, now, operationKey); - } - case NOT_COMMITTED -> { - // Terminal yet blocked, as every proven non-commit is: its - // available_at moves past every scan for good. - jdbc.update("UPDATE ma_request_receipt SET state = 'FAILED', gmt_modified = ?" - + " WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND'" - + " AND state = 'PENDING'", - now, receipt); - jdbc.update("UPDATE ma_operation SET state = 'FAILED', phase = 'FAILED'," - + " admission_stage = 'JAVA_DURABLE', delivery_state = 'BLOCKED'," - + " failure_code = ?, available_at = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?, completed_at = ?" - + " WHERE operation_key = ?", - verdict.failureCode(), Long.MAX_VALUE, now, now, operationKey); - } - case UNKNOWN -> jdbc.update("UPDATE ma_operation SET available_at = ?, gmt_modified = ?," - + " attempt_count = attempt_count + 1 WHERE operation_key = ?", - Math.addExact(lifecycleDatabaseTime(), Math.max(0, retryAt - now)), now, - operationKey); - } - return true; + return executionOperations.settleParkedRename(parked, verdict, retryAt); } @Override @@ -3804,303 +610,31 @@ public class ManagedAgentStore implements AgentStateStore { public boolean settleTaskCancel(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, TaskCancelOutcome outcome, long retryAt) { - if (owner == null) { - throw new IllegalArgumentException( - "A parked task cancel settles through settleParkedOperation"); - } - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - // A leased claim settles only while it is still this worker's. - String claim = " AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + ""; - List arguments = new ArrayList<>(); - String update = switch (outcome.status()) { - case "completed" -> { - arguments.add(publicId("rcpt")); - arguments.add(now); - arguments.add(now); - yield "UPDATE ma_operation SET phase = 'COMPLETED', state = 'COMPLETED'," - + " admission_stage = 'HARNESS_CONFIRMED'," - + " delivery_state = 'CONFIRMED', receipt_id = ?," - + " failure_code = NULL, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " completed_at = ?"; - } - case "failed" -> { - // A failed cancel is terminal yet keeps the contract's - // blocked delivery state: its available_at moves past - // every scan of the blocked index range for good. - arguments.add(outcome.failureCode()); - arguments.add(Long.MAX_VALUE); - arguments.add(now); - arguments.add(now); - yield "UPDATE ma_operation SET phase = 'FAILED', state = 'FAILED'," - + " admission_stage = 'JAVA_DURABLE'," - + " delivery_state = 'BLOCKED', failure_code = ?," - + " available_at = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " completed_at = ?"; - } - case "recovery_blocked" -> { - arguments.add(outcome.failureCode()); - arguments.add(Math.addExact(now, - Math.max(0, retryAt - clock.millis()))); - arguments.add(now); - yield "UPDATE ma_operation SET" - + " phase = 'RECOVERY_BLOCKED', state = 'RECOVERY_BLOCKED'," - + " admission_stage = 'JAVA_DURABLE'," - + " delivery_state = 'BLOCKED', failure_code = ?," - + " available_at = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " attempt_count = attempt_count + 1"; - } - default -> throw new IllegalArgumentException( - "Unknown task cancel outcome " + outcome.status()); - }; - arguments.addAll(List.of(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), sessionOperationTarget(sessionId), tenantId, sessionId, operationId, owner, claimGeneration)); - return jdbc.update( - update - + " WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ? AND operation_kind = 'TASK_CANCEL'" - + claim, - arguments.toArray()) - == 1; + return executionOperations.settleTaskCancel(tenantId, sessionId, operationId, owner, claimGeneration, outcome, retryAt); } - // The initiator, persisted at V56 admission: settlement fails when the - // operating actor no longer holds OPERATOR — revoking one in-flight - // initiator stops only their own admitted change, as the W2 guard - // intends. A pre-V56 (NULL key) row settles on the creator-keyed facts - // alone. The vocabulary filter keeps an out-of-enum stored role (only - // reachable by an out-of-band write past V53's CHECK) on the same - // fail-closed workspace_unavailable verdict as a revocation, rather - // than an IllegalArgumentException looping through the retry. - private boolean initiatorKeepsOperate(SessionRecord session, - OperationRecord operation) { - if (operation.actorKey() == null) { - return true; - } - List roles = jdbc.queryForList("SELECT role FROM" - + " ma_workspace_access WHERE tenant_id = ? AND" - + " workspace_id = ? AND actor_id = ? AND role IN" - + " ('READER', 'OPERATOR', 'OWNER')", - String.class, session.tenantId(), - session.workspace().getWorkspaceId(), operation.actorKey()); - return !roles.isEmpty() && WorkspaceAccess.valueOf(roles.getFirst()) - .atLeast(WorkspaceAccess.OPERATOR); - } - - private void requireCwdChangeRegistryFacts(SessionRecord session) { - SessionWorktreeStore.requireReady(worktreeFiles, jdbc, session.tenantId(), session.sessionId()); - if (!hasExecutionRegistryFacts(session.tenantId(), - session.sessionId())) { - throw workspaceExecutionUnavailable(); - } - } - - // The Registry still backs the binding exactly, its state is ACTIVE and - // the creation actor's grants survive — the passive-attachment subset - // WorkspaceExecutionStore.authorizePassiveAttachment gates execution on; - // the frozen profile and agent checks stay the next turn's - // acquire-time gate. Admission of every family that executes under the - // creator's authority (later Turns and the cwd change alike) re-checks - // the same facts so a widened caller cannot be certified for a run - // that can only fail. @Override public boolean hasExecutionRegistryFacts(String tenantId, String sessionId) { - return sessionsWithExecutionRegistryFacts(tenantId, - List.of(sessionId)).contains(sessionId); + return sessionQueries.hasExecutionRegistryFacts(tenantId, sessionId); } - /** The batch twin of {@link #hasExecutionRegistryFacts}. */ @Override public Set sessionsWithExecutionRegistryFacts(String tenantId, java.util.Collection sessionIds) { - if (sessionIds.isEmpty()) { - return Set.of(); - } - Map facts = new HashMap<>(); - Set pending = new HashSet<>(sessionIds); - while (!pending.isEmpty()) { - List arguments = new ArrayList<>(); - arguments.add(tenantId); - arguments.addAll(pending); - List rows = - jdbc.query( - "SELECT s.session_id, s.parent_session_id, s.workspace_id," - + " s.workspace_generation, s.workspace_storage_id, CASE WHEN" - + " r.workspace_id IS NOT NULL AND a.actor_id IS NOT NULL THEN TRUE" - + " ELSE FALSE END AS creator_can_execute FROM ma_session s LEFT" - + " JOIN ma_workspace r ON r.tenant_id = s.tenant_id AND" - + " r.workspace_id = s.workspace_id AND r.workspace_generation =" - + " s.workspace_generation AND r.storage_id =" - + " s.workspace_storage_id AND r.state = 'ACTIVE' LEFT JOIN" - + " ma_workspace_access a ON a.tenant_id = r.tenant_id AND" - + " a.workspace_id = r.workspace_id AND a.actor_id =" - + " s.creator_actor_key AND a.role IN ('OPERATOR', 'OWNER') WHERE" - + " s.tenant_id = ? AND s.session_id IN (" - + placeholders(pending.size()) - + ")", - (row, index) -> - new ExecutionFacts( - row.getString("session_id"), - row.getString("parent_session_id"), - row.getString("workspace_id"), - (Long) row.getObject("workspace_generation"), - row.getString("workspace_storage_id"), - row.getBoolean("creator_can_execute")), - arguments.toArray()); - Set queried = Set.copyOf(pending); - pending.clear(); - for (ExecutionFacts row : rows) { - facts.put(row.sessionId(), row); - } - for (ExecutionFacts row : rows) { - if (row.parentId() != null && !facts.containsKey(row.parentId()) - && !queried.contains(row.parentId())) { - pending.add(row.parentId()); - } - } - } - Set verified = new HashSet<>(); - for (String sessionId : sessionIds) { - ExecutionFacts initial = facts.get(sessionId); - if (initial == null || initial.workspaceId() == null || initial.storageId() == null - || initial.generation() == null) { - continue; - } - Set visited = new HashSet<>(); - ExecutionFacts current = initial; - while (current != null && visited.add(current.sessionId()) - && Objects.equals(initial.workspaceId(), current.workspaceId()) - && Objects.equals(initial.generation(), current.generation()) - && Objects.equals(initial.storageId(), current.storageId())) { - if (current.parentId() == null) { - if (current.creatorCanExecute()) { - verified.add(sessionId); - } - break; - } - current = facts.get(current.parentId()); - } - } - return Set.copyOf(verified); - } - - private record ExecutionFacts(String sessionId, String parentId, String workspaceId, - Long generation, String storageId, boolean creatorCanExecute) {} - - private boolean hasOpenOperation(String tenantId, String sessionId) { - return hasOpenOperation(tenantId, sessionId, null); - } - - // Hook owners can outlive their Turn with an immutable worker context. - // Only confirmed release permits changing that context's cwd/revision. - private boolean hasRetainedRuntimeSession(String tenantId, String sessionId) { - return !jdbc.queryForList("SELECT runtime_session_id FROM ma_runtime_session" - + " WHERE tenant_id = ? AND harness_session_id = ?" - + " AND session_state <> 'RELEASED' LIMIT 1", tenantId, sessionId).isEmpty(); - } - - // The bound later-Turn barrier: context-changing operations only — - // pending command rows, permission-action operations and task cancels - // (which stop one task and change no Session context) are excluded, - // matching the recovery scan's own population. - private boolean hasOpenExecutionOperation(String tenantId, - String sessionId) { - if (ManagedConfigStore.blocked(jdbc, tenantId, sessionId)) { - return true; - } - Integer operations = jdbc.queryForObject("SELECT COUNT(*) FROM" - + " ma_operation WHERE scope_key = ? AND" - + " target_key = ? AND operation_kind IN" - + " ('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE', 'CONTEXT_COMPRESSION') AND state IN" - + " ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED', 'UNKNOWN')", - Integer.class, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), sessionOperationTarget(sessionId)); - return operations != null && operations > 0; - } - - private boolean hasOpenOperation(String tenantId, String sessionId, - String excludingOperationId) { - if (ManagedConfigStore.blocked(jdbc, tenantId, sessionId)) { - return true; - } - Integer commands = - jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_request_receipt WHERE namespace =" - + " 'SESSION_COMMAND' AND tenant_key = ? AND tenant_id = ? AND" - + " session_id = ? AND state = 'PENDING'", - Integer.class, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - tenantId), - tenantId, - sessionId); - // A recovery_blocked task cancel is parked, not open (H4f): its - // acceptance is reconciled from the task's committed record and - // it must never hold the Session's lifecycle hostage meanwhile. - Integer operations = - jdbc.queryForObject( - "SELECT COUNT(*) FROM" - + " ma_operation WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND (state IN ('PENDING', 'RUNNING'," - + " 'UNKNOWN') OR state = 'RECOVERY_BLOCKED' AND operation_kind <>" - + " 'TASK_CANCEL') AND operation_id <> ?", - Integer.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys - .identity( - excludingOperationId == null ? "" : excludingOperationId)); - return (commands != null && commands > 0) - || (operations != null && operations > 0); + return sessionQueries.sessionsWithExecutionRegistryFacts(tenantId, sessionIds); } @Override public Optional findOperation(String tenantId, String sessionId, String operationId) { - return findSessionOperation(tenantId, sessionId, operationId, false) - .or(() -> findTurnCommandOperation(tenantId, sessionId, - operationId)); + return lifecycleOperations.findOperation(tenantId, sessionId, operationId); } @Override public List findDeliverableOperations(long now, int limit) { - now = lifecycleDatabaseTime(); - List targets = - new ArrayList<>( - jdbc.query( - // Every blocked lifecycle shape is - // re-driven. CLOSE, DELETE and CWD_CHANGE - // are the delivered kinds (an archive - // completes at admission): CLOSE and DELETE - // publish their writer-live and stale-boot - // waits, and a cwd change stays bounded by - // its own attempt budget in the coordinator - // (review round 2, R2-15). - "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE" - + " operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE'," - + " 'CWD_CHANGE') AND state IN ('PENDING', 'RUNNING'," - + " 'RECOVERY_BLOCKED') AND delivery_state IN ('PENDING'," - + " 'BLOCKED') AND available_at <= ? AND EXISTS (SELECT 1 FROM" - + " ma_session private_scope WHERE private_scope.tenant_id =" - + " ma_operation.tenant_id AND private_scope.session_id =" - + " ma_operation.session_id AND private_scope.csi_guard =" - + " FALSE) ORDER BY available_at LIMIT ?", - operationTargetMapper, - now, - limit)); - if (targets.size() < limit) { - targets.addAll( - jdbc.query( - "SELECT tenant_id, session_id, operation_id FROM ma_operation WHERE operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE', 'CWD_CHANGE') AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_until < " + ManagedSchemaTime.SQL_NOW + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_operation.tenant_id AND private_scope.session_id = ma_operation.session_id AND private_scope.csi_guard = FALSE) ORDER BY claim_until LIMIT ?", - operationTargetMapper, - limit - targets.size())); - } - return List.copyOf(targets); + return lifecycleOperations.findDeliverableOperations(now, limit); } @Override @@ -4108,34 +642,7 @@ public class ManagedAgentStore implements AgentStateStore { public Optional claimOperation(String tenantId, String sessionId, String operationId, String owner, Duration leaseDuration) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - int updated = - jdbc.update( - "UPDATE ma_operation SET phase = 'RUNNING', state = 'RUNNING', delivery_state = 'LEASED', claim_owner = ?, claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), claim_generation = claim_generation + 1, gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ? AND (state IN ('PENDING', 'RUNNING')" - + " OR state = 'RECOVERY_BLOCKED' AND operation_kind IN " - + LIFECYCLE_WAIT_KINDS - + ") AND ((delivery_state IN ('PENDING', 'BLOCKED')" - + " AND available_at <= ?) OR (delivery_state = 'LEASED' AND" - + " claim_until < " + ManagedSchemaTime.SQL_NOW + "))", - owner, - leaseDuration.toMillis(), - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - now); - if (updated == 1) { - syncLifecycleClaim(tenantId, sessionId, operationId); - } - return updated == 1 ? findOperation(tenantId, sessionId, operationId) - : Optional.empty(); + return lifecycleOperations.claimOperation(tenantId, sessionId, operationId, owner, leaseDuration); } @Override @@ -4143,99 +650,7 @@ public class ManagedAgentStore implements AgentStateStore { public boolean completeOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, boolean harnessConfirmed) { - requireLegacyMutation(tenantId, sessionId); - var target = findOperation(tenantId, sessionId, operationId).orElseThrow(); - if (WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion())) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - ManagedSessionStore.lockBounded(() -> ToolPublicationRetentionStore.lockTenant( - jdbc, tenantId, ManagedSessionStore.GUARD_ACQUIRE_TIMEOUT_SECONDS)); - } - if (target.kind() == OperationKind.DELETE && !WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion())) { - ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); - } - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (WorkspaceToolProfiles.lifecycleProtocol(target.lifecycleProtocolVersion()) && target.kind() == OperationKind.DELETE) { - ToolPublicationRetentionStore.lockDeletion(jdbc, tenantId, sessionId); - } - OperationRecord operation = findSessionOperation(tenantId, sessionId, operationId, true) - .orElseThrow(() -> new IllegalStateException("Session operation is unavailable")); - Boolean leaseLive = - jdbc.queryForObject( - "SELECT claim_until > " + ManagedSchemaTime.SQL_NOW + " FROM ma_operation WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ? FOR UPDATE", - Boolean.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - if (!Boolean.TRUE.equals(leaseLive) - || !"RUNNING".equals(operation.state()) - || !"LEASED".equals(operation.deliveryState()) - || !owner.equals(operation.leaseOwner()) - || operation.claimGeneration() != claimGeneration) { - return false; - } - if (!pendingStatus(operation.kind()).equals(session.status())) { - throw new IllegalStateException("Session " + sessionId + " is " - + session.status() + " during its " - + operation.kind() + " operation"); - } - if (WorkspaceToolProfiles.lifecycleProtocol(operation.lifecycleProtocolVersion())) { - if (lifecycle == null) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_close_identity_unverified"); - } - lifecycle.verifyCompletion(operation); - } - long now = lifecycleDatabaseTime(); - if (operation.kind() == OperationKind.DELETE) { - if (session.workspace() != null && !List.of("CLOSED", "ARCHIVED").contains(operation.sessionStatusBefore()) - && !WorkspaceToolProfiles.lifecycleProtocol(operation.lifecycleProtocolVersion())) { - throw new IllegalStateException("Workspace deletion requires a closed Session"); - } - ToolPublicationRetentionStore.retire(jdbc, tenantId, sessionId, operationId); - } - switch (operation.kind()) { - case CLOSE, ARCHIVE -> jdbc.update("UPDATE ma_session" - + " SET status = ?, harness_event_epoch = NULL," - + " harness_last_event_id = 0, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ?", - operation.kind() == OperationKind.CLOSE ? "CLOSED" - : "ARCHIVED", now, tenantId, sessionId); - case DELETE -> jdbc.update("UPDATE ma_session SET" - + " status = 'DELETED', harness_boot_id = NULL," - + " harness_event_epoch = NULL," - + " harness_last_event_id = 0, deleted_at = ?," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ?", - now, now, tenantId, sessionId); - } - jdbc.update( - "UPDATE ma_operation SET failure_code = NULL, phase = 'COMPLETED', state =" - + " 'COMPLETED', admission_stage = ?, delivery_state = 'CONFIRMED', receipt_id" - + " = ?, claim_owner = NULL, claim_until = NULL, gmt_modified = ?, completed_at =" - + " ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", - harnessConfirmed ? "HARNESS_CONFIRMED" : "JAVA_DURABLE", - publicId("rcpt"), - now, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - appendEvent(tenantId, sessionId, null, - completedEvent(operation.kind()), - Map.of("sessionId", sessionId, "operationId", operationId), - operation.kind() == OperationKind.DELETE, - operationSource(operationId, "completed"), now); - return true; + return lifecycleOperations.completeOperation(tenantId, sessionId, operationId, owner, claimGeneration, harnessConfirmed); } @Override @@ -4243,127 +658,21 @@ public class ManagedAgentStore implements AgentStateStore { public boolean failOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, String failureCode) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - long now = lifecycleDatabaseTime(); - // The same fencing as completeOperation, and deliberately no - // Session effect beyond the claim-mirror release below: the Session - // row keeps its pending status and no completion event is appended, - // because the settle never succeeded. The receipt - // the contract requires of every confirmed row certifies nothing — - // status failed and the failure code carry the outcome. - // delivery_state CONFIRMED keeps every recovery scan from re-driving - // the row. - int updated = jdbc.update("UPDATE ma_operation SET" - + " state = 'FAILED', phase = 'FAILED'," - + " delivery_state = 'CONFIRMED', failure_code = ?," - + " receipt_id = ?, claim_owner = NULL," - + " claim_until = NULL, gmt_modified = ?," - + " completed_at = ? WHERE scope_key = ? AND" - + " operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND" - + " session_id = ? AND operation_id = ? AND" - + " state = 'RUNNING' AND delivery_state = 'LEASED'" - + " AND claim_owner = ? AND claim_generation = ?" - + " AND claim_until > " + ManagedSchemaTime.SQL_NOW, - failureCode, publicId("rcpt"), now, now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), tenantId, sessionId, - operationId, owner, claimGeneration); - if (updated == 1) { - // The terminal write also releases the lifecycle claim mirror a - // v1 admission raised: nothing will drive this operation again, - // so the fence row must not stay bound to a dead operation's id. - // The phase stays LIFECYCLE_ONLY, though — only the completion - // path flips it to DRAINING, gated on its effects verification, - // and isHarnessDraining (drainHarnessSession's sole precondition) - // reads the phase alone: writing DRAINING here would authorize - // draining a Session whose worker stop was never verified (review - // round 6, R5-5). retryOperation and blockLifecycleOperation - // deliberately only NULL the lease — a rescheduled operation is - // still in flight and must stay admission-closed to everyone - // else. - jdbc.update("UPDATE ma_session_head SET" - + " operation_id = NULL, claim_lease_until = NULL" - + " WHERE tenant_key = ? AND harness_key = ? AND" - + " tenant_id = ? AND harness_session_id = ? AND" - + " operation_id = ? AND phase = 'LIFECYCLE_ONLY'", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, sessionId, operationId); - } - return updated == 1; - } - - private long lifecycleDatabaseTime() { - return jdbc.queryForObject( - "SELECT UNIX_TIMESTAMP(), EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(6))", - (row, index) -> - Math.addExact( - Math.multiplyExact(row.getLong(1), 1000), row.getLong(2) / 1000)); + return lifecycleOperations.failOperation(tenantId, sessionId, operationId, owner, claimGeneration, failureCode); } @Override @Transactional public boolean renewLifecycleOperation(String tenantId, String sessionId, String operationId, String owner, long generation, Duration duration) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - int updated = - jdbc.update( - "UPDATE ma_operation SET claim_until = (" + ManagedSchemaTime.SQL_NOW + " + ?), gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - duration.toMillis(), - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - owner, - generation); - if (updated == 1) { - syncLifecycleClaim(tenantId, sessionId, operationId); - } - return updated == 1; - } - - private void syncLifecycleClaim(String tenantId, String sessionId, String operationId) { - var rows = - jdbc.queryForList( - "SELECT lifecycle_protocol_version, claim_generation, FLOOR(claim_until) AS claim_lease_until FROM ma_operation WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId); - if (!rows.isEmpty() - && rows.getFirst().get("lifecycle_protocol_version") instanceof Number version - && WorkspaceToolProfiles.lifecycleProtocol(version.intValue())) { - var row = rows.getFirst(); - jdbc.update( - "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_generation = ?, claim_lease_until = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND (phase = 'LIFECYCLE_ONLY' OR ? = 2 AND phase = 'DRAINING')", - row.get("claim_generation"), - row.get("claim_lease_until"), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId, - operationId, version.intValue()); - } + return lifecycleOperations.renewLifecycleOperation(tenantId, sessionId, operationId, owner, generation, duration); } @Override @Transactional public void blockLifecycleOperation(String tenantId, String sessionId, String operationId, String owner, long generation, String failureCode, long availableAt) { - blockLifecycleOperation(tenantId, sessionId, operationId, owner, - generation, failureCode, availableAt, false); + lifecycleOperations.blockLifecycleOperation(tenantId, sessionId, operationId, owner, generation, failureCode, availableAt); } @Override @@ -4371,46 +680,7 @@ public class ManagedAgentStore implements AgentStateStore { public void blockLifecycleOperation(String tenantId, String sessionId, String operationId, String owner, long generation, String failureCode, long availableAt, boolean budgetExempt) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - // The exempt count increments from itself, never from - // attempt_count: a wait refunds no already-charged attempt, so - // attempt_count - budget_exempt_attempt stays the count of attempts - // that could have made progress regardless of interleaving order. - int updated = - jdbc.update( - "UPDATE ma_operation SET phase = 'RECOVERY_BLOCKED', state =" - + " 'RECOVERY_BLOCKED', delivery_state = 'BLOCKED', failure_code = ?," - + " available_at = ?, claim_owner = NULL, claim_until = NULL," - + " gmt_modified = ?," - + (budgetExempt - ? " budget_exempt_attempt = budget_exempt_attempt + 1," - : "") - + " attempt_count = attempt_count + 1 WHERE scope_key =" - + " ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - failureCode, - Math.addExact(now, Math.max(0, availableAt - clock.millis())), - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - owner, - generation); - if (updated == 1) { - jdbc.update( - "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId, - operationId, - generation); - } + lifecycleOperations.blockLifecycleOperation(tenantId, sessionId, operationId, owner, generation, failureCode, availableAt, budgetExempt); } @Override @@ -4418,8 +688,7 @@ public class ManagedAgentStore implements AgentStateStore { public void retryOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, long availableAt) { - retryOperation(tenantId, sessionId, operationId, owner, - claimGeneration, availableAt, false); + lifecycleOperations.retryOperation(tenantId, sessionId, operationId, owner, claimGeneration, availableAt); } @Override @@ -4427,362 +696,91 @@ public class ManagedAgentStore implements AgentStateStore { public void retryOperation(String tenantId, String sessionId, String operationId, String owner, long claimGeneration, long availableAt, boolean budgetExempt) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - requireLegacyMutation(tenantId, sessionId); - long delay = Math.max(0, availableAt - clock.millis()); - long now = lifecycleDatabaseTime(); - availableAt = Math.addExact(now, delay); - // The exempt count increments from itself, never from - // attempt_count: a wait refunds no already-charged attempt, so - // attempt_count - budget_exempt_attempt stays the count of attempts - // that could have made progress regardless of interleaving order. - int updated = - jdbc.update( - "UPDATE ma_operation SET delivery_state = 'PENDING', claim_owner = NULL," - + " claim_until = NULL," - + (budgetExempt - ? " budget_exempt_attempt = budget_exempt_attempt + 1," - : "") - + " attempt_count = attempt_count + 1, available_at" - + " = ?, gmt_modified = ? WHERE scope_key = ? AND operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND operation_id = ? AND state = 'RUNNING' AND delivery_state = 'LEASED' AND claim_owner = ? AND claim_generation = ? AND claim_until > " + ManagedSchemaTime.SQL_NOW + "", - availableAt, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - owner, - claimGeneration); - if (updated == 1) { - jdbc.update( - "UPDATE ma_session_head SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", claim_lease_until = NULL WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND operation_id = ? AND claim_generation = ?", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId, - operationId, - claimGeneration); - } + lifecycleOperations.retryOperation(tenantId, sessionId, operationId, owner, claimGeneration, availableAt, budgetExempt); } @Override @Transactional public void markOperationAnswered(String tenantId, String sessionId, String operationId, String owner, long claimGeneration) { - WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId); - requireLegacyMutation(tenantId, sessionId); - long now = lifecycleDatabaseTime(); - // Watermark only: the row stays leased and no reschedule runs here — - // the catch block's own retryOperation (or a throw losing it) decides - // what happens next. The point is that the answered fact is durable - // before any recovery read can throw it away (review round 14, - // R14-3). - jdbc.update( - "UPDATE ma_operation SET budget_exempt_attempt =" - + " budget_exempt_attempt + 1, gmt_modified = ? WHERE scope_key = ? AND" - + " operation_kind IN " - + SESSION_OPERATION_KINDS - + " AND target_key = ? AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ? AND state = 'RUNNING' AND delivery_state =" - + " 'LEASED' AND claim_owner = ? AND claim_generation = ? AND" - + " claim_until > " + ManagedSchemaTime.SQL_NOW, - now, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), - tenantId, - sessionId, - operationId, - owner, - claimGeneration); + lifecycleOperations.markOperationAnswered(tenantId, sessionId, operationId, owner, claimGeneration); } public Admission replayCommand(String tenantId, String operation, String idempotencyKey, String requestDigest) { - ManagedReceipts.Receipt command = ManagedReceipts.command(jdbc, - tenantId, operation, idempotencyKey, false); - if (command == null) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key is already in use."); - } - if (!command.requestDigest().equals(requestDigest)) { - throw new ManagedAgentException(409, - "idempotency_conflict", - "The idempotency key was reused with different content."); - } - String operationId = command.resultOperationKey() == null ? null - : jdbc.queryForObject("SELECT operation_id FROM ma_operation" - + " WHERE operation_key = ?", String.class, - command.resultOperationKey()); - return new Admission(command.sessionId(), command.turnId(), true, - false, operationId); + return sessionCommands.replayCommand(tenantId, operation, idempotencyKey, requestDigest); } public Optional findCommand(String tenantId, String operation, String idempotencyKey) { - return findCommand(tenantId, operation, idempotencyKey, false); - } - - private Optional findCommand(String tenantId, - String operation, String idempotencyKey, boolean forUpdate) { - ManagedReceipts.Receipt row = ManagedReceipts.command(jdbc, tenantId, operation, idempotencyKey, forUpdate); - if (row == null) { - return Optional.empty(); - } - return Optional.of( - new CommandRecord( - tenantId, - operation, - idempotencyKey, - row.requestDigest(), row.sessionId(), row.turnId(), row.state(), - row.sessionStatusBefore(), row.actorKey(), row.createdAt(), row.updatedAt())); + return sessionCommands.findCommand(tenantId, operation, idempotencyKey); } public Optional findSession(String tenantId, String sessionId) { - List rows = - jdbc.query( - SESSION_WITH_STREAM + " WHERE ma_session.session_kind = 'PUBLIC' AND" - + " ma_session.tenant_id = ? AND ma_session.session_id = ?", - sessionMapper, - tenantId, - sessionId); - return rows.stream().findFirst(); + return sessionQueries.findSession(tenantId, sessionId); } public Optional findSessionById(String sessionId) { - List rows = jdbc.query( - SESSION_WITH_STREAM + " WHERE ma_session.session_kind = 'PUBLIC' AND" - + " ma_session.session_id = ?", - sessionMapper, sessionId); - return rows.stream().findFirst(); + return sessionQueries.findSessionById(sessionId); } public SessionPage listSessions(String tenantId, String actorId, Long beforeCreatedAt, String beforeSessionId, int limit) { - return listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, "all"); + return sessionQueries.listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit); } public SessionPage listSessions(String tenantId, String actorId, Long beforeCreatedAt, String beforeSessionId, int limit, String archiveState) { - return listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, archiveState, ""); + return sessionQueries.listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, archiveState); } public SessionPage listSessions(String tenantId, String actorId, Long beforeCreatedAt, String beforeSessionId, int limit, String archiveState, String titleQuery) { - List arguments = new ArrayList<>(); - arguments.add(tenantId); - arguments.add(actorId == null ? null - : com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId)); - String archiveClause = switch (archiveState) { - case "archived" -> " AND ma_session.status = 'ARCHIVED'"; - case "unarchived" -> " AND ma_session.status <> 'ARCHIVED'"; - default -> ""; - }; - String titleClause = ""; - if (!StringUtils.isEmpty(titleQuery)) { - titleClause = " AND LOCATE(LOWER(?), LOWER(COALESCE(ma_session.title, ''))) > 0"; - arguments.add(titleQuery); - } - // Keyset on the immutable gmt_create: gmt_modified moves on every - // event append, so a row can slide backwards into already-visited - // pages and silently vanish from a paged listing. - String cursorClause = ""; - if (beforeCreatedAt != null && beforeSessionId != null) { - cursorClause = " AND (ma_session.gmt_create < ? OR (ma_session.gmt_create = ?" - + " AND ma_session.session_id < ?))"; - arguments.add(beforeCreatedAt); - arguments.add(beforeCreatedAt); - arguments.add(beforeSessionId); - } - arguments.add(limit + 1); - Map worktrees = new LinkedHashMap<>(); - List rows = - jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") + ", COALESCE(es.last_sequence, 0) AS list_last_sequence," - + " COALESCE(es.replay_floor_sequence, 0) AS list_floor_sequence," - + " w.tenant_id AS worktree_tenant_id, w.session_id AS worktree_session_id," - + " w.workspace_id AS worktree_workspace_id, w.worktree_id AS worktree_worktree_id," - + " w.state AS worktree_state, w.plan_json AS worktree_plan_json," - + " w.identity_json AS worktree_identity_json, w.operation_id AS worktree_operation_id," - + " w.revision AS worktree_revision, w.failure_code AS worktree_failure_code," - + " w.owner_actor_key AS worktree_owner_actor_key, o.claim_owner AS worktree_claim_owner," - + " o.claim_generation AS worktree_claim_generation, o.claim_until AS worktree_claim_until," - + " o.phase AS worktree_phase, o.attempt_count AS worktree_attempt_count FROM" - + " ma_session LEFT JOIN ma_event_stream es ON es.tenant_id =" - + " ma_session.tenant_id AND es.session_id = ma_session.session_id AND" - + " es.stream_kind = 'SESSION' AND es.stream_id = X''" - + " LEFT JOIN ma_session_worktree w ON w.tenant_id = ma_session.tenant_id" - + " AND w.session_id = ma_session.session_id LEFT JOIN ma_operation o" - + " ON o.operation_kind = 'SESSION_WORKTREE' AND o.tenant_id = w.tenant_id" - + " AND o.operation_id = w.operation_id WHERE" - + " ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id = ? AND" - + " ma_session.status <> 'DELETED' AND (ma_session.workspace_id IS NULL OR EXISTS (SELECT 1" - + " FROM ma_workspace_access wa WHERE wa.tenant_id =" - + " ma_session.tenant_id AND wa.workspace_id = ma_session.workspace_id" - + " AND wa.actor_id = ? AND wa.role IN ('READER', 'OPERATOR'," - + " 'OWNER')))" - + archiveClause + titleClause + cursorClause - + " ORDER BY ma_session.gmt_create DESC, ma_session.session_id DESC" - + " LIMIT ?", - (result, index) -> { - SessionRecord session = sessionListMapper.mapRow(result, index); - if (result.getString("worktree_worktree_id") != null) { - worktrees.put(session.sessionId(), SessionWorktreeStore.readRow(result, "worktree_")); - } - return session; - }, - arguments.toArray()); - boolean hasMore = rows.size() > limit; - if (hasMore) { - rows = new ArrayList<>(rows.subList(0, limit)); - } - var selectedWorktrees = rows.stream().map(row -> worktrees.get(row.sessionId())) - .filter(java.util.Objects::nonNull).toList(); - return new SessionPage(List.copyOf(rows), hasMore, SessionWorktreeStore.descriptors(worktreeFiles, jdbc, selectedWorktrees)); + return sessionQueries.listSessions(tenantId, actorId, beforeCreatedAt, beforeSessionId, limit, archiveState, titleQuery); } public Map findLatestLifecycleOperations(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(); - arguments.add(tenantId); - arguments.addAll(sessionIds); - Map result = new HashMap<>(); - jdbc.query("SELECT * FROM (SELECT o.session_id, o.operation_id, o.operation_kind, o.state, o.failure_code," - + " ROW_NUMBER() OVER (PARTITION BY o.session_id ORDER BY CASE WHEN o.state IN" - + " ('PENDING', 'RUNNING', 'RECOVERY_BLOCKED') THEN 0 ELSE 1 END," - + " o.gmt_modified DESC, o.gmt_create DESC, o.operation_id DESC) AS lifecycle_rank FROM ma_operation o" - + " JOIN ma_session s ON s.tenant_id = o.tenant_id AND s.session_id = o.session_id" - + " WHERE o.tenant_id = ? AND o.session_id IN (" + placeholders(sessionIds.size()) + ")" - + " AND ((s.status = 'CLOSING' AND o.operation_kind = 'CLOSE')" - + " OR (s.status = 'DELETING' AND o.operation_kind = 'DELETE')" - + " OR (s.status = 'ARCHIVING' AND o.operation_kind = 'ARCHIVE'))) lifecycle WHERE lifecycle_rank = 1", - (RowCallbackHandler) row -> result.put(row.getString("session_id"), - new WebShellLifecycleOperation( - row.getString("operation_id"), row.getString("operation_kind").toLowerCase(Locale.ROOT), - row.getString("state").toLowerCase(Locale.ROOT), row.getString("failure_code"))), - arguments.toArray()); - return result; + return sessionQueries.findLatestLifecycleOperations(tenantId, sessionIds); } public Optional findTurn(String tenantId, String sessionId, String turnId) { - List rows = jdbc.query( - "SELECT " + ManagedSchemaProjection.TURN.columns() + " FROM ma_turn WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ?", - turnMapper, tenantId, sessionId, turnId); - return rows.stream().findFirst(); + return sessionQueries.findTurn(tenantId, sessionId, turnId); } - /** The active Turn of each given Session, in one round trip. */ public Map findActiveTurns(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(sessionIds.size() + 1); - arguments.add(tenantId); - arguments.addAll(sessionIds); - List rows = jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS - + " FROM ma_turn" - + " WHERE tenant_id = ? AND session_id IN (" - + placeholders(sessionIds.size()) + ") AND status IN" - + " ('ACCEPTED', 'RUNNING', 'CANCELLING') ORDER BY" - + " gmt_create DESC", - turnSummaryMapper, arguments.toArray()); - Map result = new HashMap<>( - sessionIds.size() * 2); - for (TurnSummary row : rows) { - result.putIfAbsent(row.sessionId(), row); - } - return result; + return sessionQueries.findActiveTurns(tenantId, sessionIds); } - /** The latest Turn of each given Session, in one round trip. */ public Map findLatestTurns(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(sessionIds.size() + 3); - arguments.add(tenantId); - arguments.addAll(sessionIds); - arguments.add(tenantId); - arguments.add(tenantId); - List rows = - jdbc.query( - "SELECT " - + QUALIFIED_TURN_SUMMARY_COLUMNS - + " FROM ma_turn turn_record JOIN (SELECT event.session_id," - + " event.turn_id FROM ma_public_event event JOIN (SELECT" - + " accepted.session_id, MAX(accepted.sequence_id) AS max_sequence FROM" - + " ma_public_event accepted WHERE accepted.stream_kind = 'SESSION' AND accepted.stream_id =" - + " X'' AND accepted.tenant_id = ? AND accepted.session_id IN (" - + placeholders(sessionIds.size()) - + ") AND accepted.event_type = 'turn.accepted' GROUP BY" - + " accepted.session_id) latest ON latest.session_id =" - + " event.session_id AND latest.max_sequence =" - + " event.sequence_id WHERE event.tenant_id = ?)" - + " latest_turn ON latest_turn.session_id =" - + " turn_record.session_id AND latest_turn.turn_id =" - + " turn_record.turn_id WHERE turn_record.tenant_id =" - + " ?", - turnSummaryMapper, - arguments.toArray()); - Map result = new HashMap<>( - sessionIds.size() * 2); - for (TurnSummary row : rows) { - result.put(row.sessionId(), row); - } - return result; + return sessionQueries.findLatestTurns(tenantId, sessionIds); } @Override public Map findModelContexts( String tenantId, List sessionIds) { - return ManagedExtensionRecordStore.findModelContexts(jdbc, tenantId, sessionIds); + return sessionQueries.findModelContexts(tenantId, sessionIds); } @Override - public List modelOptions() { return modelCatalog; } + public List modelOptions() { + return sessionQueries.modelOptions(); + } @Override public Map findModelSelections(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List args = new ArrayList<>(); - args.add(tenantId); - args.addAll(sessionIds); - Map result = new HashMap<>(); - jdbc.query("SELECT session_id, model_id, model_revision FROM ma_session WHERE tenant_id = ?" - + " AND session_id IN (" + placeholders(sessionIds.size()) - + ") AND session_kind = 'PUBLIC' AND workspace_id IS NOT NULL AND model_id IS NOT NULL", - row -> { - result.put(row.getString("session_id"), new WebShellModelSelection( - row.getString("model_id"), row.getLong("model_revision"))); - }, args.toArray()); - return result; + return sessionQueries.findModelSelections(tenantId, sessionIds); } @Override public WebShellModelSelection findTurnModelSelection(String tenantId, String sessionId, String promptId) { - var rows = jdbc.query("SELECT model_id, model_revision FROM ma_turn WHERE tenant_id = ?" - + " AND session_id = ? AND prompt_id = ?", (row, index) -> row.getString("model_id") == null - ? null : new WebShellModelSelection(row.getString("model_id"), - row.getLong("model_revision")), tenantId, sessionId, promptId); - if (rows.size() != 1) { - throw new ManagedAgentException(409, "turn_not_found", "The model snapshot Turn was not found"); - } - return rows.getFirst(); + return sessionQueries.findTurnModelSelection(tenantId, sessionId, promptId); } @Override @@ -4790,135 +788,24 @@ public class ManagedAgentStore implements AgentStateStore { public WebShellModelChangeResult changeSessionModel(String tenantId, String actorId, String sessionId, String key, String digest, String modelId, long expectedRevision, boolean supported) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null) { - throw new ManagedAgentException(400, "unsupported_feature", - "Model selection requires a Workspace Session"); - } - WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); - if (!access.canRead()) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found"); - } - String scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys - .key("SET_MODEL", sessionId, actorId); - var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_COMMAND; - var scopeKind = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.ACTOR; - ManagedReceipts.Receipt replay = ManagedReceipts.find(jdbc, namespace, tenantId, - scopeKind, scope, key, false); - if (replay != null) { - if (!digest.equals(replay.requestDigest()) - || !sessionId.equals(replay.sessionId()) - || !"SET_MODEL".equals(replay.operation()) - || !java.util.Arrays.equals(actorId.getBytes(StandardCharsets.UTF_8), - replay.actorKey())) { - throw new ManagedAgentException(409, "idempotency_conflict", - "The idempotency key was reused with different content"); - } - Map result = readMap(replay.resultJson()); - return new WebShellModelChangeResult(sessionId, new WebShellModelSelection( - (String) result.get("modelId"), ((Number) result.get("revision")).longValue()), true); - } - if (!access.atLeast(WorkspaceAccess.OPERATOR)) { - throw new ManagedAgentException(403, "session_operation_forbidden", - "Only a Workspace operator may change the model"); - } - if (!supported || modelCatalog.isEmpty() || !workspaceFilesEnabled) { - throw new ManagedAgentException(409, "unsupported_feature", "Model selection is unavailable"); - } - if (modelCatalog.stream().noneMatch(option -> option.modelId().equals(modelId))) { - throw new ManagedAgentException(400, "unsupported_model", "The model is not published by this host"); - } - requirePolicyChange(session, false); - long current = jdbc.queryForObject("SELECT model_revision FROM ma_session WHERE tenant_id = ?" - + " AND session_id = ?", Long.class, tenantId, sessionId); - if (current != expectedRevision || current >= 9_007_199_254_740_991L) { - throw new ManagedAgentException(409, "model_selection_conflict", "The model revision changed"); - } - long revision = current + 1; - long now = clock.millis(); - jdbc.update("UPDATE ma_session SET model_id = ?, model_revision = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND model_revision = ?", - modelId, revision, now, tenantId, sessionId, current); - ManagedReceipts.insert(jdbc, namespace, tenantId, scopeKind, scope, key, digest, - "SET_MODEL", actorId.getBytes(StandardCharsets.UTF_8), sessionId, null, - "COMPLETED", session.status(), true, now); - jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?", - writeJson(Map.of("modelId", modelId, "revision", revision)), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.receiptKey( - namespace, tenantId, scopeKind, scope, key)); - String sessionScope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - String observation = ManagedExtensionProjection.recordKey(sessionId, "hosted_context_usage", "latest"); - jdbc.update("DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" - + " 'SESSION_RECORD' AND owner_key = ?", sessionScope, observation); - jdbc.update("DELETE FROM ma_session_record WHERE session_scope_key = ? AND record_key = ?", - sessionScope, observation); - appendEvent(tenantId, sessionId, null, "session.model.changed", - Map.of("modelId", modelId, "revision", revision), false, null, now); - return new WebShellModelChangeResult(sessionId, new WebShellModelSelection(modelId, revision), false); + return sessionPolicies.changeSessionModel(tenantId, actorId, sessionId, key, digest, modelId, expectedRevision, supported); } @Override public Map findApprovalSelections(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List args = new ArrayList<>(); - args.add(tenantId); - args.addAll(sessionIds); - Map result = new HashMap<>(); - jdbc.query("SELECT session_id, COALESCE(selected_approval_mode, approval_mode) AS mode," - + " approval_revision, plan_enabled, permission_rules_json FROM ma_session WHERE tenant_id=? AND session_id IN (" - + placeholders(sessionIds.size()) + ") AND session_kind='PUBLIC'", - row -> { result.put(row.getString("session_id"), new WebShellApprovalSelection( - row.getString("mode"), row.getLong("approval_revision"), row.getBoolean("plan_enabled"), - readPermissionRules(row.getString("permission_rules_json")))); }, args.toArray()); - return result; + return sessionQueries.findApprovalSelections(tenantId, sessionIds); } @Override public Set findApprovalOwners(String tenantId, String actorId, List sessionIds) { - if (sessionIds.isEmpty() || actorId == null || actorId.isEmpty()) { - return Set.of(); - } - List args = new ArrayList<>(); - args.add(tenantId); - args.add(com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId)); - args.addAll(sessionIds); - return new HashSet<>(jdbc.queryForList("SELECT session_id FROM ma_session WHERE tenant_id=?" - + " AND COALESCE(owner_actor_key, creator_actor_key)=? AND session_id IN (" - + placeholders(sessionIds.size()) + ")", String.class, args.toArray())); + return sessionQueries.findApprovalOwners(tenantId, actorId, sessionIds); } @Override public WebShellApprovalSelection findTurnApprovalSelection(String tenantId, String sessionId, String promptId) { - var rows = jdbc.query("SELECT t.approval_mode, t.approval_revision, t.plan_enabled, t.permission_rules_json," - + " s.approval_revision AS selected_revision, s.agent_id, s.agent_definition_digest," - + " s.workspace_id, s.tool_profile FROM ma_turn t JOIN ma_session s" - + " ON s.tenant_id=t.tenant_id AND s.session_id=t.session_id" - + " WHERE t.tenant_id=? AND t.session_id=? AND t.prompt_id=?", (row, index) -> { - if (("qwen-code".equals(row.getString("agent_id")) - ? row.getString("agent_definition_digest") != null - : row.getString("agent_definition_digest") == null) || row.getString("workspace_id") == null - || !(WorkspaceToolProfiles.FILES.equals(row.getString("tool_profile")) - || WorkspaceToolProfiles.FILES_V3.equals(row.getString("tool_profile")) - || WorkspaceToolProfiles.isPublicShell(row.getString("tool_profile")))) { - return null; - } - String mode = row.getString("approval_mode"); - if (mode == null && row.getLong("selected_revision") != 0) { - throw new ManagedAgentException(409, "approval_snapshot_missing", "The Turn has no approval snapshot"); - } - // Legacy prompt replays must preserve the original absence of a selection. - return mode == null ? null : new WebShellApprovalSelection(mode, row.getLong("approval_revision"), - row.getBoolean("plan_enabled"), readPermissionRules(row.getString("permission_rules_json"))); - }, tenantId, sessionId, promptId); - if (rows.size() != 1) { - throw new ManagedAgentException(409, "turn_not_found", "The Turn was not found"); - } - return rows.getFirst(); + return sessionQueries.findTurnApprovalSelection(tenantId, sessionId, promptId); } @Override @@ -4926,8 +813,7 @@ public class ManagedAgentStore implements AgentStateStore { public WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, String sessionId, String key, String digest, String mode, long expectedRevision, boolean supported, boolean agentDefinitionSupported) { - return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, - supported, agentDefinitionSupported, null, false); + return sessionPolicies.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, supported, agentDefinitionSupported); } @Override @@ -4935,8 +821,7 @@ public class ManagedAgentStore implements AgentStateStore { public WebShellApprovalChangeResult changeSessionApproval(String tenantId, String actorId, String sessionId, String key, String digest, String mode, long expectedRevision, boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported) { - return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, - supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, false); + return sessionPolicies.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, supported, agentDefinitionSupported, requestedPlanMode, planModeSupported); } @Override @@ -4945,8 +830,7 @@ public class ManagedAgentStore implements AgentStateStore { String sessionId, String key, String digest, String mode, long expectedRevision, boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported, boolean liveSupported) { - return changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, - supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, liveSupported, null, false, false); + return sessionPolicies.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, liveSupported); } @Override @@ -4955,277 +839,39 @@ public class ManagedAgentStore implements AgentStateStore { String sessionId, String key, String digest, String mode, long expectedRevision, boolean supported, boolean agentDefinitionSupported, Boolean requestedPlanMode, boolean planModeSupported, boolean liveSupported, Map> requestedRules, boolean rulesSupported, boolean autoSupported) { - WorkspaceMigrationAdmission.lockTenant(jdbc, tenantId); - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - if (session.workspace() == null) { - throw new ManagedAgentException(400, "unsupported_feature", - "Approval selection requires a Workspace Session"); - } - WorkspaceAccess access = workspaces.accessOf(tenantId, actorId, session.workspace().getWorkspaceId()); - if (!access.canRead()) { - throw new ManagedAgentException(404, "session_not_found", "The Session was not found"); - } - if (!workspaces.isSessionOwner(tenantId, actorId, sessionId) - || !access.atLeast(WorkspaceAccess.OPERATOR)) { - throw new ManagedAgentException(403, "session_operation_forbidden", - "Only the Session owner with OPERATOR access may change approval policy"); - } - String scope = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys - .key("SET_APPROVAL", sessionId, actorId); - var namespace = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptNamespace.SESSION_COMMAND; - var scopeKind = com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaContract.ReceiptScope.ACTOR; - ManagedReceipts.Receipt replay = ManagedReceipts.find(jdbc, namespace, tenantId, - scopeKind, scope, key, false); - if (replay != null) { - if (!digest.equals(replay.requestDigest()) - || !sessionId.equals(replay.sessionId()) - || !"SET_APPROVAL".equals(replay.operation()) - || !java.util.Arrays.equals(actorId.getBytes(StandardCharsets.UTF_8), - replay.actorKey())) { - throw new ManagedAgentException(409, "idempotency_conflict", - "The idempotency key was reused with different content"); - } - Map result = readMap(replay.resultJson()); - return new WebShellApprovalChangeResult(sessionId, (String) result.get("mode"), - ((Number) result.get("revision")).longValue(), true, Boolean.TRUE.equals(result.get("planMode")), - result.get("permissionRules") instanceof Map rules ? harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(rules)) : null); - } - if (!supported || session.agentDefinitionDigest() != null && !agentDefinitionSupported - || !workspaceFilesEnabled || !WorkspaceToolProfiles.supportsApprovalSelection(session) - || WorkspaceToolProfiles.isPublicShell(session.toolProfile()) && !workspaceShellEnabled) { - throw new ManagedAgentException(409, "unsupported_feature", "Approval selection is unavailable"); - } - if (!WorkspaceToolProfiles.supportsApprovalMode(mode) || "auto".equals(mode) && !autoSupported) { - throw new ManagedAgentException(400, "unsupported_approval_mode", "The approval mode is unavailable"); - } - var currentPolicy = jdbc.queryForMap("SELECT approval_revision, permission_rules_json FROM ma_session WHERE tenant_id=? AND session_id=?", - tenantId, sessionId); - Map> rules = requestedRules == null ? readPermissionRules((String) currentPolicy.get("permission_rules_json")) - : harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(requestedRules)); - if ((requestedRules != null || rules != null) && !rulesSupported) { - throw new ManagedAgentException(409, "unsupported_feature", "Permission rules are unavailable"); - } - boolean planMode = requestedPlanMode == null - ? Boolean.TRUE.equals(jdbc.queryForObject("SELECT plan_enabled FROM ma_session WHERE tenant_id=? AND session_id=?", - Boolean.class, tenantId, sessionId)) : requestedPlanMode; - if (planMode && (!planModeSupported || !supportsPlanMode(session))) { - throw new ManagedAgentException(409, "unsupported_feature", "Plan mode is unavailable"); - } - if (requestedRules != null && requestedPlanMode == null) { - if (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session)) { - throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval"); - } - if (hasOpenOperation(tenantId, sessionId)) { - throw new ManagedAgentException(409, "session_context_busy", "The Session requires settlement"); - } - } - requirePolicyChange(session, requestedPlanMode == null && (liveSupported || requestedRules != null)); - long current = ((Number) currentPolicy.get("approval_revision")).longValue(); - if (current != expectedRevision || current >= 9_007_199_254_740_991L - || planMode && current >= 9_007_199_254_740_990L) { - throw new ManagedAgentException(409, "approval_selection_conflict", "The approval revision changed"); - } - long revision = current + 1; - long now = clock.millis(); - jdbc.update("UPDATE ma_session SET selected_approval_mode = ?, approval_revision = ?, plan_enabled = ?, permission_rules_json = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND session_id = ? AND approval_revision = ?", - mode, revision, planMode, rules == null ? null : writeJson(rules), now, tenantId, sessionId, current); - ManagedReceipts.insert(jdbc, namespace, tenantId, scopeKind, scope, key, digest, - "SET_APPROVAL", actorId.getBytes(StandardCharsets.UTF_8), sessionId, null, - "COMPLETED", session.status(), true, now); - Map result = new LinkedHashMap<>(Map.of("mode", mode, "revision", revision, "planMode", planMode)); - if (rules != null) { - result.put("permissionRules", rules); - } - jdbc.update("UPDATE ma_request_receipt SET result_json = ? WHERE receipt_key = ?", - writeJson(result), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.receiptKey( - namespace, tenantId, scopeKind, scope, key)); - appendEvent(tenantId, sessionId, null, "session.approval.changed", - result, false, null, now); - return new WebShellApprovalChangeResult(sessionId, mode, revision, false, planMode, rules); + return sessionPolicies.changeSessionApproval(tenantId, actorId, sessionId, key, digest, mode, expectedRevision, supported, agentDefinitionSupported, requestedPlanMode, planModeSupported, liveSupported, requestedRules, rulesSupported, autoSupported); } - private Map> readPermissionRules(String json) { - return json == null ? null : harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules(readMap(json))); - } - - private void requirePublicChildImmutable(SessionRecord session) { - if (session.publicChildAgentsVersion() == 1 && findChildLineage(session.tenantId(), session.sessionId()) != null) { - throw new ManagedAgentException(409, "unsupported_feature", "Public child Sessions cannot change their delegated execution"); - } - } - - private void requirePolicyChange(SessionRecord session, boolean live) { - requirePublicChildImmutable(session); - String tenantId = session.tenantId(); - String sessionId = session.sessionId(); - WorkspaceMigrationAdmission.requireOpen(jdbc, tenantId, session.workspace().getStorageId()); - requireSessionStatus(session.status(), "ACTIVE"); - requireCwdChangeRegistryFacts(session); - if (!live && (hasActiveTurn(tenantId, sessionId) || hasDecidableAction(session))) { - throw new ManagedAgentException(409, "turn_active", "The Session has an active Turn or approval"); - } - if ((live ? hasOpenExecutionOperation(tenantId, sessionId) : hasOpenOperation(tenantId, sessionId)) - || hasPendingRenameDelivery(tenantId, sessionId) - || !live && hasRetainedRuntimeSession(tenantId, sessionId) - || !jdbc.queryForList("SELECT 1 FROM ma_turn WHERE tenant_id = ? AND session_id = ?" - + " AND (status IN ('RECOVERY_BLOCKED', 'CANCELLING') OR status IN ('ACCEPTED', 'RUNNING')" - + " AND error_code IN (?, ?, ?)) LIMIT 1", tenantId, sessionId, - AgentStateStore.RUNTIME_RECOVERY_BLOCKED, AgentStateStore.WORKFLOW_RECOVERY_BLOCKED, - AgentStateStore.HARNESS_RECOVERY_BLOCKED).isEmpty() - || !jdbc.queryForList("SELECT 1 FROM ma_session_head WHERE tenant_id = ?" - + " AND session_id = ? AND head_kind = 'MANAGED_SESSION' AND" - + " (state <> 'ACTIVE' OR recovery_status <> 'READY')", tenantId, sessionId).isEmpty()) { - throw new ManagedAgentException(409, "session_context_busy", "The Session requires settlement"); - } - } - - // Reads leave the Turn's input out; the public view never shows it. @Override public TurnPage listTurns(String tenantId, String sessionId, Long beforeCreatedAt, String beforeTurnId, int limit) { - List arguments = new ArrayList<>(List.of(tenantId, - sessionId)); - String before = ""; - if (beforeCreatedAt != null) { - before = " AND (gmt_create < ? OR (gmt_create = ? AND" - + " turn_id < ?))"; - arguments.add(beforeCreatedAt); - arguments.add(beforeCreatedAt); - arguments.add(beforeTurnId); - } - arguments.add(limit + 1); - List rows = jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS - + " FROM ma_turn WHERE tenant_id = ? AND" - + " session_id = ?" + before + " ORDER BY gmt_create" - + " DESC, turn_id DESC LIMIT ?", - turnSummaryMapper, arguments.toArray()); - boolean hasMore = rows.size() > limit; - return new TurnPage(hasMore ? List.copyOf(rows.subList(0, limit)) - : rows, hasMore); + return sessionQueries.listTurns(tenantId, sessionId, beforeCreatedAt, beforeTurnId, limit); } @Override public Optional findTurnSummary(String tenantId, String sessionId, String turnId) { - return jdbc.query("SELECT " + TURN_SUMMARY_COLUMNS + " FROM" - + " ma_turn WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ?", - turnSummaryMapper, tenantId, sessionId, turnId).stream() - // The binary collation ignores trailing spaces, so the - // database also matches an ID that adds some. - .filter(turn -> turn.turnId().equals(turnId)) - .findFirst(); + return sessionQueries.findTurnSummary(tenantId, sessionId, turnId); } public List findEvents(String tenantId, String sessionId, long afterSequence, int limit) { - requireSession(tenantId, sessionId); - return jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id = X''" - + " AND tenant_id = ? AND session_id = ? AND sequence_id > ? ORDER BY" - + " sequence_id ASC LIMIT ?", - eventMapper, - tenantId, - sessionId, - afterSequence, - limit); + return eventJournal.findEvents(tenantId, sessionId, afterSequence, limit); } - /** - * The latest environment event of each Session's latest Turn, in one round trip; the Turns are - * already loaded, so the turn-row join of the single-session read is implied. - */ public Map findLatestEnvironmentEvents( String tenantId, Map latestTurns) { - if (latestTurns.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(latestTurns.size() * 2 + 2); - arguments.add(tenantId); - StringBuilder pairs = new StringBuilder(); - for (TurnSummary turn : latestTurns.values()) { - if (pairs.length() > 0) { - pairs.append(","); - } - pairs.append(" (?, ?)"); - // Bound as identity bytes: H2 types row-comparison parameters - // as VARCHAR, and a command it recompiles after binding (any - // new session or SET in between) refuses VARBINARY rows against - // VARCHAR ones. Bytes compare the same on MySQL and MariaDB. - arguments.add(ManagedSchemaKeys.identity(turn.sessionId())); - arguments.add(ManagedSchemaKeys.identity(turn.turnId())); - } - arguments.add(tenantId); - // One row per session: the latest environment event of its latest - // Turn, selected in SQL instead of shipping every match per pair. - List rows = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns("event") + " FROM ma_public_event event JOIN (SELECT environment.session_id," - + " MAX(environment.sequence_id) AS max_sequence FROM ma_public_event environment WHERE" - + " environment.stream_kind = 'SESSION' AND environment.stream_id = X'' AND environment.tenant_id = ? AND" - + " environment.event_type IN ('environment.provisioning', 'environment.ready'," - + " 'environment.failed') AND (environment.session_id, environment.turn_id) IN (" - + pairs - + ") GROUP BY environment.session_id) latest ON latest.session_id =" - + " event.session_id AND latest.max_sequence = event.sequence_id" - + " WHERE event.stream_kind = 'SESSION' AND event.stream_id = X''" - + " AND event.tenant_id = ?", - eventMapper, - arguments.toArray()); - Map result = new HashMap<>(latestTurns.size() * 2); - for (EventRecord row : rows) { - result.put(row.sessionId(), row); - } - return result; + return eventJournal.findLatestEnvironmentEvents(tenantId, latestTurns); } public List findControlEvents(String tenantId, String sessionId, long throughSequence) { - requireSession(tenantId, sessionId); - return jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id = X''" - + " AND tenant_id = ? AND session_id = ? AND sequence_id <= ? AND event_type" - + " NOT IN ('turn.accepted', 'item.output_text.delta', 'item.reasoning.delta'," - + " 'item.tool_call.updated', 'item.tool_result.updated') ORDER BY sequence_id" - + " ASC", - eventMapper, - tenantId, - sessionId, - throughSequence); + return eventJournal.findControlEvents(tenantId, sessionId, throughSequence); } public EventPage findTranscriptEvents(String tenantId, String sessionId, Long beforeSequence, int limit) { - requireSession(tenantId, sessionId); - List rows = - beforeSequence == null - ? jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? ORDER" - + " BY sequence_id DESC LIMIT ?", - eventMapper, - tenantId, - sessionId, - limit + 1) - : jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" - + " sequence_id < ? ORDER BY sequence_id DESC LIMIT ?", - eventMapper, - tenantId, - sessionId, - beforeSequence, - limit + 1); - boolean hasMore = rows.size() > limit; - if (hasMore) { - rows = new ArrayList<>(rows.subList(0, limit)); - } else { - rows = new ArrayList<>(rows); - } - java.util.Collections.reverse(rows); - return new EventPage(List.copyOf(rows), hasMore); + return eventJournal.findTranscriptEvents(tenantId, sessionId, beforeSequence, limit); } @Override @@ -5233,7 +879,7 @@ public class ManagedAgentStore implements AgentStateStore { isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public StoreModels.MessagePage findMessagePage(String tenantId, String sessionId, String cursor, int limit, boolean backward) { - return findMessagePage(tenantId, sessionId, cursor, limit, backward, 0); + return messageProjection.findMessagePage(tenantId, sessionId, cursor, limit, backward); } @Override @@ -5241,456 +887,69 @@ public class ManagedAgentStore implements AgentStateStore { isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public StoreModels.MessagePage findMessagePage(String tenantId, String sessionId, String cursor, int limit, boolean backward, long afterSequence) { - requireSession(tenantId, sessionId); - ManagedLegacySessionGuard.requireLegacyMutation(Boolean.TRUE.equals(jdbc.queryForObject( - "SELECT csi_guard FROM ma_session WHERE tenant_id = ? AND session_id = ?", - Boolean.class, tenantId, sessionId))); - if (limit < 1 || limit > 100) { - throw new ManagedAgentException(400, "invalid_limit", - "Limit must be between 1 and 100."); - } - if (afterSequence < 0) { - throw new ManagedAgentException(400, "invalid_event_cursor", - "Item sequence must be non-negative."); - } - var progress = jdbc.queryForMap("SELECT covered_sequence, projection_version, last_sequence, replay_floor_sequence FROM ma_event_stream" - + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", - tenantId, sessionId); - long covered = ((Number) progress.get("covered_sequence")).longValue(); - int projection = ((Number) progress.get("projection_version")).intValue(); - if (covered < ((Number) progress.get("replay_floor_sequence")).longValue() - || (!backward && org.apache.commons.lang3.StringUtils.isBlank(cursor) - && covered < ((Number) progress.get("last_sequence")).longValue())) { - throw new ManagedAgentException(503, "message_projection_pending", - "Retry after pending messages have been projected."); - } - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - MessagePosition position = new MessagePosition(1, scope, backward, projection, covered, - backward ? covered + 1 : -1, ""); - if (org.apache.commons.lang3.StringUtils.isNotBlank(cursor)) { - position = messagePosition(cursor, scope, backward, covered, projection); - } - String comparison = backward ? "<" : ">"; - String order = backward ? " DESC" : " ASC"; - boolean numericBoundary = afterSequence > 0 && org.apache.commons.lang3.StringUtils.isBlank(cursor); - List pageArguments = new ArrayList<>(List.of(scope, tenantId, sessionId, position.through())); - if (numericBoundary) { - pageArguments.add(afterSequence); - } - pageArguments.addAll(List.of(position.sequence(), position.sequence(), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity(position.item()), limit + 1)); - List candidates = jdbc.query( - "SELECT m.item_id, m.first_sequence, OCTET_LENGTH(m.attributes_json) +" - + " OCTET_LENGTH(m.parts_json) + COALESCE(OCTET_LENGTH(r.inline_bytes), 0) AS byte_length" - + " FROM ma_message m LEFT JOIN ma_resource r ON r.resource_key = m.body_resource_key" - + " AND r.session_scope_key = ? AND r.tenant_id = m.tenant_id AND r.session_id = m.session_id" - + " WHERE m.tenant_id = ? AND m.session_id = ? AND m.first_sequence <= ?" - + (numericBoundary ? " AND m.first_sequence > ?" : "") - + " AND (m.first_sequence " + comparison + " ? OR (m.first_sequence = ? AND m.item_id " - + comparison + " ?)) ORDER BY m.first_sequence" + order + ", m.item_id" + order + " LIMIT ?", - (row, index) -> new MessageSize(row.getString("item_id"), row.getLong("first_sequence"), - row.getLong("byte_length")), - pageArguments.toArray()); - long bytes = 0; - List selected = new ArrayList<>(); - for (MessageSize candidate : candidates) { - if (selected.size() == limit || bytes + candidate.bytes() > MESSAGE_PAGE_BYTES) { - if (selected.isEmpty()) { - throw messagePageTooLarge(); - } - break; - } - if (candidate.bytes() < 0) { - throw new IllegalStateException("Message byte length is invalid."); - } - bytes += candidate.bytes(); - selected.add(candidate); - } - if (selected.isEmpty()) { - return new StoreModels.MessagePage(List.of(), List.of(), false, null, covered, position.through()); - } - List arguments = new ArrayList<>(List.of(tenantId, sessionId)); - selected.forEach(item -> arguments.add( - ManagedSchemaKeys.identity(item.id()))); - // Bodies are fetched only after the metadata-only lookahead has selected a bounded page. - List rows = withParts(jdbc.query( - "SELECT " + ManagedSchemaProjection.MESSAGE.columns() + " FROM ma_message WHERE tenant_id = ?" - + " AND session_id = ? AND item_id IN (" + placeholders(selected.size()) - + ") ORDER BY first_sequence ASC, item_id ASC", itemMapper, arguments.toArray())); - if (rows.size() != selected.size() || rows.stream().anyMatch(item -> item.lastSequence() > covered)) { - throw new IllegalStateException("Message projection differs from its progress."); - } - List events = backward ? messageTurnEvents(tenantId, sessionId, rows, covered, bytes) : List.of(); - boolean more = selected.size() < candidates.size(); - MessageSize last = selected.getLast(); - String next = more ? messageCursor(new MessagePosition(1, scope, backward, projection, - position.through(), last.sequence(), last.id())) : null; - return new StoreModels.MessagePage(rows, events, more, next, covered, position.through()); + return messageProjection.findMessagePage(tenantId, sessionId, cursor, limit, backward, afterSequence); } - private List messageTurnEvents(String tenantId, String sessionId, - List items, long covered, long itemBytes) { - List turns = items.stream().map(ItemRecord::turnId).distinct().toList(); - List arguments = new ArrayList<>(List.of(tenantId, sessionId, covered)); - turns.forEach(turn -> arguments.add( - ManagedSchemaKeys.identity(turn))); - arguments.add(tenantId); - arguments.add(sessionId); - var sizes = jdbc.query("SELECT e.sequence_id, OCTET_LENGTH(e.data_json) AS byte_length FROM ma_public_event e" - + " JOIN (SELECT turn_id, event_type, MAX(sequence_id) AS seq FROM ma_public_event" - + " WHERE tenant_id = ? AND session_id = ? AND sequence_id <= ?" - + " AND stream_kind = 'SESSION' AND stream_id = X'' AND turn_id IN (" - + placeholders(turns.size()) + ") AND event_type IN ('turn.started', 'turn.completed'," - + " 'turn.failed', 'turn.cancelled', 'turn.cancel.requested', 'environment.provisioning'," - + " 'environment.ready', 'environment.failed') GROUP BY turn_id, event_type) latest" - + " ON e.turn_id = latest.turn_id AND e.event_type = latest.event_type AND e.sequence_id = latest.seq" - + " WHERE e.tenant_id = ? AND e.session_id = ? AND e.stream_kind = 'SESSION' AND e.stream_id = X''", - (row, index) -> new EventSize(row.getLong("sequence_id"), row.getLong("byte_length")), arguments.toArray()); - if (sizes.stream().mapToLong(EventSize::bytes).sum() + itemBytes > MESSAGE_PAGE_BYTES) { - throw messagePageTooLarge(); - } - if (sizes.isEmpty()) { - return List.of(); - } - List eventArguments = new ArrayList<>(List.of(tenantId, sessionId)); - sizes.forEach(event -> eventArguments.add(event.sequence())); - return jdbc.query("SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event" - + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''" - + " AND sequence_id IN (" + placeholders(sizes.size()) + ") ORDER BY sequence_id ASC", - eventMapper, eventArguments.toArray()); - } - - private MessagePosition messagePosition(String cursor, String scope, boolean backward, - long covered, int projection) { - try { - if (cursor.length() > 4096) { - throw new IllegalArgumentException(); - } - MessagePosition position = objectMapper.readValue( - java.util.Base64.getUrlDecoder().decode(cursor), MessagePosition.class); - if (position.version() != 1 || !scope.equals(position.scope()) || position.backward() != backward - || position.projection() < 1 || position.through() < 0 || position.sequence() < 0 - || position.sequence() > position.through() || position.item() == null - || position.item().isEmpty() || position.item().length() > 512) { - throw new IllegalArgumentException(); - } - if (position.projection() != projection) { - throw new ManagedAgentException(409, "message_projection_changed", - "Reload message history after its projection changed."); - } - if (position.through() > covered) { - throw new IllegalArgumentException(); - } - return position; - } catch (java.io.IOException | IllegalArgumentException invalid) { - throw new ManagedAgentException(400, "invalid_message_cursor", - "The message cursor is invalid for this Session and direction."); - } - } - - private String messageCursor(MessagePosition position) { - try { - return java.util.Base64.getUrlEncoder().withoutPadding() - .encodeToString(objectMapper.writeValueAsBytes(position)); - } catch (java.io.IOException failure) { - throw new IllegalStateException("Message cursor serialization failed.", failure); - } - } - - private static ManagedAgentException messagePageTooLarge() { - return new ManagedAgentException(413, "message_page_too_large", - "The message page exceeds its byte budget."); - } - - private record MessagePosition(int version, String scope, boolean backward, int projection, - long through, long sequence, String item) {} - - private record MessageSize(String id, long sequence, long bytes) {} - - private record EventSize(long sequence, long bytes) {} - - /** The message projection coverage of each Session, in one read. */ public Map findMessageCoveredSequences(String tenantId, List sessionIds) { - if (sessionIds.isEmpty()) { - return Map.of(); - } - List arguments = new ArrayList<>(sessionIds.size() + 1); - arguments.add(tenantId); - arguments.addAll(sessionIds); - Map result = new HashMap<>(sessionIds.size() * 2); - RowCallbackHandler reader = row -> result.put( - row.getString("session_id"), row.getLong("covered_sequence")); - jdbc.query( - "SELECT s.session_id, s.covered_sequence FROM ma_event_stream s" - + " WHERE s.stream_kind = 'SESSION' AND" - + " s.stream_id = X'' AND s.tenant_id = ? AND s.session_id IN (" - + placeholders(sessionIds.size()) - + ")", - reader, - arguments.toArray()); - return result; + return messageProjection.findMessageCoveredSequences(tenantId, sessionIds); } public ReplayWindow findReplayWindow(String tenantId, String sessionId) { - List rows = - jdbc.query( - "SELECT s.replay_floor_sequence, s.covered_sequence FROM ma_event_stream s" - + " WHERE s.tenant_id = ? AND s.session_id = ?" - + " AND s.stream_kind = 'SESSION' AND s.stream_id = X''", - (result, row) -> - new ReplayWindow( - result.getLong("replay_floor_sequence"), - result.getLong("covered_sequence")), - tenantId, - sessionId); - if (rows.isEmpty()) { - throw new ManagedAgentException(404, "session_not_found", - "The Session was not found."); - } - return rows.getFirst(); + return messageProjection.findReplayWindow(tenantId, sessionId); } public List findReplayFloorTargets(int limit) { - return jdbc.query( - "SELECT s.tenant_id, s.session_id FROM ma_event_stream s" - + " WHERE s.stream_kind = 'SESSION' AND s.stream_id = X''" - + " AND s.covered_sequence > s.replay_floor_sequence" - + " AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE" - + " private_scope.tenant_id = s.tenant_id AND private_scope.session_id = s.session_id" - + " AND private_scope.csi_guard = FALSE)" - + " AND NOT EXISTS (SELECT 1 FROM ma_turn t JOIN ma_public_event e ON" - + " e.tenant_id = t.tenant_id AND e.session_id = t.session_id AND e.turn_id = t.turn_id" - + " WHERE t.tenant_id = s.tenant_id AND t.session_id = s.session_id" - + " AND t.status IN ('ACCEPTED', 'RUNNING', 'CANCELLING')" - + " AND e.stream_kind = 'SESSION' AND e.stream_id = X''" - + " AND e.event_type = 'turn.accepted' AND e.sequence_id <= s.replay_floor_sequence + 1)" - + " ORDER BY s.gmt_modified ASC LIMIT ?", - (row, index) -> new ReplayFloorTarget(row.getString("tenant_id"), row.getString("session_id")), - limit); + return messageProjection.findReplayFloorTargets(limit); } - /** Advances only through readable message coverage, retaining active-Turn evidence. */ @Transactional public ReplayWindow advanceReplayFloor(String tenantId, String sessionId, long floorSequence) { - requireLegacySessionForUpdate(tenantId, sessionId); - ReplayWindow window = findReplayWindow(tenantId, sessionId); - long floor = Math.min(floorSequence, window.snapshotThroughSequence()); - Long activeStart = - jdbc.queryForObject( - "SELECT MIN(e.sequence_id) FROM ma_public_event e JOIN ma_turn t ON" - + " t.tenant_id = e.tenant_id AND t.session_id = e.session_id AND" - + " t.turn_id = e.turn_id WHERE e.tenant_id = ? AND e.session_id = ?" - + " AND e.stream_kind = 'SESSION' AND e.stream_id = X'' AND" - + " e.event_type = 'turn.accepted' AND t.status IN ('ACCEPTED'," - + " 'RUNNING', 'CANCELLING')", - Long.class, - tenantId, - sessionId); - if (activeStart != null) { - floor = Math.min(floor, Math.max(0, activeStart - 1)); - } - if (floor <= window.floorSequence()) { - return window; - } - jdbc.update( - "UPDATE ma_event_stream SET replay_floor_sequence = ? WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", - floor, - tenantId, - sessionId); - return new ReplayWindow(floor, window.snapshotThroughSequence()); + return messageProjection.advanceReplayFloor(tenantId, sessionId, floorSequence); } public List findMaterializationTargets(int limit) { - return jdbc.query( - "SELECT s.tenant_id, s.session_id FROM ma_session s JOIN ma_event_stream p ON p.tenant_id = s.tenant_id AND p.session_id = s.session_id AND p.stream_kind = 'SESSION' AND p.stream_id = X'' WHERE s.session_kind = 'PUBLIC' AND s.csi_guard = FALSE AND s.retired_at IS NULL AND s.status <> 'DELETED' AND p.projection_pending = TRUE ORDER BY p.gmt_modified ASC LIMIT ?", - (result, row) -> - new MaterializationTarget( - result.getString("tenant_id"), result.getString("session_id")), - limit); + return messageProjection.findMaterializationTargets(limit); } @Transactional public MaterializationResult materializeNextBatch(String tenantId, String sessionId, int limit) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - Long covered = jdbc.queryForObject( - "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" - + " UPDATE", - Long.class, tenantId, sessionId); - if (covered == null) { - throw new IllegalStateException( - "Message projection progress is unavailable"); - } - List events = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" - + " = X'' AND tenant_id = ? AND session_id = ? AND sequence_id > ?" - + " ORDER BY sequence_id ASC LIMIT ?", - eventMapper, - tenantId, - sessionId, - covered, - limit); - if (events.isEmpty()) { - return new MaterializationResult(false, covered); - } - long expected = covered + 1; - Map> projectedParts = new LinkedHashMap<>(); - for (EventRecord event : events) { - if (event.sequence() != expected) { - // A gap is permanent: appendEvent allocates a sequence under - // the session row lock this method already holds, so no - // missing sequence can still appear. Skipping ahead once - // beats throwing on every scan and wedging the projection. - LOG.error("Message projection skips a permanent event gap" - + " tenant={} session={} missingSequences={}-{}" - + " nextEvent={}", - tenantId, sessionId, expected, event.sequence() - 1, - event.sequence()); - // The skipped range may have carried a turn's terminal - // event, whose settle is the only write that moves its - // items off in_progress. Settle those turns' items here, or - // they would be pinned forever — but only for a turn that is - // provably over: its terminal event is in the journal (its - // own materialization settled it already, making this a - // no-op guard) or its row is not live. A turn whose row is - // still live (recordHarnessEvents flips it in the same - // transaction that appends the terminal event, so a live row - // means the terminal event can still arrive) is settled by - // its own event or by turn recovery's turn.failed — never - // here. The remaining guards mirror settleTurnItems (never - // re-settle a completed item, never push last_sequence - // backwards). - jdbc.update("UPDATE ma_message SET item_status =" - + " 'failed', last_sequence = CASE WHEN" - + " last_sequence < ? THEN ? ELSE" - + " last_sequence END, gmt_modified = ?," - + " revision = revision + 1 WHERE tenant_id" - + " = ? AND session_id = ? AND item_status" - + " = 'in_progress' AND last_sequence < ?" - + " AND NOT EXISTS (SELECT 1 FROM" - + " ma_public_event terminal WHERE" - + " terminal.stream_kind = 'SESSION' AND" - + " terminal.stream_id = X'' AND" - + " terminal.tenant_id =" - + " ma_message.tenant_id AND" - + " terminal.session_id =" - + " ma_message.session_id AND" - + " terminal.turn_id =" - + " ma_message.turn_id AND" - + " terminal.event_type IN" - + " ('turn.completed', 'turn.failed'," - + " 'turn.cancelled'))" - + " AND NOT EXISTS (SELECT 1 FROM" - + " ma_turn live WHERE" - + " live.tenant_id =" - + " ma_message.tenant_id AND" - + " live.session_id =" - + " ma_message.session_id AND" - + " live.turn_id =" - + " ma_message.turn_id AND" - + " live.status IN ('ACCEPTED', 'RUNNING'," - + " 'CANCELLING'))", - event.sequence() - 1, event.sequence() - 1, - clock.millis(), tenantId, sessionId, - event.sequence()); - } - materializeEvent(event, projectedParts); - expected = event.sequence() + 1; - } - flushParts(tenantId, sessionId, projectedParts); - long nextCovered = events.get(events.size() - 1).sequence(); - long now = clock.millis(); - jdbc.update( - "UPDATE ma_event_stream SET covered_sequence = ?, gmt_modified = ?" - + " WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", - nextCovered, - now, - tenantId, - sessionId); - return new MaterializationResult(true, nextCovered); + return messageProjection.materializeNextBatch(tenantId, sessionId, limit); } @Override @Transactional public void deferMaterializationTarget(String tenantId, String sessionId) { - requireLegacyMutation(tenantId, sessionId); - jdbc.update("UPDATE ma_event_stream SET gmt_modified = ? WHERE tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", - clock.millis(), tenantId, sessionId); + messageProjection.deferMaterializationTarget(tenantId, sessionId); } public List findDispatchable(long now, int limit) { - // Workflow/Runtime parks have separate settlement paths. A due - // Harness park enters only the coordinator's read-only observation - // branch; cancellation still uses the normal dispatch lane. - return jdbc.query( - "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR error_code IS NULL OR error_code NOT IN ('" + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "', '" + AgentStateStore.RUNTIME_RECOVERY_BLOCKED + "')) AND (status = 'CANCELLING' OR NOT EXISTS (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id AND w.session_id = ma_turn.session_id AND w.state <> 'READY')) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY gmt_modified ASC LIMIT ?", - (result, row) -> new DispatchTarget( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("turn_id")), now, now, limit); + return turnExecution.findDispatchable(now, limit); } public List findRuntimeRecoveryParked(long now, int limit) { - // D7-2: a Turn whose parked Runtime execution still cannot be proved - // either way waits for its recheck, never for a dispatch. A cancel - // is not scanned here: the normal sweep owns that lane. - return jdbc.query( - "SELECT tenant_id, session_id, turn_id FROM ma_turn WHERE status IN ('ACCEPTED', 'RUNNING') AND error_code = '" - + AgentStateStore.RUNTIME_RECOVERY_BLOCKED - + "' AND (dispatch_lease_until IS NULL OR dispatch_lease_until < ?) AND (retry_after IS NULL OR retry_after <= ?) AND EXISTS (SELECT 1 FROM ma_session private_scope WHERE private_scope.tenant_id = ma_turn.tenant_id AND private_scope.session_id = ma_turn.session_id AND private_scope.csi_guard = FALSE) ORDER BY retry_after ASC, gmt_modified ASC LIMIT ?", - (result, row) -> new DispatchTarget( - result.getString("tenant_id"), - result.getString("session_id"), - result.getString("turn_id")), now, now, limit); + return turnExecution.findRuntimeRecoveryParked(now, limit); } @Transactional public Optional claimTurn(String tenantId, String sessionId, String turnId, String owner, Duration leaseDuration) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - int updated = jdbc.update("UPDATE ma_turn SET" - + " dispatch_owner = ?, dispatch_lease_until = ?," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ? AND turn_id = ?" - + " AND status IN ('ACCEPTED', 'RUNNING'," - + " 'CANCELLING') AND (dispatch_lease_until IS NULL" - + " OR dispatch_lease_until < ?) AND (retry_after IS" - + " NULL OR retry_after <= ?) AND (status = 'CANCELLING' OR NOT EXISTS" - + " (SELECT 1 FROM ma_session_worktree w WHERE w.tenant_id = ma_turn.tenant_id" - + " AND w.session_id = ma_turn.session_id AND w.state <> 'READY'))", - owner, now + leaseDuration.toMillis(), now, tenantId, - sessionId, turnId, now, now); - return updated == 0 ? Optional.empty() - : findTurn(tenantId, sessionId, turnId); + return turnExecution.claimTurn(tenantId, sessionId, turnId, owner, leaseDuration); } @Transactional public boolean renewTurn(String tenantId, String sessionId, String turnId, String owner, Duration leaseDuration) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - return jdbc.update("UPDATE ma_turn SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", dispatch_lease_until = ?, version = version + 1 WHERE tenant_id = ? AND session_id = ? AND turn_id = ? AND dispatch_owner = ? AND status IN ('ACCEPTED', 'RUNNING', 'CANCELLING') AND dispatch_lease_until >= ?", - now + leaseDuration.toMillis(), tenantId, sessionId, turnId, - owner, now) == 1; + return turnExecution.renewTurn(tenantId, sessionId, turnId, owner, leaseDuration); } @Transactional public void releaseTurnLease(String tenantId, String sessionId, String turnId, String owner) { - requireLegacyMutation(tenantId, sessionId); - jdbc.update("UPDATE ma_turn SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", dispatch_owner = NULL," - + " dispatch_lease_until = NULL, version = version +" - + " 1 WHERE tenant_id = ? AND session_id = ? AND" - + " turn_id = ? AND dispatch_owner = ? AND status IN" - + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", - tenantId, sessionId, turnId, owner); + turnExecution.releaseTurnLease(tenantId, sessionId, turnId, owner); } @Override @@ -5698,60 +957,21 @@ public class ManagedAgentStore implements AgentStateStore { public boolean markTurnRecoveryBlocked(String tenantId, String sessionId, String turnId, String owner, String errorCode, String errorMessage) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - Long retryAfter = AgentStateStore.HARNESS_RECOVERY_BLOCKED.equals(errorCode) - ? now + 15_000L : null; - // A sampled Harness block can lift. Retain the unsettled outcome - // and back off its read-only recheck without consuming retry budget. - return jdbc.update("UPDATE ma_turn SET error_code = ?," - + " error_message = ?, retry_after = ?, dispatch_owner = NULL," - + " dispatch_lease_until = NULL, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until >= ?" - + " AND status IN ('ACCEPTED', 'RUNNING'," - + " 'CANCELLING') AND (? <> ? OR status <> 'CANCELLING')", - errorCode, errorMessage, retryAfter, now, tenantId, sessionId, turnId, - owner, now, errorCode, AgentStateStore.HARNESS_RECOVERY_BLOCKED) == 1; + return turnExecution.markTurnRecoveryBlocked(tenantId, sessionId, turnId, owner, errorCode, errorMessage); } @Override @Transactional public boolean markTurnFailureRecovery(String tenantId, String sessionId, String turnId, String owner, String message) { - requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - String diagnostic = AgentStateStore.RUNTIME_RECOVERY_BLOCKED.equals(turn.errorCode()) - && turn.errorMessage() != null ? turn.errorMessage() : message; - return jdbc.update("UPDATE ma_turn SET error_code = ?, error_message = ?," - + " gmt_modified = ?, version = version + 1 WHERE tenant_id = ?" - + " AND session_id = ? AND turn_id = ? AND dispatch_owner = ?" - + " AND dispatch_lease_until >= ? AND submission_attempted = TRUE" - + " AND status IN ('ACCEPTED', 'RUNNING')", - AgentStateStore.RUNTIME_RECOVERY_BLOCKED, diagnostic, now, - tenantId, sessionId, turnId, owner, now) == 1; + return turnExecution.markTurnFailureRecovery(tenantId, sessionId, turnId, owner, message); } @Override @Transactional public boolean requestRecoveryRecheck(String tenantId, String sessionId, String turnId) { - requireLegacySessionForUpdate(tenantId, sessionId); - long now = clock.millis(); - return jdbc.update("UPDATE ma_turn SET retry_after = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND session_id = ?" - + " AND turn_id = ? AND (retry_after IS NULL OR retry_after > ?)" - + " AND (status = 'CANCELLING' OR status IN ('ACCEPTED', 'RUNNING')" - + " AND error_code = ?)", - now, now, tenantId, sessionId, turnId, now, - AgentStateStore.RUNTIME_RECOVERY_BLOCKED) == 1 - || jdbc.queryForObject("SELECT COUNT(*) FROM ma_turn WHERE tenant_id = ?" - + " AND session_id = ? AND turn_id = ? AND (status = 'CANCELLING'" - + " OR status IN ('ACCEPTED', 'RUNNING') AND error_code = ?)", - Long.class, tenantId, sessionId, turnId, - AgentStateStore.RUNTIME_RECOVERY_BLOCKED) == 1; + return turnExecution.requestRecoveryRecheck(tenantId, sessionId, turnId); } @Override @@ -5759,189 +979,53 @@ public class ManagedAgentStore implements AgentStateStore { public boolean parkTurnForRuntimeRecovery(String tenantId, String sessionId, String turnId, String owner, String message, long retryAfter) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - // D7-2: the park keeps the Turn active so its input stays unsettled. - // Only a proven cancel may settle it; the recheck drives that cancel. - return jdbc.update("UPDATE ma_turn SET error_code = ?," - + " error_message = ?, retry_after = ?," - + " dispatch_owner = NULL, dispatch_lease_until = NULL," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until >= ? AND" - + " status IN ('ACCEPTED', 'RUNNING')", - AgentStateStore.RUNTIME_RECOVERY_BLOCKED, message, retryAfter, - now, tenantId, sessionId, turnId, owner, now) == 1; + return turnExecution.parkTurnForRuntimeRecovery(tenantId, sessionId, turnId, owner, message, retryAfter); } @Override @Transactional public void deferTurnRetry(String tenantId, String sessionId, String turnId, String owner, long retryAfter) { - scheduleTurnRetry(tenantId, sessionId, turnId, owner, retryAfter, - false); + turnExecution.deferTurnRetry(tenantId, sessionId, turnId, owner, retryAfter); } @Transactional public void scheduleTurnRetry(String tenantId, String sessionId, String turnId, String owner, long retryAfter, boolean budgetExempt) { - requireLegacyMutation(tenantId, sessionId); - jdbc.update("UPDATE ma_turn SET retry_count = retry_count" - + " + 1, consecutive_failures = consecutive_failures" - + " + 1, budget_exempt_failures =" - + " budget_exempt_failures + ?, retry_after = ?," - + " dispatch_owner = NULL, dispatch_lease_until =" - + " NULL, gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ? AND turn_id = ?" - + " AND dispatch_owner = ? AND status IN ('ACCEPTED'," - + " 'RUNNING', 'CANCELLING')", - budgetExempt ? 1 : 0, retryAfter, clock.millis(), tenantId, - sessionId, turnId, owner); + turnExecution.scheduleTurnRetry(tenantId, sessionId, turnId, owner, retryAfter, budgetExempt); } @Transactional public boolean bindHarness(String tenantId, String sessionId, String turnId, String owner, String harnessBootId) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < now - || !ACTIVE_TURN_STATES.contains(turn.status())) { - return false; - } - if (harnessBootId.equals(session.harnessBootId())) { - return true; - } - if (session.harnessBootId() != null - && (turn.submissionAttempted() - || turn.harnessEventEpoch() != null)) { - return false; - } - jdbc.update("UPDATE ma_session SET harness_boot_id = ?," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ?", - harnessBootId, now, tenantId, sessionId); - return true; + return turnExecution.bindHarness(tenantId, sessionId, turnId, owner, harnessBootId); } @Transactional public boolean bindRecoveredHarness(String tenantId, String sessionId, String turnId, String owner, String expectedHarnessBootId, String harnessBootId) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < now - || !ACTIVE_TURN_STATES.contains(turn.status()) - || !turn.submissionAttempted() - || turn.harnessEventEpoch() == null) { - return false; - } - if (harnessBootId.equals(session.harnessBootId())) { - return true; - } - if (!Objects.equals(expectedHarnessBootId, - session.harnessBootId())) { - return false; - } - int updated = jdbc.update("UPDATE ma_session SET" - + " harness_boot_id = ?, gmt_modified = ?, version =" - + " version + 1 WHERE tenant_id = ? AND session_id" - + " = ? AND harness_boot_id = ?", - harnessBootId, now, tenantId, sessionId, - expectedHarnessBootId); - return updated == 1; + return turnExecution.bindRecoveredHarness(tenantId, sessionId, turnId, owner, expectedHarnessBootId, harnessBootId); } @Transactional public void markSubmissionAttempted(String tenantId, String sessionId, String turnId, String owner) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - int updated = jdbc.update("UPDATE ma_turn SET" - + " submission_attempted = TRUE, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ? AND harness_event_epoch IS NULL", - now, tenantId, sessionId, turnId, owner, now); - if (updated != 1) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } + turnExecution.markSubmissionAttempted(tenantId, sessionId, turnId, owner); } - /** - * G3: back out a submission mark whose attempt carries no event epoch, so a new Harness - * generation may bind and re-submit. A null epoch only proves the admission REPLY never landed: - * a settled old-generation admission replays idempotently on the journal's commandId when the - * Turn re-submits, while one still unsettled answers the resubmit with the coded - * duplicate-admission 409 — the caller adopts the attach's epoch and streams instead of - * withdrawing again (R10-4). Refuses under the same lease guards as the mark. - */ @Transactional public boolean withdrawSubmissionAttempted(String tenantId, String sessionId, String turnId, String owner) { - requireLegacyMutation(tenantId, sessionId); - long now = clock.millis(); - int updated = jdbc.update("UPDATE ma_turn SET" - + " submission_attempted = FALSE, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ? AND submission_attempted = TRUE AND" - + " harness_event_epoch IS NULL AND status IN" - + " ('ACCEPTED','RUNNING','CANCELLING')", - now, tenantId, sessionId, turnId, owner, now); - return updated == 1; + return turnExecution.withdrawSubmissionAttempted(tenantId, sessionId, turnId, owner); } @Transactional public void recordAdmission(String tenantId, String sessionId, String turnId, String owner, String eventEpoch, long lastEventId) { - // Lock the session row before the turn row: every writer must take - // the two in this order or InnoDB deadlocks against the - // session-first paths (e.g. insertCancelCommand). - requireLegacySessionForUpdate(tenantId, sessionId); - long now = clock.millis(); - // The admission proves coordination made progress, so the retry - // budget restarts: it counts consecutive failures without progress. - // retry_count is not reset — it paces the backoff, which must keep - // growing across a crash loop that keeps making progress (review - // round 8, R8-3). - int updated = jdbc.update("UPDATE ma_turn SET status =" - + " CASE WHEN status = 'CANCELLING' THEN status ELSE" - + " 'RUNNING' END, harness_event_epoch = ?," - + " harness_last_event_id = ?, consecutive_failures = 0," - + " budget_exempt_failures = 0," - + " gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ?", - eventEpoch, lastEventId, now, tenantId, sessionId, turnId, - owner, now); - if (updated != 1) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - int sessionUpdated = jdbc.update("UPDATE ma_session SET harness_event_epoch =" - + " ?, harness_last_event_id = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ?", - eventEpoch, lastEventId, now, tenantId, sessionId); - if (sessionUpdated != 1) { - throw new IllegalStateException("Session disappeared"); - } - if (!hasEventType(tenantId, sessionId, turnId, "turn.started")) { - appendEvent(tenantId, sessionId, turnId, "turn.started", - Map.of("turnId", turnId), false, null, now); - } - settleTurnCommands(tenantId, sessionId, turnId, now); + turnExecution.recordAdmission(tenantId, sessionId, turnId, owner, eventEpoch, lastEventId); } @Transactional @@ -5949,138 +1033,21 @@ public class ManagedAgentStore implements AgentStateStore { String turnId, String owner, String expectedTurnEventEpoch, String expectedSessionEventEpoch, String eventEpoch, long lastEventId) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < now - || !ACTIVE_TURN_STATES.contains(turn.status())) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - if (eventEpoch.equals(turn.harnessEventEpoch()) - && turn.harnessLastEventId() != null - && turn.harnessLastEventId() >= lastEventId) { - return; - } - if (!turn.submissionAttempted() - || !Objects.equals(expectedTurnEventEpoch, - turn.harnessEventEpoch()) - || !Objects.equals(expectedSessionEventEpoch, - session.harnessEventEpoch())) { - throw new IllegalStateException( - "Hosted Harness recovery epoch changed"); - } - // The recovery admission proves coordination made progress, so the - // retry budget restarts: it counts consecutive failures without - // progress. retry_count is not reset — it paces the backoff (review - // round 8, R8-3). - int updated = jdbc.update("UPDATE ma_turn SET status =" - + " CASE WHEN status = 'CANCELLING' THEN status ELSE" - + " 'RUNNING' END, harness_event_epoch = ?," - + " harness_last_event_id = ?, consecutive_failures = 0," - + " budget_exempt_failures = 0," - + " gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ? AND (harness_event_epoch = ? OR" - + " (harness_event_epoch IS NULL AND ? IS NULL))", - eventEpoch, lastEventId, now, tenantId, sessionId, turnId, - owner, now, expectedTurnEventEpoch, expectedTurnEventEpoch); - if (updated != 1) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - int sessionUpdated = jdbc.update("UPDATE ma_session SET harness_event_epoch =" - + " ?, harness_last_event_id = ?, gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND (harness_event_epoch = ? OR" - + " (harness_event_epoch IS NULL AND ? IS NULL))", - eventEpoch, lastEventId, now, tenantId, sessionId, - expectedSessionEventEpoch, expectedSessionEventEpoch); - if (sessionUpdated != 1) { - throw new IllegalStateException( - "Hosted Harness recovery epoch changed"); - } - settleTurnCommands(tenantId, sessionId, turnId, now); + turnExecution.recordRecoveryAdmission(tenantId, sessionId, turnId, owner, expectedTurnEventEpoch, expectedSessionEventEpoch, eventEpoch, lastEventId); } @Transactional public void retractContinuationOutput(String tenantId, String sessionId, String turnId, String owner, String harnessBootId, String eventEpoch) { - requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < now - || !ACTIVE_TURN_STATES.contains(turn.status())) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - if (harnessBootId.isBlank() || eventEpoch.isBlank()) { - throw new IllegalArgumentException( - "continuation owner is missing"); - } - String sourcePrefix = harnessBootId + ":" + eventEpoch + ":"; - String reconciliationKey = "reconcile:" + sourcePrefix + turnId; - if (hasSourceEvent(tenantId, sessionId, reconciliationKey)) { - return; - } - jdbc.queryForObject("SELECT covered_sequence FROM" - + " ma_event_stream WHERE tenant_id" - + " = ? AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''" - + " FOR UPDATE", - Long.class, tenantId, sessionId); - List deltas = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" - + " = X'' AND tenant_id = ? AND session_id = ? AND turn_id = ? AND" - + " event_type IN ('item.output_text.delta', 'item.reasoning.delta')" - + " ORDER BY sequence_id ASC", - eventMapper, - tenantId, - sessionId, - turnId); - long firstRetracted = Long.MAX_VALUE; - for (EventRecord event : deltas) { - if (event.sourceKey() == null - || !event.sourceKey().startsWith(sourcePrefix)) { - continue; - } - Map data = new LinkedHashMap<>(event.data()); - data.put("text", ""); - jdbc.update( - "UPDATE ma_public_event SET data_json = ? WHERE tenant_id = ? AND session_id =" - + " ? AND stream_kind = 'SESSION' AND stream_id = X'' AND sequence_id = ?", - writeJson(data), - tenantId, - sessionId, - event.sequence()); - firstRetracted = Math.min(firstRetracted, event.sequence()); - } - if (firstRetracted != Long.MAX_VALUE) { - reassignIdentity(tenantId, sessionId, firstRetracted); - } - rebuildTurnText(tenantId, sessionId, turnId, now); - appendEvent(tenantId, sessionId, turnId, "stream.reconciled", Map.of(), - false, reconciliationKey, now); + turnExecution.retractContinuationOutput(tenantId, sessionId, turnId, owner, harnessBootId, eventEpoch); } - /** - * In-band sibling of {@link #retractContinuationOutput} (#13319): a restarted model attempt on - * the live Harness retracts the published prefix of the message it replaces. The range is keyed - * by the journal sequence of the message's first delta, which the Harness reports as {@code - * fromSourceId}; only deltas of the current in-flight message carry source ids at or after it, - * so earlier committed rounds stay. The cursor advances past the retraction event in the same - * transaction whether or not the retraction applied, so a crash cannot re-deliver it. - */ @Transactional public void retractHarnessTurnOutput(String tenantId, String sessionId, String turnId, String owner, String eventEpoch, long fromSourceId, long retractionSourceId) { - retractHarnessTurnOutput(tenantId, sessionId, turnId, owner, eventEpoch, - fromSourceId, retractionSourceId, null, null, null); + turnExecution.retractHarnessTurnOutput(tenantId, sessionId, turnId, owner, eventEpoch, fromSourceId, retractionSourceId); } @Transactional @@ -6088,1564 +1055,75 @@ public class ManagedAgentStore implements AgentStateStore { String turnId, String owner, String eventEpoch, long fromSourceId, long retractionSourceId, String sourceBootId, String sourceEventEpoch, Long throughSourceId) { - requireLegacyMutation(tenantId, sessionId); - boolean bounded = throughSourceId != null; - if (bounded && (sourceBootId == null || sourceEventEpoch == null - || fromSourceId < 1 || throughSourceId < fromSourceId || throughSourceId >= retractionSourceId)) { - throw new IllegalArgumentException("Invalid bounded Hosted retraction"); - } - SessionRecord session = requireSessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - long now = clock.millis(); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < now) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - if (!eventEpoch.equals(turn.harnessEventEpoch())) { - throw new IllegalStateException( - "Hosted Harness event epoch changed"); - } - String sourcePrefix = (bounded ? sourceBootId : session.harnessBootId()) + ":" - + (bounded ? sourceEventEpoch : eventEpoch) + ":"; - String reconciliationKey = "reconcile:inband:" + sourcePrefix + turnId - + ":" + fromSourceId + (bounded ? ":" + throughSourceId : ""); - if (!hasSourceEvent(tenantId, sessionId, reconciliationKey)) { - jdbc.queryForObject( - "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" - + " UPDATE", - Long.class, - tenantId, - sessionId); - List deltas = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" - + " turn_id = ? AND event_type IN ('item.output_text.delta'," - + " 'item.reasoning.delta') ORDER BY sequence_id ASC", - eventMapper, - tenantId, - sessionId, - turnId); - long firstRetracted = Long.MAX_VALUE; - for (EventRecord event : deltas) { - if (event.sourceKey() == null - || (!bounded && !event.sourceKey().startsWith(sourcePrefix)) - || sourceIdOf(event.sourceKey()) < fromSourceId - || (bounded && sourceIdOf(event.sourceKey()) > throughSourceId)) { - continue; - } - Map data = new LinkedHashMap<>(event.data()); - data.put("text", ""); - jdbc.update( - "UPDATE ma_public_event SET data_json = ? WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' AND" - + " sequence_id = ?", - writeJson(data), - tenantId, - sessionId, - event.sequence()); - firstRetracted = Math.min(firstRetracted, event.sequence()); - } - if (firstRetracted != Long.MAX_VALUE) { - reassignIdentity(tenantId, sessionId, firstRetracted); - } - rebuildTurnText(tenantId, sessionId, turnId, now); - appendEvent( - tenantId, - sessionId, - turnId, - "stream.reconciled", - Map.of(), - false, - reconciliationKey, - now); - } - int updated = updateHarnessCursor(tenantId, sessionId, turnId, owner, - eventEpoch, retractionSourceId, now); - if (updated != 1) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - } - - private void rebuildTurnText(String tenantId, String sessionId, String turnId, long now) { - Long covered = - jdbc.queryForObject( - "SELECT covered_sequence FROM ma_event_stream WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" - + " UPDATE", - Long.class, - tenantId, - sessionId); - Map> parts = new LinkedHashMap<>(); - jdbc.query( - "SELECT item_id, parts_json, body_resource_key FROM ma_message WHERE tenant_id = ?" - + " AND session_id = ? AND turn_id = ? AND item_role = 'assistant' AND" - + " item_type = 'message' FOR UPDATE", - (org.springframework.jdbc.core.RowCallbackHandler) - row -> { - var retained = - new ArrayList<>( - readParts( - row.getString("parts_json"), - row.getString("body_resource_key"), - tenantId, - sessionId)); - retained.removeIf( - part -> - "output_text".equals(part.type()) - || "reasoning".equals(part.type())); - parts.put(row.getString("item_id"), retained); - }, - tenantId, - sessionId, - turnId); - List events = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE tenant_id = ? AND session_id = ? AND" - + " stream_kind = 'SESSION' AND stream_id = X'' AND turn_id = ? AND" - + " sequence_id <= ? AND event_type IN ('item.output_text.delta'," - + " 'item.reasoning.delta', 'turn.completed', 'turn.failed'," - + " 'turn.cancelled') ORDER BY sequence_id", - eventMapper, - tenantId, - sessionId, - turnId, - covered); - EventRecord terminal = null; - for (EventRecord event : events) { - if (List.of("turn.completed", "turn.failed", "turn.cancelled").contains(event.type())) { - terminal = event; - } else { - materializeText( - event, - "item.reasoning.delta".equals(event.type()) ? "reasoning" : "output_text", - parts); - } - } - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - var iterator = parts.entrySet().iterator(); - while (iterator.hasNext()) { - var entry = iterator.next(); - List retained = entry.getValue(); - if (retained.isEmpty()) { - ManagedProjectionResourceCollector.release(jdbc, scope, "MESSAGE", - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(scope, entry.getKey())); - jdbc.update( - "DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" - + " 'MESSAGE' AND owner_key = ?", - scope, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - scope, entry.getKey())); - jdbc.update( - "DELETE FROM ma_message WHERE tenant_id = ? AND session_id = ? AND item_id" - + " = ?", - tenantId, - sessionId, - entry.getKey()); - iterator.remove(); - continue; - } - long first = - retained.stream().mapToLong(ItemPartRecord::firstSequence).min().orElseThrow(); - long last = - retained.stream().mapToLong(ItemPartRecord::lastSequence).max().orElseThrow(); - long created = - retained.stream().mapToLong(ItemPartRecord::createdAt).min().orElseThrow(); - long updated = - retained.stream().mapToLong(ItemPartRecord::updatedAt).max().orElseThrow(); - String status = "in_progress"; - if (terminal != null) { - last = Math.max(last, terminal.sequence()); - updated = Math.max(updated, terminal.createdAt()); - status = switch (terminal.type()) { - case "turn.completed" -> "completed"; - case "turn.cancelled" -> "cancelled"; - default -> "failed"; - }; - } - jdbc.update( - "UPDATE ma_message SET first_sequence = ?, last_sequence = ?, gmt_create = ?," - + " gmt_modified = ?, item_status = ?, revision = revision + 1 WHERE" - + " tenant_id = ? AND session_id = ? AND item_id = ?", - first, - last, - created, - updated, - status, - tenantId, - sessionId, - entry.getKey()); - } - flushParts(tenantId, sessionId, parts); - jdbc.update( - "UPDATE ma_event_stream SET projection_version = projection_version + 1," - + " gmt_modified = ? WHERE tenant_id = ? AND session_id = ?" - + " AND stream_kind = 'SESSION' AND stream_id = X''", - now, - tenantId, - sessionId); - } - - // A Harness source key ends in the journal sequence of its journal event; - // the boot and epoch prefixes are colon-free by construction but the - // suffix is what the range compares. - private static long sourceIdOf(String sourceKey) { - try { - return Long.parseLong( - sourceKey.substring(sourceKey.lastIndexOf(':') + 1)); - } catch (NumberFormatException error) { - return -1; - } + turnExecution.retractHarnessTurnOutput(tenantId, sessionId, turnId, owner, eventEpoch, fromSourceId, retractionSourceId, sourceBootId, sourceEventEpoch, throughSourceId); } @Transactional public void recordHarnessEvents(String tenantId, String sessionId, String turnId, String owner, String eventEpoch, List events) { - requireLegacyMutation(tenantId, sessionId); - if (events.isEmpty()) { - return; - } - SessionRecord session = requireSessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurnForUpdate(tenantId, sessionId, turnId); - if (!owner.equals(turn.dispatchOwner()) - || turn.dispatchLeaseUntil() == null - || turn.dispatchLeaseUntil() < clock.millis()) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - if (!eventEpoch.equals(turn.harnessEventEpoch())) { - throw new IllegalStateException( - "Hosted Harness event epoch changed"); - } - long lastSourceId = turn.harnessLastEventId() == null ? 0 - : turn.harnessLastEventId(); - List accepted = new ArrayList<>(); - for (HarnessEvent event : events) { - if (event.sourceId() > lastSourceId) { - accepted.add(event); - lastSourceId = event.sourceId(); - } - } - if (accepted.isEmpty()) { - return; - } - long now = clock.millis(); - HarnessEvent terminal = null; - for (int index = 0; index < accepted.size(); index++) { - HarnessEvent event = accepted.get(index); - if (event.projection() != null - && event.projection().terminal()) { - if (terminal != null || index != accepted.size() - 1) { - throw new IllegalArgumentException( - "Terminal Harness event must end the batch"); - } - terminal = event; - } - } - int updated = terminal == null - ? updateHarnessCursor(tenantId, sessionId, turnId, owner, - eventEpoch, lastSourceId, now) - : completeHarnessTurn(tenantId, sessionId, turnId, owner, - eventEpoch, lastSourceId, terminal.projection(), now); - if (updated != 1) { - throw new IllegalStateException("Turn dispatch lease was lost"); - } - List projected = accepted.stream() - .filter(event -> event.projection() != null).toList(); - List committed = appendEvents(tenantId, sessionId, - turnId, eventEpoch, lastSourceId, session.lastSequence(), - projected, now); - if (terminal != null) { - settleTurnCommands(tenantId, sessionId, turnId, now); - } - publishAfterCommit(committed); - } - - private int updateHarnessCursor(String tenantId, String sessionId, - String turnId, String owner, String eventEpoch, - long lastSourceId, long now) { - // Journaling new events proves coordination made progress, so the - // retry budget restarts: it counts consecutive failures without - // progress. retry_count is not reset — it paces the backoff (review - // round 8, R8-3). - return jdbc.update("UPDATE ma_turn SET" - + " harness_event_epoch = ?," - + " harness_last_event_id = ?, consecutive_failures = 0," - + " budget_exempt_failures = 0," - + " gmt_modified = ?," - + " version = version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ?", - eventEpoch, lastSourceId, now, tenantId, sessionId, turnId, - owner, now); - } - - private int completeHarnessTurn(String tenantId, String sessionId, - String turnId, String owner, String eventEpoch, - long lastSourceId, ProjectedEvent terminal, long now) { - return jdbc.update("UPDATE ma_turn SET" - + " harness_event_epoch = ?," - + " harness_last_event_id = ?, status = ?," - + " error_code = ?, error_message = ?," - + " completed_at = ?, gmt_modified = ?," - + " dispatch_owner = NULL," - + " dispatch_lease_until = NULL, version =" - + " version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until" - + " >= ?", - eventEpoch, lastSourceId, terminal.terminalStatus(), - terminal.errorCode(), terminal.errorMessage(), now, now, - tenantId, sessionId, turnId, owner, now); - } - - private List appendEvents(String tenantId, - String sessionId, String turnId, String eventEpoch, - long lastSourceId, long sequence, List events, - long now) { - List records = new ArrayList<>(); - long next = sequence; - Identity previous = events.isEmpty() ? null - : findIdentity(tenantId, sessionId, next, - events.getFirst().projection().type()); - for (HarnessEvent event : events) { - ProjectedEvent projection = event.projection(); - previous = EventIdentity.of(projection.type(), turnId, ++next, - projection.data(), previous); - records.add(new EventRecord(tenantId, sessionId, next, - publicId("evt"), turnId, projection.type(), - projection.data(), projection.terminal(), - event.sourceKey(), now, EventIdentity.SCHEMA_VERSION, - EventIdentity.PROJECTION_VERSION, previous.itemId(), - previous.contentPartId())); - } - jdbc.update("UPDATE ma_session SET harness_event_epoch =" - + " ?, harness_last_event_id = ?," - + " gmt_modified = ?, version = version + 1 WHERE" - + " tenant_id = ? AND session_id = ?", - eventEpoch, lastSourceId, now, tenantId, sessionId); - jdbc.update( - "UPDATE ma_event_stream SET last_sequence = ?, gmt_modified = ? WHERE tenant_id = ?" - + " AND session_id = ? AND stream_kind = 'SESSION' AND stream_id = X''", - next, - now, - tenantId, - sessionId); - if (!records.isEmpty()) { - jdbc.batchUpdate(INSERT_EVENT, records, records.size(), - (statement, event) -> { - statement.setString(1, event.tenantId()); - statement.setString(2, event.sessionId()); - statement.setLong(3, event.sequence()); - statement.setString(4, event.eventId()); - statement.setString(5, event.turnId()); - statement.setString(6, event.type()); - statement.setString(7, writeJson(event.data())); - statement.setBoolean(8, event.terminal()); - statement.setString(9, event.sourceKey()); - statement.setLong(10, event.createdAt()); - statement.setInt(11, event.schemaVersion()); - statement.setInt(12, event.projectionVersion()); - statement.setString(13, event.itemId()); - statement.setString(14, event.contentPartId()); - }); - } - return List.copyOf(records); + turnExecution.recordHarnessEvents(tenantId, sessionId, turnId, owner, eventEpoch, events); } @Override @Transactional public boolean cancelUnadmittedTurn(String tenantId, String sessionId, String turnId, String owner) { - SessionRecord session = requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurn(tenantId, sessionId, turnId); - if (!"CANCELLING".equals(turn.status()) - || !owner.equals(turn.dispatchOwner()) - || turn.harnessEventEpoch() != null - || !turn.submissionAttempted()) { - return false; - } - var heads = WorkspaceOperations.rows(jdbc, - "SELECT tenant_id, session_id, state, writer_lease_until FROM" - + " ma_session_head WHERE tenant_key = ? AND harness_key = ?" - + " AND head_kind = 'MANAGED_SESSION' FOR UPDATE", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId)); - long now = lifecycleDatabaseTime(); - if (heads.size() != 1 || !tenantId.equals(heads.getFirst().get("tenant_id")) - || !sessionId.equals(heads.getFirst().get("session_id")) - || !("SEALED".equals(heads.getFirst().get("state")) - || "ACTIVE".equals(heads.getFirst().get("state")) - && heads.getFirst().get("writer_lease_until") instanceof Number until - && until.longValue() <= now)) { - return false; - } - // The locked, fenced writer cannot admit a late HTTP request. Even an - // idle journal may contain this prompt's completed admission: replay - // its real result instead of replacing it with cancellation. - java.util.Set accepted; - try { - accepted = WorkspaceLifecycleStore.requireIdleJournal(jdbc, objectMapper, - tenantId, sessionId, false, - WorkspaceToolProfiles.lifecycleProtocol(session.toolProfile()) == 2); - } catch (ManagedAgentException unverified) { - return false; - } - if (accepted.contains(turn.promptId()) || !withdrawSubmissionAttempted(tenantId, sessionId, turnId, owner)) { - return false; - } - cancelBeforeAdmission(tenantId, sessionId, turnId, owner); - return "CANCELLED".equals(requireTurn(tenantId, sessionId, turnId).status()); + return turnExecution.cancelUnadmittedTurn(tenantId, sessionId, turnId, owner); } @Transactional public void cancelBeforeAdmission(String tenantId, String sessionId, String turnId, String owner) { - // Session-row lock first; see recordAdmission. - requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurn(tenantId, sessionId, turnId); - if (!owner.equals(turn.dispatchOwner()) - || turn.harnessEventEpoch() != null - || turn.submissionAttempted()) { - return; - } - long now = clock.millis(); - int updated = jdbc.update("UPDATE ma_turn SET status =" - + " 'CANCELLED'," - + " completed_at = ?, gmt_modified = ?," - + " dispatch_owner = NULL," - + " dispatch_lease_until = NULL, version =" - + " version + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND" - + " dispatch_owner = ? AND dispatch_lease_until >= ?" - + " AND submission_attempted = FALSE", - now, now, tenantId, sessionId, turnId, owner, now); - if (updated != 1) { - return; - } - appendEvent(tenantId, sessionId, turnId, "turn.cancelled", - Map.of("turnId", turnId), true, null, now); - settleTurnCommands(tenantId, sessionId, turnId, now); + turnExecution.cancelBeforeAdmission(tenantId, sessionId, turnId, owner); } @Transactional public void failTurn(String tenantId, String sessionId, String turnId, String owner, String code, String message) { - settleFailedTurn(tenantId, sessionId, turnId, owner, code, message, true); + turnExecution.failTurn(tenantId, sessionId, turnId, owner, code, message); } @Transactional public void failWorktreeInitialTurns(String tenantId, String sessionId, String code) { - requireLegacySessionForUpdate(tenantId, sessionId); - if (jdbc.queryForList("SELECT 1 FROM ma_session_worktree WHERE tenant_id = ? AND session_id = ?" - + " AND state IN ('FAILED', 'BLOCKED') FOR UPDATE", Integer.class, tenantId, sessionId).isEmpty()) { - return; - } - long now = clock.millis(); - String message = "The Session worktree could not be prepared."; - List turns = jdbc.queryForList("SELECT turn_id FROM ma_turn WHERE tenant_id = ? AND session_id = ?" - + " AND status = 'ACCEPTED' AND submission_attempted = FALSE FOR UPDATE", String.class, tenantId, sessionId); - for (String turnId : turns) { - jdbc.update("UPDATE ma_turn SET status = 'FAILED', error_code = ?, error_message = ?, completed_at = ?," - + " gmt_modified = ?, dispatch_owner = NULL, dispatch_lease_until = NULL, version = version + 1" - + " WHERE tenant_id = ? AND session_id = ? AND turn_id = ? AND status = 'ACCEPTED'" - + " AND submission_attempted = FALSE", code, message, now, now, tenantId, sessionId, turnId); - appendEvent(tenantId, sessionId, turnId, "turn.failed", Map.of("code", code, "message", message), true, null, now); - settleTurnCommands(tenantId, sessionId, turnId, now); - } + turnExecution.failWorktreeInitialTurns(tenantId, sessionId, code); } @Transactional public boolean failRecoveryBlockedTurn(String tenantId, String sessionId, String turnId, String owner, String message) { - return settleFailedTurn(tenantId, sessionId, turnId, owner, - "managed_runtime_recovery_blocked", message, false); - } - - private boolean settleFailedTurn(String tenantId, String sessionId, String turnId, - String owner, String code, String message, boolean allowCancelling) { - // Session-row lock first; see recordAdmission. - requireLegacySessionForUpdate(tenantId, sessionId); - TurnRecord turn = requireTurn(tenantId, sessionId, turnId); - if (!owner.equals(turn.dispatchOwner()) - || !ACTIVE_TURN_STATES.contains(turn.status()) - || !allowCancelling && "CANCELLING".equals(turn.status())) { - return false; - } - long now = clock.millis(); - int updated = jdbc.update("UPDATE ma_turn SET status =" - + " 'FAILED'," - + " error_code = ?, error_message = ?, completed_at =" - + " ?, gmt_modified = ?, dispatch_owner = NULL," - + " dispatch_lease_until = NULL, version = version +" - + " 1 WHERE tenant_id = ? AND session_id = ? AND" - + " turn_id = ? AND dispatch_owner = ? AND status IN" - + (allowCancelling ? " ('ACCEPTED', 'RUNNING', 'CANCELLING') AND" - : " ('ACCEPTED', 'RUNNING') AND") - + " dispatch_lease_until >= ?", - code, message, now, now, tenantId, sessionId, turnId, owner, - now); - if (updated != 1) { - return false; - } - appendEvent(tenantId, sessionId, turnId, "turn.failed", - Map.of("code", code, "message", message), true, null, now); - settleTurnCommands(tenantId, sessionId, turnId, now); - return true; + return turnExecution.failRecoveryBlockedTurn(tenantId, sessionId, turnId, owner, message); } @Transactional public void appendPublicEventIfAbsent(String tenantId, String sessionId, String turnId, String type, Map data, boolean terminal, String sourceKey) { - requireLegacySessionForUpdate(tenantId, sessionId); - if (!hasSourceEvent(tenantId, sessionId, sourceKey)) { - appendEvent(tenantId, sessionId, turnId, type, data, terminal, - sourceKey, clock.millis()); - } + eventJournal.appendPublicEventIfAbsent(tenantId, sessionId, turnId, type, data, terminal, sourceKey); } @Transactional public void appendLiveSessionEventIfAbsent(String tenantId, String sessionId, String type, Map data, String sourceKey) { - requireLegacyMutation(tenantId, sessionId); - // A locking read sees the latest committed status, where a plain one - // could still see the snapshot taken before a deletion committed. - Optional session = jdbc.query(SESSION_WITH_STREAM + " WHERE ma_session.tenant_id = ? AND" - + " ma_session.session_kind = 'PUBLIC' AND" - + " ma_session.session_id = ? FOR UPDATE", - sessionMapper, tenantId, sessionId).stream() - .findFirst(); - if (session.isEmpty() || "DELETING".equals(session.get().status()) - || "DELETED".equals(session.get().status())) { - return; - } - if (!hasSourceEvent(tenantId, sessionId, sourceKey)) { - appendEvent(tenantId, sessionId, null, type, data, false, - sourceKey, clock.millis()); - } + eventJournal.appendLiveSessionEventIfAbsent(tenantId, sessionId, type, data, sourceKey); } @Override public Optional findLastVisibleStatusBeforeDelete(String tenantId, String sessionId) { - return jdbc.query("SELECT session_status_before FROM ma_operation" - + " WHERE scope_key = ? AND operation_kind = 'DELETE'" - + " AND target_key = ? AND tenant_id = ? AND session_id = ?" - + " AND session_status_before IS NOT NULL" - + " ORDER BY gmt_create DESC LIMIT 1", - (rows, row) -> rows.getString(1), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - sessionOperationTarget(sessionId), tenantId, sessionId) - .stream().findFirst(); + return sessionQueries.findLastVisibleStatusBeforeDelete(tenantId, sessionId); } public SessionRecord requireSession(String tenantId, String sessionId) { - return findSession(tenantId, sessionId).orElseThrow(() -> - new ManagedAgentException(404, "session_not_found", - "The Session was not found.")); - } - - private void materializeEvent(EventRecord event, Map> projectedParts) { - switch (event.type()) { - case "turn.accepted", "item.input.added" -> materializeInput(event, projectedParts); - case "item.output_text.delta" -> materializeText(event, - "output_text", projectedParts); - case "item.reasoning.delta" -> materializeText(event, - "reasoning", projectedParts); - case "item.tool_call.updated", "item.tool_result.updated" -> materializeTool(event); - case "turn.completed", "turn.failed", "turn.cancelled" -> - settleTurnItems(event); - default -> { - return; - } - } - } - - private void materializeInput(EventRecord event, Map> projectedParts) { - List> input = inputData(event.data().get("input")); - if (input.isEmpty()) { - input = requireTurn(event.tenantId(), event.sessionId(), - event.turnId()).input(); - } - String itemId = string(event.data().get("itemId")); - if (itemId == null) { - itemId = StoreModels.inputItemId(event.turnId()); - } - upsertItem(event, itemId, "message", "user", "completed", - input.stream().anyMatch(block -> "image".equals(block.get("type")) || "resource".equals(block.get("type"))) - ? Map.of("inputAttachments", input) : Map.of()); - for (int index = 0; index < input.size(); index++) { - Map block = input.get(index); - String text = string(block.get("text")); - if (text == null) { - text = workflowLaunchSummary(block); - } - if (text == null) { - continue; - } - replacePart(event, itemId, - "part_" + ("item.input.added".equals(event.type()) ? itemId : event.turnId()) + "_input_" + index, - "input_text", text, projectedParts); - } - } - - /** - * A workflow child's first turn carries one {@code workflow_launch} block and no text (#13803 - * K1): the message names the launch pin exactly as the daemon journal does ({@code - * workflowLaunchSummaryText} in hosted-workflow-turn.ts), so both projections of the turn - * agree. The block itself is never given a text key — the daemon parser requires its closed key - * set. - */ - private static String workflowLaunchSummary(Map block) { - if (!"workflow_launch".equals(block.get("type")) - || !(block.get("definition") instanceof Map pin)) { - return null; - } - String id = string(pin.get("definitionId")); - String digest = string(pin.get("definitionDigest")); - if (id == null || digest == null - || !(pin.get("definitionRevision") instanceof Number revision)) { - return null; - } - return ""; - } - - private void materializeText(EventRecord event, String partType, Map> projectedParts) { - String text = string(event.data().get("text")); - if (text == null || text.isEmpty()) { - return; - } - String itemId = string(event.data().get("itemId")); - if (itemId == null) { - itemId = EventIdentity.assistantItemId(event.turnId()); - } - // The previous part of the same series is the one with the greatest - // last_sequence, whatever events sit between the two deltas — an - // announcement row must not split an assistant message in two - // (#13300 R3-3, symmetric with the write-side identity lookup). - List parts = projectedParts.computeIfAbsent(itemId, - key -> new ArrayList<>(partsForItem(event.tenantId(), event.sessionId(), key))); - String partId = event.contentPartId() != null ? event.contentPartId() : parts.stream() - .filter(part -> partType.equals(part.type())) - .map(ItemPartRecord::partId).findFirst().orElseGet(() -> - EventIdentity.textPartId(event.turnId(), partType, event.sequence())); - upsertItem(event, itemId, "message", "assistant", "in_progress", - Map.of()); - appendPart(event, itemId, partId, partType, text, projectedParts); - } - - private void materializeTool(EventRecord event) { - String itemId = EventIdentity.toolItemId(event.turnId(), - event.sequence(), event.data()); - String sourceStatus = string(event.data().get("status")); - // Protocol tokens must fold with Locale.ROOT: under a tr_TR default - // locale "FAILED" becomes "faıled" and misses these arms. - String status = switch (sourceStatus == null ? "" - : sourceStatus.toLowerCase(Locale.ROOT)) { - case "completed", "success" -> "completed"; - case "failed" -> "failed"; - case "cancelled" -> "cancelled"; - default -> "in_progress"; - }; - Map attributes = existingAttributes(event, itemId); - if (attributes.get("result") instanceof Map previous) { - if (!(event.data().get("result") instanceof Map next) - || number(next.get("projection_revision")) - <= number(previous.get("projection_revision"))) { - return; - } - } - attributes.putAll(event.data()); - attributes.remove("itemId"); - upsertItem(event, itemId, "tool_call", "assistant", status, - Map.copyOf(attributes)); - } - - private static long number(Object value) { - return value instanceof Number numeric ? numeric.longValue() : 0; - } - - private Map existingAttributes(EventRecord event, - String itemId) { - List rows = jdbc.query("SELECT attributes_json FROM" - + " ma_message WHERE tenant_id = ? AND" - + " session_id = ? AND item_id = ?", - (result, row) -> result.getString("attributes_json"), - event.tenantId(), event.sessionId(), itemId); - return rows.isEmpty() ? new LinkedHashMap<>() - : new LinkedHashMap<>(readMap(rows.get(0))); - } - - private void settleTurnItems(EventRecord event) { - String status = switch (event.type()) { - case "turn.completed" -> "completed"; - case "turn.cancelled" -> "cancelled"; - default -> "failed"; - }; - jdbc.update("UPDATE ma_message SET item_status = ?," - + " last_sequence = CASE WHEN last_sequence < ?" - + " THEN ? ELSE last_sequence END, gmt_modified = ?," - + " revision = revision + 1 WHERE tenant_id = ? AND" - + " session_id = ? AND turn_id = ? AND item_status" - + " = 'in_progress'", - status, event.sequence(), event.sequence(), - event.createdAt(), event.tenantId(), event.sessionId(), - event.turnId()); - } - - private void upsertItem(EventRecord event, String itemId, String type, - String role, String status, Map attributes) { - int updated = jdbc.update("UPDATE ma_message SET" - + " item_status = ?, attributes_json = ?," - + " last_sequence = ?, gmt_modified = ?, revision =" - + " revision + 1 WHERE tenant_id = ? AND session_id" - + " = ? AND item_id = ?", - status, writeJson(attributes), event.sequence(), - event.createdAt(), event.tenantId(), event.sessionId(), - itemId); - if (updated == 0) { - jdbc.update("INSERT INTO ma_message (tenant_id," - + " session_id, item_id, turn_id, item_type," - + " item_role, item_status, attributes_json, parts_json," - + " first_sequence, last_sequence, gmt_create," - + " gmt_modified) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '[]'," - + " ?, ?, ?, ?)", - event.tenantId(), event.sessionId(), itemId, - event.turnId(), type, role, status, - writeJson(attributes), event.sequence(), event.sequence(), - event.createdAt(), event.createdAt()); - } - } - - private void replacePart(EventRecord event, String itemId, - String partId, String type, String text, Map> projectedParts) { - changePart(event, itemId, partId, type, text, false, projectedParts); - } - - private void appendPart(EventRecord event, String itemId, - String partId, String type, String text, Map> projectedParts) { - changePart(event, itemId, partId, type, text, true, projectedParts); - } - - private void changePart(EventRecord event, String itemId, String partId, - String type, String text, boolean append, Map> projectedParts) { - List parts = projectedParts.computeIfAbsent(itemId, - key -> new ArrayList<>(partsForItem(event.tenantId(), event.sessionId(), key))); - int index = -1; - for (int current = 0; current < parts.size(); current++) { - if (partId.equals(parts.get(current).partId())) { - index = current; - break; - } - } - ItemPartRecord old = index < 0 ? null : parts.get(index); - ItemPartRecord next = new ItemPartRecord(partId, type, - append && old != null ? old.text() + text : text, - old == null ? event.sequence() : old.firstSequence(), event.sequence(), - old == null ? event.createdAt() : old.createdAt(), event.createdAt(), - old == null ? 1 : old.revision() + 1); - if (index < 0) { - parts.add(next); - } else { - parts.set(index, next); - } - } - - private void flushParts( - String tenantId, String sessionId, Map> projectedParts) { - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - for (var entry : projectedParts.entrySet()) { - String item = entry.getKey(); - byte[] bytes = - writeJson(entry.getValue()).getBytes(java.nio.charset.StandardCharsets.UTF_8); - String owner = - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - scope, item); - Map previous = - jdbc.queryForMap( - "SELECT revision, body_resource_key FROM ma_message WHERE tenant_id = ?" - + " AND session_id = ? AND item_id = ? FOR UPDATE", - tenantId, - sessionId, - item); - long revision = ((Number) previous.get("revision")).longValue(); - if (previous.get("body_resource_key") != null) { - ManagedProjectionResourceCollector.release(jdbc, scope, "MESSAGE", owner); - } - jdbc.update( - "DELETE FROM ma_resource_ref WHERE session_scope_key = ? AND owner_kind =" - + " 'MESSAGE' AND owner_key = ?", - scope, - owner); - String resource = null; - if (bytes.length > 64 * 1024) { - String digest = WorkspaceEvidenceResources.digest(bytes); - String id = "message:" + owner + ":" + revision + ":" + digest; - resource = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, id); - try { - jdbc.update( - "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, session_id, resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, 'managed-message-parts', 1, ?, ?, 'MYSQL_INLINE', ?, 'REFERENCED', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", - resource, - scope, - tenantId, - sessionId, - id, - bytes.length, - digest, - bytes); - } catch (DuplicateKeyException duplicate) { - List matching = - jdbc.query( - "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE resource_key = ? FOR UPDATE", - (row, index) -> - scope.equals(row.getString("session_scope_key")) - && tenantId.equals(row.getString("tenant_id")) - && sessionId.equals(row.getString("session_id")) - && id.equals(row.getString("resource_id")) - && "managed-message-parts" - .equals(row.getString("kind")) - && row.getInt("schema_version") == 1 - && row.getLong("byte_length") == bytes.length - && digest.equals(row.getString("sha256")) - && java.util.Arrays.equals( - bytes, row.getBytes("inline_bytes")) - && "MYSQL_INLINE" - .equals(row.getString("storage_kind")) - && "REFERENCED".equals(row.getString("state")) - && row.getString("deletion_operation_key") - == null - && row.getString("publication_id") == null - && row.getString("object_key") == null - && row.getString("object_version_id") == null - && row.getString("encryption_key_id") == null, - resource); - if (matching.size() != 1 || !matching.getFirst()) { - throw new IllegalStateException( - "Immutable message body identity differs.", duplicate); - } - } - jdbc.update( - "INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key, tenant_id, session_id, resource_id, gmt_create) VALUES (?, 'MESSAGE', ?, ?, ?, ?, ?, ?, " + ManagedSchemaTime.SQL_NOW + ")", - scope, - owner, - revision, - resource, - tenantId, - sessionId, - id); - } - int changed = - jdbc.update( - "UPDATE ma_message SET parts_json = ?, body_resource_key = ?" - + " WHERE tenant_id = ? AND session_id = ? AND item_id = ?", - resource == null - ? new String(bytes, java.nio.charset.StandardCharsets.UTF_8) - : "[]", - resource, - tenantId, - sessionId, - item); - if (changed != 1) { - throw new IllegalStateException("Message disappeared during locked projection."); - } - } - } - - private List partsForItem(String tenantId, String sessionId, String itemId) { - List> rows = - jdbc.query( - "SELECT parts_json, body_resource_key FROM ma_message WHERE tenant_id = ?" - + " AND session_id = ? AND item_id = ? FOR UPDATE", - (row, index) -> readParts(row.getString(1), row.getString(2), tenantId, sessionId), - tenantId, - sessionId, - itemId); - return rows.isEmpty() ? List.of() : rows.getFirst(); - } - - private List readParts(String value, String resourceKey, String tenantId, String sessionId) { - if (resourceKey == null) { - return readParts(value); - } - List rows = - jdbc.query( - "SELECT inline_bytes, byte_length, sha256 FROM ma_resource WHERE" - + " resource_key = ? AND session_scope_key = ? AND tenant_id = ? AND" - + " session_id = ? AND kind = ? AND schema_version = 1 AND state =" - + " 'REFERENCED' AND deletion_operation_key IS NULL", - (row, i) -> { - byte[] bytes = row.getBytes("inline_bytes"); - if (bytes == null - || bytes.length != row.getLong("byte_length") - || !WorkspaceEvidenceResources.digest(bytes) - .equals(row.getString("sha256"))) { - throw new IllegalStateException( - "Message body resource digest differs."); - } - return bytes; - }, - resourceKey, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), - tenantId, - sessionId, - "managed-message-parts"); - if (rows.size() != 1) { - throw new IllegalStateException("Message body resource is unavailable."); - } - return readParts(new String(rows.getFirst(), java.nio.charset.StandardCharsets.UTF_8)); - } - - private List readParts(String value) { - try { - return value == null ? List.of() : objectMapper.readValue(value, PARTS_TYPE); - } catch (java.io.IOException failure) { - throw new IllegalStateException("Stored message parts are invalid.", failure); - } - } - - private List allItems(String tenantId, String sessionId) { - return withParts(jdbc.query("SELECT " + ManagedSchemaProjection.MESSAGE.columns() + " FROM ma_message WHERE" - + " tenant_id = ? AND session_id = ? ORDER BY" - + " first_sequence ASC", - itemMapper, tenantId, sessionId)); - } - - private List withParts(List rows) { - return rows.stream().map(ItemRow::toRecord).toList(); - } - - private static Map acceptedData(String turnId, - List> input) { - return Map.of("turnId", turnId, - "itemId", StoreModels.inputItemId(turnId), "input", input); - } - - private static String string(Object value) { - return value instanceof String ? (String) value : null; - } - - private static List> inputData(Object value) { - if (!(value instanceof List values)) { - return List.of(); - } - List> input = new ArrayList<>(); - for (Object item : values) { - if (!(item instanceof Map raw)) { - continue; - } - Map block = new LinkedHashMap<>(); - raw.forEach((key, entry) -> { - if (key instanceof String name) { - block.put(name, entry); - } - }); - input.add(Map.copyOf(block)); - } - return List.copyOf(input); - } - - private void requireLegacyMutation(String tenantId, String sessionId) { - ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); - } - - private SessionRecord requireLegacySessionForUpdate(String tenantId, String sessionId) { - return requireSessionForUpdate(tenantId, sessionId, true); - } - - private SessionRecord requireSessionForUpdate(String tenantId, - String sessionId) { - return requireSessionForUpdate(tenantId, sessionId, false); - } - - private SessionRecord requireSessionForUpdate( - String tenantId, String sessionId, boolean legacyMutation) { - try { - return jdbc.queryForObject( - "SELECT " + ManagedSchemaProjection.SESSION.columns("ma_session") - + " FROM ma_session WHERE ma_session.session_kind = 'PUBLIC' AND ma_session.tenant_id =" - + " ? AND ma_session.session_id = ? FOR UPDATE", - (rows, rowNumber) -> { - if (legacyMutation) { - ManagedLegacySessionGuard.requireLegacyMutation(rows.getBoolean("csi_guard")); - } - // H2 can retain pre-wait outer-join values after locking the Session. - // Read its stream only after acquiring that lock. - List streams = jdbc.query( - "SELECT last_sequence, replay_floor_sequence FROM ma_event_stream" - + " WHERE tenant_id = ? AND session_id = ? AND stream_kind =" - + " 'SESSION' AND stream_id = X'' FOR UPDATE", - (stream, index) -> new StreamCounters(stream.getLong("last_sequence"), - stream.getLong("replay_floor_sequence")), - tenantId, sessionId); - if (streams.isEmpty()) { - throw new IllegalStateException("Session stream is unavailable."); - } - return readSession(rows, streams.getFirst()); - }, - tenantId, - sessionId); - } catch (EmptyResultDataAccessException error) { - throw new ManagedAgentException(404, - "session_not_found", "The Session was not found."); - } - } - - private TurnRecord requireTurn(String tenantId, String sessionId, - String turnId) { - return findTurn(tenantId, sessionId, turnId).orElseThrow(() -> - new ManagedAgentException(404, "turn_not_found", - "The Turn was not found.")); - } - - private TurnRecord requireTurnForUpdate(String tenantId, - String sessionId, String turnId) { - try { - return jdbc.queryForObject("SELECT " + ManagedSchemaProjection.TURN.columns() + " FROM ma_turn" - + " WHERE tenant_id = ? AND session_id = ? AND" - + " turn_id = ? FOR UPDATE", - turnMapper, tenantId, sessionId, turnId); - } catch (EmptyResultDataAccessException error) { - throw new ManagedAgentException(404, "turn_not_found", - "The Turn was not found."); - } - } - - private void insertTurn(String tenantId, String sessionId, - String turnId, String promptId, List> input, - String payloadDigest, long now) { - jdbc.update("INSERT INTO ma_turn (tenant_id, session_id," - + " turn_id, prompt_id, input_json, payload_digest," - + " status, gmt_create, gmt_modified, model_id, model_revision, approval_mode, approval_revision, plan_enabled, permission_rules_json) SELECT" - + " ?, ?, ?, ?, ?, ?, 'ACCEPTED', ?, ?, model_id," - + " CASE WHEN model_id IS NULL THEN NULL ELSE model_revision END," - + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" - + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" - + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," - + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" - + " THEN COALESCE(selected_approval_mode, approval_mode) ELSE NULL END," - + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" - + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" - + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," - + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" - + " THEN approval_revision ELSE NULL END," - + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" - + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" - + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," - + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" - + " THEN plan_enabled ELSE NULL END," - + " CASE WHEN ((agent_id=? AND agent_definition_digest IS NULL)" - + " OR (agent_id<>? AND agent_definition_digest IS NOT NULL))" - + " AND workspace_id IS NOT NULL AND tool_profile IN ('hosted-workspace-files/1'," - + " 'hosted-workspace-shell/1','hosted-workspace-files/3')" - + " THEN permission_rules_json ELSE NULL END" - + " FROM ma_session WHERE tenant_id = ? AND session_id = ?", - tenantId, sessionId, turnId, promptId, writeJson(input), - payloadDigest, now, now, "qwen-code".getBytes(StandardCharsets.UTF_8), - "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), - "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), - "qwen-code".getBytes(StandardCharsets.UTF_8), "qwen-code".getBytes(StandardCharsets.UTF_8), - "qwen-code".getBytes(StandardCharsets.UTF_8), tenantId, sessionId); - } - - private void insertCommand(String tenantId, String operation, - String idempotencyKey, String requestDigest, String sessionId, - String turnId, long now) { - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, "COMPLETED", now); - } - - private void insertCommand(String tenantId, String operation, - String idempotencyKey, String requestDigest, String sessionId, - String turnId, String status, long now) { - insertCommand(tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, status, null, null, now); - } - - private void insertCommand(String tenantId, String operation, - String idempotencyKey, String requestDigest, String sessionId, - String turnId, String status, String sessionStatusBefore, - byte[] actorKey, long now) { - ManagedReceipts.insertCommand(jdbc, tenantId, operation, idempotencyKey, requestDigest, - sessionId, turnId, status, sessionStatusBefore, actorKey, now); - } - - // null for an actor id the registry key cannot encode: ManagedReceipts - // stamps the receipt UNATTRIBUTED, which answers to the creator gate - // alone. - private static byte[] commandActorKey(String tenantId, String actorId) { - if (actorId == null) { - return null; - } - try { - return com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey(tenantId, actorId); - } catch (IllegalArgumentException error) { - return null; - } - } - - // A mutation leaves the status unchanged until it completes. - private static void validateMutationStatus(SessionRecord session, - SessionMutationKind kind) { - requireSessionStatus(session.status(), - kind == SessionMutationKind.RENAME ? "ACTIVE" : "ARCHIVED"); - } - - private static String mutationEvent(SessionMutationKind kind, - String phase) { - if ("completed".equals(phase)) { - return kind == SessionMutationKind.RENAME ? "session.updated" - : "session.unarchived"; - } - return "session." - + (kind == SessionMutationKind.RENAME ? "update" : "unarchive") - + "." + phase; - } - - // The Session lock orders each new attempt against committed events. - // Legacy receipts fall back to their original requested event; a - // re-drive refreshes the boundary without appending another event. - private boolean supersededByLaterMutation(String tenantId, - String sessionId, String operation, String idempotencyKey, - SessionMutationKind kind) { - List attempts = - jdbc.queryForList( - "SELECT COALESCE( c.mutation_attempt_sequence, e.sequence_id) FROM" - + " ma_request_receipt c LEFT JOIN ma_public_event e ON e.tenant_id =" - + " c.tenant_id AND e.session_id = c.session_id AND e.stream_kind =" - + " 'SESSION' AND e.stream_id = X'' AND e.source_key = ? WHERE" - + " c.namespace = 'SESSION_COMMAND' AND c.receipt_key = ? AND" - + " c.session_id = ?", - Long.class, - mutationSource(operation, idempotencyKey, "requested"), - ManagedReceipts.commandKey(tenantId, operation, idempotencyKey), - sessionId); - if (attempts.isEmpty() || attempts.getFirst() == null) { - return false; - } - Integer later = - jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND" - + " sequence_id > ? AND event_type = ? AND source_key LIKE 'control:%'", - Integer.class, - tenantId, - sessionId, - attempts.getFirst(), - mutationEvent(kind, "completed")); - return later != null && later > 0; - } - - private static String mutationSource(String operation, - String idempotencyKey, String phase) { - return "control:" + operation + ":" + idempotencyKey + ":" + phase; - } - - // One lifecycle change at a time: a pending rename or unarchive command - // blocks an operation, and an open operation blocks both commands. - /** The latest terminally failed lifecycle operation of the kind: the - * status the Session carried when it began and the protocol it ran - * under — both re-pinned by a resumed admission. */ - private Optional latestFailedLifecycleOperation( - String tenantId, String sessionId, OperationKind kind) { - var rows = jdbc.query("SELECT operation_id, session_status_before," - + " lifecycle_protocol_version FROM" - + " ma_operation WHERE tenant_id = ?" - + " AND session_id = ? AND operation_kind = ? AND" - + " state = 'FAILED' ORDER BY gmt_modified DESC," - + " gmt_create DESC LIMIT 1", - (result, row) -> new FailedLifecycleOperation( - result.getString(1), result.getString(2), - result.getInt(3)), - tenantId, sessionId, kind.name()); - return rows.isEmpty() ? Optional.empty() - : Optional.of(rows.getFirst()); - } - - private record FailedLifecycleOperation(String operationId, - String sessionStatusBefore, int lifecycleProtocolVersion) { - } - - private void requireNoOpenOperation(String tenantId, String sessionId) { - if (hasOpenOperation(tenantId, sessionId)) { - throw new ManagedAgentException(409, - "session_operation_active", - "The Session already has a lifecycle operation in progress."); - } - } - - private void validateOperationStart(SessionRecord session, - OperationKind kind) { - String status = session.status(); - switch (kind) { - case CLOSE -> requireSessionStatus(status, "ACTIVE"); - case ARCHIVE -> requireSessionStatus(status, "CLOSED"); - case DELETE -> { - if (!List.of("ACTIVE", "CLOSED", "ARCHIVED") - .contains(status)) { - throw sessionStateConflict(status); - } - } - } - if ("ACTIVE".equals(status) - && (hasLiveTurn(session.tenantId(), session.sessionId()) - || session.workspace() != null && hasDecidableAction(session))) { - throw new ManagedAgentException(409, "turn_active", - "The Session has an active Turn."); - } - } - - /** - * Only a permanently refused workflow leaves this lifecycle gate. - * Runtime and Harness parks still await authoritative settlement; - * closing them could discard a later result. Cancellation also waits. - */ - private boolean hasLiveTurn(String tenantId, String sessionId) { - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" - + " ma_turn WHERE tenant_id = ? AND" - + " session_id = ? AND status IN" - + " ('ACCEPTED', 'RUNNING', 'CANCELLING')" - + " AND (status = 'CANCELLING' OR error_code IS NULL" - + " OR error_code <> '" - + AgentStateStore.WORKFLOW_RECOVERY_BLOCKED + "')", - Integer.class, tenantId, sessionId); - return count != null && count > 0; - } - - private boolean hasDecidableAction(SessionRecord session) { - long now = lifecycleDatabaseTime(); - for (String options : jdbc.queryForList( - "SELECT options_json FROM ma_session_record WHERE session_scope_key = ? AND" - + " record_kind = 'ACTION' AND state = 'requested' AND tenant_id = ?" - + " AND session_id = ?", String.class, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(session.tenantId(), session.sessionId()), - session.tenantId(), session.sessionId())) { - try { - if (now < objectMapper.readTree(options).path("expiresAt").asLong()) { - return true; - } - } catch (JsonProcessingException error) { - throw new IllegalStateException("Stored Action options are invalid", error); - } - } - return false; - } - - // ARCHIVING remains only for an archive admitted before V17, which closes - // the Harness as archive used to. - private static String pendingStatus(OperationKind kind) { - return switch (kind) { - case CLOSE -> "CLOSING"; - case ARCHIVE -> "ARCHIVING"; - case DELETE -> "DELETING"; - case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> - throw new IllegalArgumentException("Not a lifecycle operation"); - }; - } - - private static String requestedEvent(OperationKind kind) { - return switch (kind) { - case CLOSE -> "session.close.requested"; - case ARCHIVE -> "session.archive.requested"; - case DELETE -> "session.delete.requested"; - case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> - throw new IllegalArgumentException("Not a lifecycle operation"); - }; - } - - private static String completedEvent(OperationKind kind) { - return switch (kind) { - case CLOSE -> "session.closed"; - case ARCHIVE -> "session.archived"; - case DELETE -> "session.deleted"; - case ACTION_RESPONSE, CWD_CHANGE, CONTEXT_COMPRESSION, TASK_CANCEL, SUBMIT_INPUT, CANCEL -> - throw new IllegalArgumentException("Not a lifecycle operation"); - }; - } - - private static String operationSource(String operationId, - String phase) { - return "operation:" + operationId + ":" + phase; - } - - private static void requireSessionStatus(String actual, - String expected) { - if (!expected.equals(actual)) { - throw sessionStateConflict(actual); - } - } - - private static ManagedAgentException sessionStateConflict(String status) { - return new ManagedAgentException(409, - "session_state_conflict", - "The Session is " + status.toLowerCase(Locale.ROOT) - + " and cannot perform this operation."); - } - - private EventRecord appendEvent(String tenantId, String sessionId, - String turnId, String type, Map data, - boolean terminal, String sourceKey, long now) { - Long sequence = - jdbc.queryForObject( - "SELECT last_sequence FROM ma_event_stream WHERE tenant_id = ? AND" - + " session_id = ? AND stream_kind = 'SESSION' AND stream_id = X'' FOR" - + " UPDATE", - Long.class, - tenantId, - sessionId); - if (sequence == null) { - throw new IllegalStateException("Session sequence is unavailable"); - } - long next = sequence + 1; - Identity identity = EventIdentity.of(type, turnId, next, data, - findIdentity(tenantId, sessionId, sequence, type)); - EventRecord event = new EventRecord(tenantId, sessionId, next, - publicId("evt"), turnId, type, data, terminal, sourceKey, - now, EventIdentity.SCHEMA_VERSION, - EventIdentity.PROJECTION_VERSION, identity.itemId(), - identity.contentPartId()); - jdbc.update("UPDATE ma_event_stream SET last_sequence = ?," - + " gmt_modified = ? WHERE tenant_id = ? AND session_id = ?" - + " AND stream_kind = 'SESSION' AND stream_id = X''", - next, now, tenantId, sessionId); - jdbc.update(INSERT_EVENT, event.tenantId(), event.sessionId(), - event.sequence(), event.eventId(), event.turnId(), - event.type(), writeJson(event.data()), event.terminal(), - event.sourceKey(), event.createdAt(), event.schemaVersion(), - event.projectionVersion(), event.itemId(), - event.contentPartId()); - publishAfterCommit(List.of(event)); - return event; - } - - // Emptied deltas name nothing, and a delta that continued one now starts - // a Part of its own, so identities from the first emptied event on are - // derived again as the rebuilt Items will name them. - private void reassignIdentity(String tenantId, String sessionId, - long fromSequence) { - List events = - jdbc.query( - "SELECT " + ManagedSchemaProjection.PUBLIC_EVENT.columns() + " FROM ma_public_event WHERE stream_kind = 'SESSION' AND stream_id" - + " = X'' AND tenant_id = ? AND session_id = ? AND sequence_id >= ?" - + " ORDER BY sequence_id ASC", - eventMapper, - tenantId, - sessionId, - fromSequence - 1); - Identity previous = null; - for (EventRecord event : events) { - if (event.sequence() < fromSequence) { - previous = new Identity(event.type(), event.itemId(), - event.contentPartId()); - continue; - } - Identity identity = EventIdentity.of(event.type(), - event.turnId(), event.sequence(), event.data(), previous); - if (!Objects.equals(identity.itemId(), event.itemId()) - || !Objects.equals(identity.contentPartId(), - event.contentPartId())) { - jdbc.update( - "UPDATE ma_public_event SET item_id = ?, content_part_id = ? WHERE" - + " tenant_id = ? AND session_id = ? AND stream_kind = 'SESSION' AND" - + " stream_id = X'' AND sequence_id = ?", - identity.itemId(), - identity.contentPartId(), - tenantId, - sessionId, - event.sequence()); - } - previous = identity; - } - } - - // Only a text delta continues the event before it, so other events skip - // the lookup. - private Identity findIdentity(String tenantId, String sessionId, - long sequence, String nextType) { - if (!EventIdentity.continuesPrevious(nextType)) { - return null; - } - List rows = - jdbc.query( - "SELECT event_type, item_id, content_part_id FROM ma_public_event WHERE" - + " stream_kind = 'SESSION' AND stream_id = X'' AND tenant_id = ? AND" - + " session_id = ? AND sequence_id = ?", - (result, row) -> - new Identity( - result.getString("event_type"), - result.getString("item_id"), - result.getString("content_part_id")), - tenantId, - sessionId, - sequence); - return rows.isEmpty() ? null : rows.getFirst(); - } - - private void publishAfterCommit(List events) { - if (events.isEmpty()) { - return; - } - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - eventPublisher.publish(events); - return; - } - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - eventPublisher.publish(events); - } - }); - } - - private boolean hasActiveTurn(String tenantId, String sessionId) { - Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM" - + " ma_turn WHERE tenant_id = ? AND" - + " session_id = ? AND status IN" - + " ('ACCEPTED', 'RUNNING', 'CANCELLING')", - Integer.class, tenantId, sessionId); - return count != null && count > 0; - } - - private boolean hasEventType(String tenantId, String sessionId, - String turnId, String type) { - Integer count = - jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND turn_id =" - + " ? AND event_type = ?", - Integer.class, - tenantId, - sessionId, - turnId, - type); - return count != null && count > 0; - } - - private boolean hasSourceEvent(String tenantId, String sessionId, - String sourceKey) { - Integer count = - jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_public_event WHERE stream_kind = 'SESSION' AND" - + " stream_id = X'' AND tenant_id = ? AND session_id = ? AND source_key" - + " = ?", - Integer.class, - tenantId, - sessionId, - sourceKey); - return count != null && count > 0; - } - - private String writeJson(Object value) { - try { - return objectMapper.writeValueAsString(value); - } catch (JsonProcessingException error) { - throw new IllegalArgumentException("Value is not valid JSON", - error); - } - } - - private List> readInput(String value) { - try { - return objectMapper.readValue(value, INPUT_TYPE); - } catch (JsonProcessingException error) { - throw new IllegalStateException("Stored input is invalid", error); - } - } - - private Map readMap(String value) { - try { - return objectMapper.readValue(value, MAP_TYPE); - } catch (JsonProcessingException error) { - throw new IllegalStateException("Stored event is invalid", error); - } + return sessionQueries.requireSession(tenantId, sessionId); } public static ContextBinding readBinding(java.sql.ResultSet result) throws java.sql.SQLException { - String workspaceId = result.getString("workspace_id"); - if (workspaceId == null) { - return null; - } - String configRef = result.getString("workspace_config_ref"); - String policyRef = result.getString("workspace_policy_ref"); - String contextConfigRef = result.getString("context_config_ref"); - if (!ManagedWorkspaceRegistry.descriptorRef(configRef, policyRef) - .equals(contextConfigRef)) { - throw new IllegalStateException( - "Persisted Workspace configuration descriptor changed for session " - + result.getString("session_id") + " of tenant " - + result.getString("tenant_id")); - } - return new ContextBinding(result.getString("tenant_id"), workspaceId, - result.getLong("workspace_generation"), - result.getString("workspace_storage_id"), - result.getString("cwd_relative"), contextConfigRef, - result.getLong("context_revision")); + return AgentStoreRows.readBinding(result); } - private static Long nullableLong(java.sql.ResultSet result, String name) - throws java.sql.SQLException { - long value = result.getLong(name); - return result.wasNull() ? null : value; - } - - // The cwd columns arrive with V46; an operation read against an - // additive-upgrade schema that predates them must treat the columns as - // absent instead of erroring the whole query. - private static String additiveString(java.sql.ResultSet result, - String name) throws java.sql.SQLException { - return hasColumn(result, name) ? result.getString(name) : null; - } - - private static Long additiveLong(java.sql.ResultSet result, String name) - throws java.sql.SQLException { - return hasColumn(result, name) ? nullableLong(result, name) : null; - } - - private static int additiveInt(java.sql.ResultSet result, String name) - throws java.sql.SQLException { - return hasColumn(result, name) ? result.getInt(name) : 0; - } - - private static boolean hasColumn(java.sql.ResultSet result, String name) - throws java.sql.SQLException { - var meta = result.getMetaData(); - for (int column = 1; column <= meta.getColumnCount(); column++) { - if (name.equalsIgnoreCase(meta.getColumnLabel(column))) { - return true; - } - } - return false; - } - - private static String publicId(String prefix) { - return prefix + "_" + UUID.randomUUID().toString() - .replace("-", ""); - } - - private record StreamCounters(long lastSequence, long floorSequence) {} - - private static String placeholders(int count) { - return String.join(", ", Collections.nCopies(count, "?")); - } - - private record ItemRow(String tenantId, String sessionId, String itemId, - String turnId, String type, String role, String status, - Map attributes, long firstSequence, - long lastSequence, long createdAt, long updatedAt, - long revision, List content) { - private ItemRecord toRecord() { - return new ItemRecord(tenantId, sessionId, itemId, turnId, type, - role, status, attributes, firstSequence, lastSequence, - createdAt, updatedAt, revision, content); - } - } @Override @Transactional public Admission insertSessionCommand(String tenantId, String operation, diff --git a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java index 9aaed3722c1..afa901b7bf9 100644 --- a/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java +++ b/packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java @@ -1,18 +1,11 @@ package com.alibaba.qwen.code.managedagent.persistence; import com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore; -import com.alibaba.qwen.code.managedagent.contracts.catalog.AgentDefinitionExtensions; import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedConfigInstall; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionProjection; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedExtensionRecords; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHarnessReleaseRecords; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedHostedRecoveryRecords; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.AcquireWriterRequest; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.BlockRecoveryRequest; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitReceipt; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.PublishToolResultRequest; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.RecoveryStateReceipt; @@ -21,551 +14,131 @@ import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionSt import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.SealReceipt; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.SealWriterRequest; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredResource; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.StoredTransaction; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.ToolResultResourceRef; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.TransactionPage; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.WriterGrant; -import com.alibaba.qwen.code.managedagent.contracts.persistence.ToolPublicationContract; -import com.alibaba.qwen.code.managedagent.contracts.persistence.WorkspaceCsiApplicationJson; import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy; import com.alibaba.qwen.code.managedagent.contracts.session.SessionQueryStore; import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; -import com.alibaba.qwen.code.managedagent.model.RequestDigests; -import com.alibaba.qwen.code.runtimebroker.ManagedCheckpointHistory; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; -import com.alibaba.qwen.code.runtimebroker.WorkspaceCsiRuntimeIdentity; -import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaProjection; -import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchemaTime; -import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiActivationAdmission; import com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiFilesRetirementGuard; import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.apache.commons.codec.digest.DigestUtils; -import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.dao.DeadlockLoserDataAccessException; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.dao.PessimisticLockingFailureException; -import org.springframework.dao.QueryTimeoutException; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; -import java.io.IOException; -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.nio.charset.CharacterCodingException; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Timestamp; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Objects; -import java.util.Set; import java.util.function.Supplier; -import java.util.regex.Pattern; @Repository @Qualifier("managedAgentTransactionManager") public class ManagedSessionStore implements com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore { - private com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules harnessRules; - @org.springframework.beans.factory.annotation.Autowired - public void configureHarnessRules(com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules value) { harnessRules = value; } - - private com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion = - com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy.inProcess(); - @org.springframework.beans.factory.annotation.Autowired - public void configureReaderVersion(com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy value) { - readerVersion = value; - } - - private static final int STORAGE_VERSION = 1; - private static final ObjectMapper CSI_JSON = new ObjectMapper(); - private static final RequestDigests INITIAL_DIGESTS = new RequestDigests(); - private static final ObjectMapper CSI_TERMINAL_JSON = CSI_JSON.copy().enable(com.fasterxml.jackson.core.JsonParser.Feature.STRICT_DUPLICATE_DETECTION) - .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES) - .disable(com.fasterxml.jackson.databind.MapperFeature.ALLOW_COERCION_OF_SCALARS) - .disable(com.fasterxml.jackson.databind.DeserializationFeature.ACCEPT_FLOAT_AS_INT); - private static final int MAX_ID_BYTES = 512; - private static final int MAX_TEXT_BYTES = 4096; - // Hot paths touch the tenant-wide guard rows only on admission, and - // never wait on them past this per-statement bound: a held row degrades - // to a named, retryable failure instead of out-waiting the caller's own - // request budget (#13800). - static final int GUARD_ACQUIRE_TIMEOUT_SECONDS = 5; - private static final Pattern DIGEST = Pattern.compile("^[0-9a-f]{64}$"); - private static final Pattern WRITER_TOKEN = Pattern.compile( - "^[A-Za-z0-9_-]{" - + ManagedSessionStoreModels.MIN_WRITER_TOKEN_LENGTH + "," - + ManagedSessionStoreModels.MAX_WRITER_TOKEN_LENGTH + "}$"); - private static final Set HEAD_STATES = Set.of( - "ACTIVE", "SEALED", "DELETING", "DELETED"); - private static final Set RECOVERY_STATES = Set.of( - "READY", "BLOCKED_RESOURCE", "BLOCKED_WORKSPACE", - "BLOCKED_EXECUTION"); - private final JdbcTemplate jdbc; - private ToolPublicationObjectStore publicationObjects; - private ManagedToolResultStore toolResults; - private JournalEventTransport journalEventTransport; - private WriterCredentialPolicy credentials = WriterCredentialPolicy.unbound(); - private JdbcRuntimeBindingRepository csiBindings; - private final ManagedExtensionRecordStore extensionRecords; - private final ManagedActionStore actions; - private final ToolPublicationRetentionStore outputRetention; - private final org.springframework.transaction.support.TransactionTemplate ownerReads; - private final RowMapper headMapper = (result, row) -> - new HeadRow(result.getString("tenant_id"), - result.getString("workspace_id"), - result.getString("session_id"), - result.getString("state"), - result.getInt("storage_version"), - result.getLong("writer_generation"), - result.getString("writer_id"), - ManagedSchemaTime.timestamp(result, "writer_lease_until"), - result.getString("lease_token_hash"), - result.getLong("journal_revision"), - result.getLong("committed_sequence"), - result.getString("last_commit_digest"), - result.getLong("activation_epoch"), - result.getString("latest_checkpoint_resource_id"), - result.getLong("compacted_through_revision"), - result.getString("recovery_status"), - result.getString("recovery_detail_code"), - result.getString("activation_id"), - result.getString("activation_phase"), - result.getObject("activation_event_epoch", Long.class), - result.getObject("activation_expires_at", Long.class), - result.getObject("activation_head_revision", Long.class)); - private final RowMapper transactionMapper = - (result, row) -> transactionRow(result); - private final RowMapper resourceMapper = - (result, row) -> - new ResourceRow( - result.getString("tenant_id"), - result.getString("workspace_id"), - result.getString("session_id"), - result.getString("resource_id"), - result.getString("kind"), - result.getInt("schema_version"), - result.getLong("byte_length"), - result.getString("sha256"), - result.getString("storage_kind"), - result.getBytes("inline_bytes"), - result.getString("object_key"), - result.getString("object_version_id"), - result.getString("encryption_key_id"), - result.getString("state"), - result.getString("capture_state"), - result.getString("deletion_operation_key")); + private final JournalStoreRows storeRows; + private final JournalFencing fencing; + private final JournalHistoryQueries historyQueries; + private final JournalLifecycleAuthorization lifecycleAuthorization; + private final JournalResourceReads resourceReads; + private final JournalResourceWrites resourceWrites; + private final JournalWriterLifecycle writerLifecycle; + private final JournalCommit commits; public ManagedSessionStore(JdbcTemplate jdbc) { this(jdbc, new ManagedExtensionRecordStore(jdbc)); } - public ManagedSessionStore(JdbcTemplate jdbc, - ManagedExtensionRecordStore extensionRecords) { + public ManagedSessionStore(JdbcTemplate jdbc, ManagedExtensionRecordStore extensionRecords) { this(jdbc, extensionRecords, null); } - @Autowired - public ManagedSessionStore( - @Qualifier("managedJdbcTemplate") JdbcTemplate jdbc, - ManagedExtensionRecordStore extensionRecords, + public ManagedSessionStore(JdbcTemplate jdbc, ManagedExtensionRecordStore extensionRecords, ManagedActionStore actions) { - this.jdbc = jdbc; - this.extensionRecords = extensionRecords; - this.actions = actions; - var manager = - new org.springframework.jdbc.datasource.DataSourceTransactionManager(jdbc.getDataSource()); - this.outputRetention = new ToolPublicationRetentionStore(jdbc, manager); - this.ownerReads = new org.springframework.transaction.support.TransactionTemplate(manager); - this.ownerReads.setTimeout(10); + this(jdbc, extensionRecords, actions, null, + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy.inProcess(), + null, null, null, null, null, WriterCredentialPolicy.unbound(), null); } - private WorkspaceExecutionStore lifecycleExecution; - private SessionQueryStore lifecycleSessions; - - @Autowired(required = false) - public void setLifecycleExecution(WorkspaceExecutionStore execution, SessionQueryStore sessions) { - this.lifecycleExecution = execution; - this.lifecycleSessions = sessions; + @Autowired + public ManagedSessionStore(@Qualifier("managedJdbcTemplate") JdbcTemplate jdbc, + ManagedExtensionRecordStore extensionRecords, ManagedActionStore actions, + com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules harnessRules, + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy readerVersion, + @org.springframework.lang.Nullable WorkspaceExecutionStore lifecycleExecution, + @org.springframework.lang.Nullable SessionQueryStore lifecycleSessions, + @org.springframework.lang.Nullable ToolPublicationObjectStore publicationObjects, + @org.springframework.lang.Nullable ManagedToolResultStore toolResults, + @org.springframework.lang.Nullable JournalEventTransport journalEventTransport, + WriterCredentialPolicy credentials, + @org.springframework.lang.Nullable RuntimeBindingRepository bindings) { + if (lifecycleExecution == null || lifecycleSessions == null) { + lifecycleExecution = null; + lifecycleSessions = null; + } + JdbcRuntimeBindingRepository csiBindings = + bindings instanceof JdbcRuntimeBindingRepository original + && original.usesDataSource(jdbc.getDataSource()) + ? original + : null; + var manager = new org.springframework.jdbc.datasource.DataSourceTransactionManager(jdbc.getDataSource()); + ToolPublicationRetentionStore outputRetention = new ToolPublicationRetentionStore(jdbc, manager); + var ownerReads = new org.springframework.transaction.support.TransactionTemplate(manager); + ownerReads.setTimeout(10); + this.storeRows = new JournalStoreRows(readerVersion, jdbc); + this.fencing = new JournalFencing(storeRows, jdbc, journalEventTransport, credentials, csiBindings); + this.historyQueries = new JournalHistoryQueries(fencing, storeRows, jdbc); + this.lifecycleAuthorization = new JournalLifecycleAuthorization(fencing, storeRows, harnessRules, jdbc, extensionRecords, lifecycleExecution, lifecycleSessions); + this.resourceReads = new JournalResourceReads(fencing, storeRows, jdbc, publicationObjects, outputRetention, ownerReads); + this.resourceWrites = new JournalResourceWrites(fencing, storeRows, jdbc); + this.writerLifecycle = new JournalWriterLifecycle(fencing, storeRows, readerVersion, jdbc, credentials, csiBindings); + this.commits = new JournalCommit(fencing, lifecycleAuthorization, resourceWrites, storeRows, readerVersion, jdbc, toolResults, journalEventTransport, extensionRecords, actions); } + static final int GUARD_ACQUIRE_TIMEOUT_SECONDS = JournalFencing.GUARD_ACQUIRE_TIMEOUT_SECONDS; + @Transactional public void authorizeLifecycle(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.AuthorizeLifecycleRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - ensureSessionRoot(tenantId, sessionId); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - jdbc.queryForList( - "SELECT session_id FROM ma_session WHERE tenant_id = ? AND session_id = ? FOR" - + " UPDATE", - tenantId, - sessionId); - WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); - if (request.kind() != null) { - String kind = - jdbc.queryForObject( - "SELECT operation_kind FROM ma_operation WHERE tenant_id = ?" - + " AND session_id = ? AND operation_id = ?", - String.class, - tenantId, - sessionId, - authority.operationId()); - if (!kind.toLowerCase(java.util.Locale.ROOT).equals(request.kind())) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_operation_conflict"); - } - } else if (!"DRAINING" - .equals( - jdbc.queryForObject( - "SELECT phase FROM ma_session_head WHERE tenant_key = ? AND" - + " harness_key = ? AND tenant_id = ? AND harness_session_id =" - + " ?", - String.class, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId))) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_hooks_unsettled"); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - if (request.kind() != null) { - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); - } else if (!List.of("ACTIVE", "SEALED").contains(head.state()) - || request.writerGeneration() != head.writerGeneration() - || !request.writerId().equals(head.writerId()) - || !secureEquals(tokenHash(writerToken), head.leaseTokenHash())) { - throw writerConflict(); - } + lifecycleAuthorization.authorizeLifecycle(tenantId, sessionId, writerToken, request, authority); } - private void checkLifecycleWriter(String tenantId, String sessionId, - com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - // Ordinary traffic fences per Session (head row, drain admission); - // taking the two tenant-wide rows here would let any long holder of - // them wedge every Session of the tenant (#13800). No v2 probe may - // run here: a plain consistent read pins the REPEATABLE READ snapshot - // before the guard waits below; a row-locking read inverts against - // the claim order (guards → operation) of a concurrently claiming - // lifecycle; and borrowing a second pooled connection deadlocks the - // pool under saturation. The fence - // that rejects ordinary writes against a v2 Session lives in - // requireOrdinaryAdmission, where the caller's own guards are already - // held and the same consistent read layer is established. - if (authority == null) { - return; - } - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); - WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, true); - } - - private static ManagedAgentException guardContended() { - return new ManagedAgentException(503, - "tenant_guard_contended", - "A tenant-wide Managed Session guard could not be acquired" - + " within the hot-path bound; retry."); - } - - /** - * Runs one sweep candidate in its own transaction. A guard refusal skips that candidate - * instead of aborting the sweep; any other failure propagates unchanged. - */ static T skipContended( org.springframework.transaction.support.TransactionTemplate transactions, org.springframework.transaction.support.TransactionCallback body) { - try { - return transactions.execute(body); - } catch (ManagedAgentException refusal) { - if (!"tenant_guard_contended".equals(refusal.getCode())) { - throw refusal; - } - return null; - } + return JournalFencing.skipContended(transactions, body); } static void lockBounded(Runnable lock) { - lockBoundedResult(() -> { - lock.run(); - return null; - }); + JournalFencing.lockBounded(lock); } static T lockBoundedResult(Supplier lock) { - try { - return lock.get(); - } catch (PessimisticLockingFailureException | QueryTimeoutException error) { - throw guardContended(); - } + return JournalFencing.lockBoundedResult(lock); } - /** - * The root-side half of {@link #lockBounded}'s mapping. A pinned CSI - * Session's continuation holds its root while it waits for the tenant - * guards, so a guard-first lane on that Session closes an InnoDB cycle - * when it reaches the root, and InnoDB may roll back either side - * (#13800 R1-1). The root acquisitions name that rollback the way the - * guard side does; an ordinary wait on the root stays unbounded and - * keeps its own failure. The default translator reports a deadlock - * (MySQL/MariaDB 1213, SQLState 40001) as CannotAcquireLockException, - * the error-code translator as DeadlockLoserDataAccessException, so the - * victim is recognized by its vendor code. - */ static T namedRootDeadlock(Supplier lock) { - try { - return lock.get(); - } catch (PessimisticLockingFailureException error) { - if (error instanceof DeadlockLoserDataAccessException || deadlockVictim(error)) { - throw guardContended(); - } - throw error; - } - } - - private static boolean deadlockVictim(Throwable error) { - for (Throwable cause = error; cause != null; cause = cause.getCause()) { - if (cause instanceof java.sql.SQLException sql && sql.getErrorCode() == 1213) { - return true; - } - } - return false; - } - - private void requirePhysicalSessionOpen(String tenantId, String workspaceId, String sessionId) { - // Placement is the tenant-wide row migration admission shares; on - // these per-turn paths it stays bounded with the shared named-failure - // mapping, so a held row answers tenant_guard_contended instead of - // out-waiting the caller (#13800). - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - WorkspaceMigrationAdmission.requirePhysicalOpen(jdbc, tenantId, - WorkspaceMigrationAdmission.sessionStorage(jdbc, tenantId, sessionId, workspaceId)); - } - - private void bindControlSessionStorage(String tenantId, String workspaceId, String sessionId) { - String storage = WorkspaceMigrationAdmission.sessionStorage(jdbc, tenantId, sessionId, workspaceId); - if (storage == null) { - return; - } - var rows = jdbc.queryForList("SELECT session_kind, workspace_id, workspace_storage_id FROM ma_session" - + " WHERE tenant_id = ? AND session_id = ? FOR UPDATE", tenantId, sessionId); - if (rows.size() != 1 || !"CONTROL".equals(rows.getFirst().get("session_kind"))) { - return; - } - var row = rows.getFirst(); - if (row.get("workspace_id") != null && !workspaceId.equals(row.get("workspace_id")) - || row.get("workspace_storage_id") != null && !storage.equals(row.get("workspace_storage_id"))) { - throw conflict("managed_session_scope_conflict", "The Managed Session Workspace binding conflicts."); - } - jdbc.update( - "UPDATE ma_session SET gmt_modified = " + ManagedSchemaTime.SQL_NOW + ", workspace_id = ?, workspace_storage_id = ? WHERE tenant_id =" - + " ? AND session_id = ? AND session_kind = 'CONTROL'", - workspaceId, storage, tenantId, sessionId); + return JournalFencing.namedRootDeadlock(lock); } @Transactional public void authorizeOrdinary(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - // Ordinary authorisation fences per Session (drain + head): the - // tenant-wide guard rows stay with lifecycle/publication paths, or - // any long holder of them wedges unrelated Sessions (#13800). The - // Session root is locked before the drain admission reads the head - // row, keeping the root-then-head order of every other writer. - ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - if (!"legacy-close".equals(request.kind()) || !WorkspaceLifecycleStore.legacyClose(jdbc, tenantId, sessionId)) { - requireOrdinaryAdmission(tenantId, sessionId); - } - if (request.kind() != null && request.kind().startsWith("configuration:")) { - String operation = request.kind().substring("configuration:".length()); - JsonNode current = ManagedMcpSettings.find(jdbc, tenantId, sessionId); - if (!ManagedConfigInstall.uuid(operation) || ManagedConfigStore.blocked(jdbc, tenantId, sessionId) - && (!ManagedConfigInstall.isPackageConfig(current) || !operation.equals(current.path("operationId").asText()))) { - throw conflict("session_config_recovery_required", "Only the original configuration operation may continue."); - } - } else { - ManagedConfigStore.requireOpen(jdbc, tenantId, sessionId); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); + lifecycleAuthorization.authorizeOrdinary(tenantId, sessionId, writerToken, request); } @Transactional public java.util.Map approvalSelection(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.AuthorizeLifecycleRequest request) { - if (request.kind() != null) { - throw invalid("Approval queries do not accept lifecycle authority."); - } - authorizeOrdinary(tenantId, sessionId, writerToken, request); - var rows = jdbc.queryForList("SELECT agent_id, agent_definition_digest, tool_profile, workspace_id," - + " approval_mode, selected_approval_mode, approval_revision, permission_rules_json FROM ma_session" - + " WHERE tenant_id=? AND session_id=? FOR UPDATE", tenantId, sessionId); - if (rows.size() != 1) { - throw conflict("managed_session_scope_conflict", "The Managed Session is missing."); - } - var row = rows.getFirst(); - String profile = (String) row.get("tool_profile"); - if (row.get("workspace_id") == null - || ("qwen-code".equals(row.get("agent_id")) - ? row.get("agent_definition_digest") != null : row.get("agent_definition_digest") == null) - || !(com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.FILES.equals(profile) - || com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.FILES_V3.equals(profile) - || com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.isPublicShell(profile))) { - return null; - } - String mode = (String) row.get("selected_approval_mode"); - if (mode == null) { - mode = (String) row.get("approval_mode"); - } - long revision = ((Number) row.get("approval_revision")).longValue(); - if (!com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceToolProfiles.supportsApprovalMode(mode) - || revision < 0 || revision > 9_007_199_254_740_991L) { - throw conflict("approval_selection_conflict", "The approval selection is invalid."); - } - java.util.Map result = new java.util.LinkedHashMap<>(java.util.Map.of("mode", mode, "revision", revision)); - if (row.get("permission_rules_json") != null) { - try { - var rules = harnessRules.nonEmptyPermissionRules(harnessRules.permissionRules( - CSI_JSON.readValue((String) row.get("permission_rules_json"), java.util.Map.class))); - if (rules != null) { - result.put("permissionRules", rules); - } - } catch (java.io.IOException | IllegalArgumentException invalid) { - throw conflict("approval_selection_conflict", "The permission rules are invalid."); - } - } - return result; - } - - // An ordinary write against a v2-claimed Session must fail with the - // claim's name, and the read that names it must never walk the three - // cliffs the earlier probes hit: no plain consistent read ahead of the - // guard waits in the calling path (it pins the snapshot before the retry - // learns its winner), no row lock ahead of the claim order (guards → - // operation), and no second pooled connection while this transaction - // holds its own (the pool self-deadlocks at saturation). - // Every call site invokes this only after its own guard or head row has - // been taken, so the consistent read layer the flow required is already - // established and the classification costs one lock-free session-scoped - // range scan on the caller's own connection. - private void requireNoLifecycleClaim(String tenantId, String sessionId) { - if (!jdbc.queryForList("SELECT operation_id FROM ma_operation WHERE scope_key = ?" - + " AND operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE') AND tenant_id = ? AND session_id = ?" - + " AND lifecycle_protocol_version = 2", com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId), tenantId, sessionId).isEmpty()) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_claim_fenced"); - } - } - - private void requireOrdinaryAdmission(String tenantId, String sessionId) { - requireNoLifecycleClaim(tenantId, sessionId); - if (lifecycleFenced(tenantId, sessionId)) { - throw conflict( - "managed_session_lifecycle_active", "Ordinary execution admission is closed."); - } - } - - private boolean lifecycleFenced(String tenantId, String sessionId) { - return !jdbc.queryForList( - "SELECT phase FROM ma_session_head WHERE phase <> 'OPEN' AND tenant_key = ?" - + " AND harness_key = ? AND tenant_id = ? AND harness_session_id = ?" - + " FOR UPDATE", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId) - .isEmpty(); - } - - private boolean lifecycleProtocolFenced(String tenantId, String sessionId) { - return !jdbc.queryForList( - "SELECT operation_id FROM ma_session_head WHERE tenant_key = ? AND" - + " harness_key = ? AND tenant_id = ? AND harness_session_id = ? AND" - + " operation_id IS NOT NULL FOR UPDATE", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId) - .isEmpty(); + return lifecycleAuthorization.approvalSelection(tenantId, sessionId, writerToken, request); } boolean usesDataSource(javax.sql.DataSource source) { - return source != null && jdbc.getDataSource() == source; - } - - @Autowired(required = false) - public void setPublicationObjects(ToolPublicationObjectStore publicationObjects) { - this.publicationObjects = publicationObjects; - } - - @Autowired(required = false) - public void setToolResults(ManagedToolResultStore toolResults) { - this.toolResults = toolResults; - } - - // Present only when the journal event transport is enabled; absent by - // default so a flag-off deployment stays byte-identical (design MQ2b). - @Autowired(required = false) - public void setJournalEventTransport(JournalEventTransport transport) { - this.journalEventTransport = transport; - } - - // The policy bean is unconditional, so required injection fails closed - // in a Spring context; direct constructor use keeps the unbound default. - @Autowired - public void setCredentials(WriterCredentialPolicy credentials) { - this.credentials = credentials; - } - - @Autowired(required = false) - public void setCsiBindings(RuntimeBindingRepository bindings) { - this.csiBindings = - bindings instanceof JdbcRuntimeBindingRepository original - && original.usesDataSource(jdbc.getDataSource()) - ? original - : null; - } - - // A bound credential is checked before any state lookup so a foreign - // token never learns whether a Session exists. - private void requireCredential(String tenantId, String workspaceId, - String sessionId, String writerToken) { - credentials.require(tenantId, workspaceId, sessionId, writerToken); - } - - private HarnessPlacementStore.Placement requirePlacement(String tenant, String workspace, - String session, String token, String writer, boolean newWork) { - return HarnessPlacementStore.requireCurrent(jdbc, credentials, tenant, workspace, session, token, writer, newWork); + return storeRows.usesDataSource(source); } record PublicationWriter(long now, long leaseUntil, long journalRevision, @@ -576,1393 +149,119 @@ public class ManagedSessionStore RestoreHead lockSnapshotWriter(String tenant, String session, String token, ManagedNativeJournalSnapshots.PublishRequest request) { - validateScope(tenant, request.workspaceId(), session); - requireCredential(tenant, request.workspaceId(), session, token); - validateStableId(request.writerId(), "writerId"); - validateStableId(request.snapshotId(), "snapshotId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - validateCounter(request.expectedJournalRevision(), "expectedJournalRevision", 1); - validateCounter(request.expectedCommittedSequence(), "expectedCommittedSequence", 0); - validateDigest(request.expectedLastCommitDigest(), "expectedLastCommitDigest", true); - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenant, GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockPublicationWriter(tenant, request.workspaceId(), session, request.writerId(), request.writerGeneration(), token); - requireOrdinaryAdmission(tenant, session); - HeadRow locked = requireHeadForUpdate(tenant, session); - RestoreHead head = restoreHead(locked); - if (!"READY".equals(head.recoveryStatus())) { - throw conflict("managed_session_recovery_blocked", "The Session is blocked from snapshot publication."); - } - return head; + return writerLifecycle.lockSnapshotWriter(tenant, session, token, request); } PublicationWriter lockPublicationWriter(String tenant, String workspace, String session, String writer, long generation, String token) { - validateScope(tenant, workspace, session); - requireCredential(tenant, workspace, session, token); - ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenant, session); - requirePhysicalSessionOpen(tenant, workspace, session); - // Publication-lane writer: keeps the tenant CAS row before the - // Session root the CSI check locks and before the head row, the - // order retention reads take (CAS row, root, head) and the same as - // before #13800 decoupled ordinary traffic. - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenant, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - requirePlacement(tenant, workspace, session, token, writer, false); - var continuation = lockCsiContinuation(tenant, session); - var csiOriginal = continuation == null ? null : continuation.original(); - if (csiOriginal != null) { - csiOriginal.requireAdmission(); - } - HeadRow head = requireHeadForUpdate(tenant, session); - requireHeadScope(head, tenant, workspace, session); - Timestamp now = databaseNow(); - requireWriter(head, writer, generation, token, now, true); - requireInstalledCsiWriter(csiOriginal); - return new PublicationWriter(epochMillis(now), epochMillis(head.writerLeaseUntil()), - head.journalRevision(), head.committedSequence(), head.activationEpoch(), - head.latestCheckpointResourceId(), head.recoveryStatus(), head.activationId(), - head.activationPhase(), head.activationEventEpoch(), head.activationExpiresAt(), - head.activationHeadRevision()); + return writerLifecycle.lockPublicationWriter(tenant, workspace, session, writer, generation, token); } @Transactional public WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, AcquireWriterRequest request) { - return acquireWriter(tenantId, sessionId, writerToken, request, null); + return writerLifecycle.acquireWriter(tenantId, sessionId, writerToken, request); } @Transactional public WriterGrant acquireWriter(String tenantId, String sessionId, String writerToken, AcquireWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - checkLifecycleWriter(tenantId, sessionId, authority); - validateStableId(request.writerId(), "writerId"); - validateLeaseMillis(request.leaseMillis()); - // Acquisition stays serialized with an in-flight delete/retire: while - // a Session has no head row, the tenant CAS row is the only ordering - // point, so it stays. Both acquisitions run in the codebase-wide - // placement-before-publication order, bounded — a held row degrades - // to a named failure instead of a silent wedge or a 1213 (#13800). - // Placement must precede the drain admission check below: begin paths - // insert drain rows only after locking placement, so checking the - // drain first would take its gap lock and deadlock against a - // lifecycle that already holds placement (#13800). - // requireSessionOpen must run after the CAS guard: it is this - // transaction's first consistent read, so its REPEATABLE READ - // snapshot only sees a retirement committed underneath the CAS wait - // if it is pinned after that wait (#13800 R2). - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, - tenantId, GUARD_ACQUIRE_TIMEOUT_SECONDS)); - var csiOriginal = lockCsiOriginal(tenantId, sessionId); - if (csiOriginal != null) { - csiOriginal.requireAdmission(); - } - WorkspaceMigrationAdmission.requireSessionOpen(jdbc, tenantId, sessionId, request.workspaceId()); - ensureSessionRoot(tenantId, sessionId); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), true); - bindControlSessionStorage(tenantId, request.workspaceId(), sessionId); - // Materialize the key before any locking read: missing-key RR locks can - // deadlock independent tenants when both first writers insert a head. - jdbc.update("INSERT IGNORE INTO ma_session_head (tenant_key, harness_key, tenant_id, harness_session_id, session_id, head_kind, phase, gmt_create, gmt_modified) VALUES (?, ?, ?, ?, ?, 'HARNESS', 'OPEN', " + ManagedSchemaTime.SQL_NOW + ", " + ManagedSchemaTime.SQL_NOW + ")", - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, sessionId, sessionId); - if (authority != null) { - WorkspaceLifecycleStore.requireClaim(jdbc, tenantId, sessionId, authority, - WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority)); - } else { - requireOrdinaryAdmission(tenantId, sessionId); - } - ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); - List closed = jdbc.query( - "SELECT status FROM ma_session WHERE tenant_id = ? AND session_id = ? AND" - + " (workspace_id IS NOT NULL OR session_kind = 'PUBLIC') FOR UPDATE", - (row, index) -> row.getString("status"), tenantId, sessionId); - if (authority == null && !closed.isEmpty() && !"ACTIVE".equals(closed.getFirst())) { - throw conflict("managed_session_not_writable", "The Session is closing or closed."); - } - String tokenHash = tokenHash(writerToken); - readerVersion.requireReadable(1); - Timestamp createdAt = databaseNow(); - Timestamp initialLeaseUntil = plusMillis(createdAt, - request.leaseMillis()); - List kinds = - jdbc.queryForList( - "SELECT head_kind FROM ma_session_head WHERE tenant_key = ? AND harness_key" - + " = ? AND tenant_id = ? AND session_id = ? FOR UPDATE", - String.class, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - if (!kinds.isEmpty() && "HARNESS".equals(kinds.getFirst())) { - jdbc.update( - "UPDATE ma_session_head SET head_kind = 'MANAGED_SESSION', workspace_id = ?, storage_version = ?, state = 'ACTIVE', writer_generation = 1, writer_id = ?, writer_lease_until = ?, lease_token_hash = ?, journal_revision = 0, wake_head_revision = 0, cleanup_head_revision = 0, committed_sequence = 0, activation_epoch = 0, compacted_through_revision = 0, recovery_status = 'READY', gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND head_kind = 'HARNESS'", - request.workspaceId(), - STORAGE_VERSION, - request.writerId(), - initialLeaseUntil.getTime(), - tokenHash, - createdAt.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return new WriterGrant(1, epochMillis(initialLeaseUntil), 0, 0, null, 0, false); - } - try { - jdbc.update( - "INSERT INTO ma_session_head (tenant_key, harness_key, harness_session_id, head_kind, tenant_id, workspace_id, session_id, storage_version, state, writer_generation, writer_id, writer_lease_until, lease_token_hash, journal_revision, wake_head_revision, cleanup_head_revision, committed_sequence, activation_epoch, compacted_through_revision, recovery_status, gmt_create, gmt_modified) VALUES (?, ?, ?, 'MANAGED_SESSION', ?, ?, ?, ?, 'ACTIVE', 1, ?, ?, ?, 0, 0, 0, 0, 0, 0, 'READY', ?, ?)", - com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository - .harnessDrainKey(tenantId), - com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository - .harnessDrainKey(sessionId), - sessionId, - tenantId, - request.workspaceId(), - sessionId, - STORAGE_VERSION, - request.writerId(), - initialLeaseUntil.getTime(), - tokenHash, - createdAt.getTime(), - createdAt.getTime()); - return new WriterGrant(1, epochMillis(initialLeaseUntil), 0, 0, - null, 0, false); - } catch (DuplicateKeyException ignored) { - // A prior acquire created the row; lock and inspect it below. - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - requireCsiWriter(csiOriginal, head); - if ("DELETING".equals(head.state()) - || "DELETED".equals(head.state())) { - throw conflict("managed_session_not_writable", - "The Managed Session does not accept a writer."); - } - if (!"ACTIVE".equals(head.state()) - && !"SEALED".equals(head.state())) { - throw conflict("managed_session_not_writable", - "The Managed Session state is not writable."); - } - Timestamp now = databaseNow(); - if ("ACTIVE".equals(head.state()) - && head.writerLeaseUntil() != null - && head.writerLeaseUntil().after(now)) { - if (!request.writerId().equals(head.writerId()) - || !secureEquals(tokenHash, head.leaseTokenHash())) { - throw writerConflict(); - } - Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), - plusMillis(now, request.leaseMillis())); - jdbc.update( - "UPDATE ma_session_head SET writer_lease_until = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", - leaseUntil.getTime(), - now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return grant(head, leaseUntil, true); - } - long generation = increment(head.writerGeneration(), - "writer generation"); - if (csiOriginal != null) { - if (!"ACTIVE".equals(head.state()) || head.storageVersion() != STORAGE_VERSION - || head.compactedThroughRevision() != 0 || !"READY".equals(head.recoveryStatus()) - || head.writerLeaseUntil() == null || head.journalRevision() < 2 || head.activationEpoch() < 1) { - throw conflict("csi_original_writer_unavailable", "The original CSI writer cannot be replaced."); - } - requireCsiHistoryOwner(csiOriginal); - jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { - JdbcCsiActivationAdmission.qualifyColdWriter(connection, csiOriginal, csiBindings, - head.journalRevision(), head.committedSequence(), head.lastCommitDigest(), head.writerId(), - head.writerGeneration(), head.activationEpoch(), epochMillis(head.writerLeaseUntil()), - request.writerId()); - return null; - }); - now = databaseNow(); - } - Timestamp leaseUntil = plusMillis(now, request.leaseMillis()); - jdbc.update( - "UPDATE ma_session_head SET state = 'ACTIVE', writer_generation = ?, writer_id = ?, writer_lease_until = ?, lease_token_hash = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", - generation, - request.writerId(), - leaseUntil.getTime(), - tokenHash, - now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return new WriterGrant(generation, epochMillis(leaseUntil), - head.journalRevision(), head.committedSequence(), - head.lastCommitDigest(), head.activationEpoch(), false); + return writerLifecycle.acquireWriter(tenantId, sessionId, writerToken, request, authority); } @Transactional public WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, RenewWriterRequest request) { - return renewWriter(tenantId, sessionId, writerToken, request, null); + return writerLifecycle.renewWriter(tenantId, sessionId, writerToken, request); } @Transactional public WriterGrant renewWriter(String tenantId, String sessionId, String writerToken, RenewWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - checkLifecycleWriter(tenantId, sessionId, authority); - validateStableId(request.writerId(), "writerId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - validateLeaseMillis(request.leaseMillis()); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - var continuation = lockCsiContinuation(tenantId, sessionId); - var csiOriginal = continuation == null ? null : continuation.original(); - if (csiOriginal != null) { - csiOriginal.requireContinuation(); - } - HeadRow head = continuation == null ? requireHeartbeatHead(tenantId, sessionId) : requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - if (authority == null) { - requireNoLifecycleClaim(tenantId, sessionId); - } - requireCsiWriter(csiOriginal, head); - Timestamp now = databaseNow(); - boolean cleanup = authority != null && WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority); - // A resident cleanup writer may outlive its lease while the coordinator - // is down; its token and generation must still own the locked head. - requireWriter(head, request.writerId(), request.writerGeneration(), - writerToken, now, !cleanup); - Timestamp leaseUntil = laterOf(head.writerLeaseUntil(), - plusMillis(now, request.leaseMillis())); - requireCsiLease(continuation, epochMillis(head.writerLeaseUntil()), epochMillis(leaseUntil)); - jdbc.update( - "UPDATE ma_session_head SET writer_lease_until = ?, gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", - leaseUntil.getTime(), - now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - if (request.activation() != null) { - var activation = request.activation(); - validateStableId(activation.activationId(), "activationId"); - validateCounter(activation.epoch(), "activation epoch", 1); - validateCounter(activation.renewalSeq(), "activation renewalSeq", 1); - validateLeaseMillis(activation.leaseDurationMs()); - if (!"READY".equals(head.recoveryStatus()) || !"active".equals(head.activationPhase()) - || !Objects.equals(head.activationHeadRevision(), head.journalRevision()) - || !activation.activationId().equals(head.activationId()) - || !Objects.equals(head.activationEventEpoch(), activation.epoch())) { - throw conflict("managed_session_activation_conflict", "The activation is no longer current."); - } - var row = activationLeaseRow(head); - if (!request.writerId().equals(row.workerId()) - || activation.renewalSeq() < row.lease().renewalSeq() - || activation.renewalSeq() > row.lease().renewalSeq() + 1) { - throw conflict("managed_session_activation_conflict", "The activation renewal is stale or out of order."); - } - long until = row.lease().expiresAt(); - if (activation.renewalSeq() != row.lease().renewalSeq()) { - until = Math.max(until, plusMillis(now, activation.leaseDurationMs()).getTime()); - requireCsiLease(continuation, row.lease().expiresAt(), until); - jdbc.update("UPDATE ma_session_journal_tx SET activation_lease_until = ?, activation_renewal_seq = ?" - + " WHERE tenant_id = ? AND session_id = ? AND journal_revision = ?", - until, activation.renewalSeq(), tenantId, sessionId, row.revision()); - jdbc.update("UPDATE ma_session_head SET activation_expires_at = ?" - + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ?", - until, JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); - } - var lease = new ManagedSessionStoreModels.ActivationLease( - activation.activationId(), activation.epoch(), activation.renewalSeq(), until); - return new WriterGrant(head.writerGeneration(), epochMillis(leaseUntil), head.journalRevision(), - head.committedSequence(), head.lastCommitDigest(), head.activationEpoch(), false, - com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED, true, lease); - } - return grant(head, leaseUntil, false); + return writerLifecycle.renewWriter(tenantId, sessionId, writerToken, request, authority); } @Transactional public SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, SealWriterRequest request) { - return sealWriter(tenantId, sessionId, writerToken, request, null); + return writerLifecycle.sealWriter(tenantId, sessionId, writerToken, request); } @Transactional public SealReceipt sealWriter(String tenantId, String sessionId, String writerToken, SealWriterRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - checkLifecycleWriter(tenantId, sessionId, authority); - validateStableId(request.writerId(), "writerId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - if (lockCsiOriginal(tenantId, sessionId) != null) { - throw conflict( - "csi_finalize_required", - "The original CSI writer requires retirement finalization."); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - if (authority == null) { - requireNoLifecycleClaim(tenantId, sessionId); - } - if ("SEALED".equals(head.state()) - && request.writerGeneration() == head.writerGeneration() - && request.writerId().equals(head.writerId()) - && secureEquals(tokenHash(writerToken), - head.leaseTokenHash())) { - return new SealReceipt(head.writerGeneration(), "SEALED", true); - } - Timestamp now = databaseNow(); - requireWriter(head, request.writerId(), request.writerGeneration(), - writerToken, now, false); - jdbc.update( - "UPDATE ma_session_head SET state = 'SEALED', gmt_modified = ? WHERE tenant_key = ?" - + " AND harness_key = ? AND tenant_id = ? AND session_id = ?", - now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return new SealReceipt(head.writerGeneration(), "SEALED", false); + return writerLifecycle.sealWriter(tenantId, sessionId, writerToken, request, authority); } @Transactional public void quiesceOwnership(String tenant, String session, String token, ManagedSessionStoreModels.QuiesceOwnershipRequest request) { - validateScope(tenant, request.workspaceId(), session); - requireCredential(tenant, request.workspaceId(), session, token); - credentials.requirePlacement(tenant, request.workspaceId(), session, request.instanceId(), request.bootId(), - request.placementEpoch(), token); - requirePlacement(tenant, request.workspaceId(), session, token, request.writerId(), false); - HeadRow head = requireHeadForUpdate(tenant, session); - requireHeadScope(head, tenant, request.workspaceId(), session); - requireWriter(head, request.writerId(), request.writerGeneration(), token, databaseNow(), true); - jdbc.update("UPDATE ma_session SET harness_placement_state = 'QUIESCING' WHERE tenant_id = ? AND session_id = ?", tenant, session); + writerLifecycle.quiesceOwnership(tenant, session, token, request); } @Transactional public JsonNode releaseOwnership(String tenantId, String sessionId, String writerToken, ManagedSessionStoreModels.ReleaseOwnershipRequest request) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, writerToken); - validateStableId(request.operationId(), "operationId"); - validateStableId(request.writerId(), "writerId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - validateCounter(request.journalRevision(), "journalRevision", 1); - validateCounter(request.placementEpoch(), "placementEpoch", 1); - if (request.commitProof() == null || !request.bootId().equals(request.writerId())) { - throw invalid("Ownership release requires the original boot and committed prefix."); - } - validateDigest(request.commitProof().committedPrefixHash(), "committedPrefixHash", false); - credentials.requirePlacement(tenantId, request.workspaceId(), sessionId, - request.instanceId(), request.bootId(), request.placementEpoch(), writerToken); - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, GUARD_ACQUIRE_TIMEOUT_SECONDS)); - ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); - String recordId = HarnessPlacementStore.releaseId(request.placementEpoch()); - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - String recordKey = ManagedExtensionProjection.recordKey(sessionId, HarnessPlacementStore.RELEASE_DOMAIN, recordId); - ObjectNode receipt = CSI_JSON.createObjectNode(); - receipt.put("format", "qwen-hosted-harness-release/1"); - receipt.put("tenantId", tenantId); - receipt.put("workspaceId", request.workspaceId()); - receipt.put("sessionId", sessionId); - receipt.put("operationId", request.operationId()); - receipt.put("instanceId", request.instanceId()); - receipt.put("bootId", request.bootId()); - receipt.put("placementEpoch", request.placementEpoch()); - receipt.put("writerGeneration", request.writerGeneration()); - receipt.put("journalRevision", request.journalRevision()); - receipt.put("committedSequence", request.commitProof().lastCommitSequence()); - receipt.set("commitProof", CSI_JSON.valueToTree(request.commitProof())); - receipt.put("state", "RELEASED"); - // Normalize integer nodes to the representation read back from the stored JSON. - receipt = (ObjectNode) com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(receipt.toString()); - var saved = jdbc.query("SELECT record_resource_id FROM ma_session_record WHERE session_scope_key = ?" - + " AND record_key = ? AND tenant_id = ? AND session_id = ? AND domain = ? AND record_kind = 'DOMAIN'", - (row, index) -> row.getString(1), scope, recordKey, tenantId, sessionId, HarnessPlacementStore.RELEASE_DOMAIN); - if (!saved.isEmpty()) { - var resource = findResource(scope, saved.getFirst()); - if (resource == null) { - throw HarnessPlacementStore.blocked("harness_release_receipt_missing"); - } - verifyStoredResource(resource); - JsonNode original = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); - if (!receipt.equals(original)) { - throw HarnessPlacementStore.blocked("harness_release_operation_conflict"); - } - return original; - } - var placement = requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - if (placement == null || !"QUIESCING".equals(placement.state())) { - throw HarnessPlacementStore.blocked("harness_placement_not_quiescing"); - } - requireNoLifecycleClaim(tenantId, sessionId); - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - Timestamp now = databaseNow(); - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, now, true); - if (head.journalRevision() != request.journalRevision() - || head.committedSequence() != request.commitProof().lastCommitSequence() - || !Objects.equals(head.lastCommitDigest(), request.commitProof().committedPrefixHash()) - || !"released".equals(head.activationPhase()) - || !Objects.equals(head.activationHeadRevision(), head.journalRevision())) { - throw HarnessPlacementStore.blocked("harness_release_proof_conflict"); - } - String resourceId = "harness-release:" + request.placementEpoch(); - byte[] bytes; - try { - bytes = CSI_JSON.writeValueAsBytes(receipt); - } catch (com.fasterxml.jackson.core.JsonProcessingException failure) { - throw new IllegalStateException("Ownership release receipt cannot be encoded.", failure); - } - ManagedHarnessReleaseRecords.require(receipt); - String resourceKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, resourceId); - jdbc.update("INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id, workspace_id, session_id," - + " resource_id, kind, schema_version, byte_length, sha256, storage_kind, inline_bytes, state, gmt_create, last_verified_at)" - + " VALUES (?, ?, ?, ?, ?, ?, 'managed-hosted_harness_release', 1, ?, ?, 'MYSQL_INLINE', ?, 'REFERENCED', ?, ?)", - resourceKey, scope, tenantId, request.workspaceId(), sessionId, resourceId, - bytes.length, sha256(bytes), bytes, now.getTime(), now.getTime()); - jdbc.update("INSERT INTO ma_session_record (session_scope_key, record_key, tenant_id, workspace_id, session_id," - + " domain, record_id, operation_hash, revision, record_resource_id, gmt_create, settled_at, first_sequence)" - + " VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)", scope, recordKey, tenantId, - request.workspaceId(), sessionId, HarnessPlacementStore.RELEASE_DOMAIN, recordId, sha256(request.operationId().getBytes(StandardCharsets.UTF_8)), - resourceId, now.getTime(), now.getTime(), head.committedSequence()); - jdbc.update("INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key, owner_revision, resource_key," - + " tenant_id, workspace_id, session_id, resource_id, gmt_create) VALUES (?, 'SESSION_RECORD', ?, 1, ?, ?, ?, ?, ?, ?)", - scope, recordKey, resourceKey, tenantId, request.workspaceId(), sessionId, resourceId, now.getTime()); - jdbc.update("UPDATE ma_session_head SET state = 'SEALED', gmt_modified = ? WHERE tenant_key = ? AND harness_key = ?" - + " AND tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); - jdbc.update("UPDATE ma_session SET harness_placement_state = 'RELEASED' WHERE tenant_id = ? AND session_id = ?", - tenantId, sessionId); - return receipt; + return writerLifecycle.releaseOwnership(tenantId, sessionId, writerToken, request); } @Transactional public RecoveryStateReceipt blockRecovery(String tenantId, String sessionId, String writerToken, BlockRecoveryRequest request) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - validateStableId(request.writerId(), "writerId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - if (!RECOVERY_STATES.contains(request.recoveryStatus()) - || "READY".equals(request.recoveryStatus())) { - throw invalid("recoveryStatus must be a blocked state."); - } - // The 128-character column width is enforced one layer up by - // @Size(max = 128) on BlockRecoveryRequest — byte-counting here - // would reject non-ASCII codes that legitimately fit VARCHAR(128). - validateText(request.recoveryDetailCode(), "recoveryDetailCode", - MAX_TEXT_BYTES); - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - var continuation = lockCsiContinuation(tenantId, sessionId); - var csiOriginal = continuation == null ? null : continuation.original(); - if (csiOriginal != null) { - csiOriginal.requireContinuation(); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - requireCsiWriter(csiOriginal, head); - Timestamp now = databaseNow(); - requireWriter(head, request.writerId(), request.writerGeneration(), - writerToken, now, true); - requireCsiWriterCurrent( - continuation, head, request.writerId(), request.writerGeneration(), writerToken); - if (request.recoveryStatus().equals(head.recoveryStatus()) - && request.recoveryDetailCode() - .equals(head.recoveryDetailCode())) { - return new RecoveryStateReceipt(head.writerGeneration(), - head.recoveryStatus(), head.recoveryDetailCode(), true); - } - if (!"READY".equals(head.recoveryStatus())) { - throw conflict( - ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT, - "The Managed Session already has a different recovery" - + " block."); - } - requireCsiWriterCurrent(continuation, head, request.writerId(), request.writerGeneration(), writerToken); - jdbc.update( - "UPDATE ma_session_head SET recovery_status = ?, recovery_detail_code = ?," - + " gmt_modified = ? WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" - + " AND session_id = ?", - request.recoveryStatus(), - request.recoveryDetailCode(), - now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return new RecoveryStateReceipt(head.writerGeneration(), - request.recoveryStatus(), request.recoveryDetailCode(), - false); + return writerLifecycle.blockRecovery(tenantId, sessionId, writerToken, request); } @Transactional public CommitReceipt commit(String tenantId, String sessionId, String writerToken, CommitTransactionRequest request) { - return commit(tenantId, sessionId, writerToken, request, null); + return commits.commit(tenantId, sessionId, writerToken, request); } @Transactional public CommitReceipt commit(String tenantId, String sessionId, String writerToken, CommitTransactionRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - checkLifecycleWriter(tenantId, sessionId, authority); - // Ordinary record commits still hold the placement guard while they - // fly: a begin/lifecycle admission serializes with only that row, so - // without it an admission sails through mid-commit. The acquisition - // stays bounded — a held row degrades to a named failure within the - // hot-path budget rather than waiting out the caller (#13800). - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - validateStableId(request.writerId(), "writerId"); - ValidatedCommit validated = validateCommit(request); - HarnessPlacementStore.Placement placement = requirePlacement(tenantId, request.workspaceId(), - sessionId, writerToken, request.writerId(), false); - boolean promotes = ManagedHostedRecoveryRecords.requiresVersionTwo(validated.recordBytes(), request.resources()) - || validated.resources().stream().anyMatch(resource -> { - if (!"managed-action-options".equals(resource.resource().kind()) || resource.bytes() == null) { - return false; - } - JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(resource.bytes(), StandardCharsets.UTF_8)); - return body != null && body.path("v").asInt() == 3; - }); - if (promotes) { - readerVersion.requireReadable(2); - } - var continuation = lockCsiContinuation(tenantId, sessionId); - var csiOriginal = continuation == null ? null : continuation.original(); - if (csiOriginal != null) { - csiOriginal.requireContinuation(); - } - HeadRow csiHead = csiOriginal == null ? null : requireHeadForUpdate(tenantId, sessionId); - requireCsiWriter(csiOriginal, csiHead); - if (csiOriginal != null - && ("commitFileHistory".equals(request.operation()) - || !jdbc.query( - "SELECT journal_revision FROM ma_session_journal_tx WHERE" - + " tenant_id = ? AND session_id = ? AND operation =" - + " 'commitFileHistory' ORDER BY journal_revision LIMIT" - + " 1 FOR UPDATE", - (row, index) -> row.getLong(1), - tenantId, - sessionId) - .isEmpty())) { - requireCsiHistoryOwner(csiOriginal); - } - TransactionRow existing = findTransactionByCommand(tenantId, - sessionId, commandKeyHash(request.operation(), - request.commandId())); - if (existing != null) { - requireTransactionWorkspace(existing, request.workspaceId()); - if (csiOriginal != null) { - requireWriter( - csiHead, - request.writerId(), - request.writerGeneration(), - writerToken, - databaseNow(), - true); - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - JdbcCsiActivationAdmission.requireReplay( - connection, - csiOriginal, - csiHead.journalRevision(), - csiHead.committedSequence(), - csiHead.lastCommitDigest(), - csiHead.writerId()); - return null; - }); - requireCsiCurrentTime(continuation); - } - // A duplicate is fenced like a first attempt: before #13800 the - // v2 claim check ran ahead of this lookup. - if (authority == null) { - requireNoLifecycleClaim(tenantId, sessionId); - } - return replay(existing, request, validated.recordDigest(), - writerToken); - } - requirePhysicalSessionOpen(tenantId, request.workspaceId(), sessionId); - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - if (authority == null) { - requireNoLifecycleClaim(tenantId, sessionId); - } - Timestamp now = databaseNow(); - requireWriter(head, request.writerId(), request.writerGeneration(), - writerToken, now, true); - validateHead(head, request); - long revision = increment(head.journalRevision(), - "journal revision"); - String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - if (csiOriginal != null - && java.util.Set.of("commitFileHistory", "toolIntent", "commitCheckpoint") - .contains(request.operation())) { - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - JdbcCsiActivationAdmission.preflightCommit( - connection, - csiOriginal, - CSI_JSON.valueToTree(request), - com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof - .records(validated.recordBytes()), - head.journalRevision(), - head.committedSequence(), - head.lastCommitDigest(), - head.writerId()); - return null; - }); - requireWriter( - head, - request.writerId(), - request.writerGeneration(), - writerToken, - databaseNow(), - true); - } - commitResources(scopeKey, tenantId, sessionId, request, revision, - now, validated.resources(), - () -> requireCsiCurrentTime(continuation)); - Set declaredResources = new HashSet<>(); - for (var resource : request.resources() == null ? List.of() : request.resources()) { - declaredResources.add(resource.resourceId()); - } - var resourceCache = new HashMap(); - var hostedReferences = new HashMap(); - java.util.function.Function reader = resourceId -> resourceCache.computeIfAbsent(resourceId, - id -> storedResource(scopeKey, tenantId, request.workspaceId(), sessionId, id)); - ManagedHostedRecoveryRecords.HistoricalResources closureReader = new ManagedHostedRecoveryRecords.HistoricalResources() { - @Override - public StoredResource apply(String resourceId) { - if (!declaredResources.contains(resourceId)) { - throw invalid("Hosted nested reference is absent from the transaction census."); - } - return reader.apply(resourceId); - } - - @Override - public void checkpointHistory(JsonNode ref) { - validateCheckpointHistory(ref, tenantId, request.workspaceId(), sessionId, - head.journalRevision(), declaredResources, reader); - } - }; - for (var resource : request.resources() == null ? List.of() : request.resources()) { - if (ManagedCheckpointHistory.KIND.equals(resource.kind())) { - ObjectNode ref = CSI_JSON.valueToTree(resource); - ref.remove("bytesBase64"); - closureReader.checkpointHistory(ref); - } else if ("managed-checkpoint".equals(resource.kind())) { - validatePagedCheckpoint(closureReader.apply(resource.resourceId()), tenantId, request.workspaceId(), sessionId, closureReader); - } - if (ManagedHostedRecoveryRecords.KINDS.contains(resource.kind()) || "managed-action-options".equals(resource.kind())) { - ManagedHostedRecoveryRecords.validateResource(closureReader.apply(resource.resourceId()), tenantId, - request.workspaceId(), sessionId, closureReader, hostedReferences); - } - } - boolean wakeInput = false; - for (String line : - new String(validated.recordBytes(), StandardCharsets.UTF_8).split("\n")) { - JsonNode record = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(line); - if (record != null && record.has("managedSession")) { - JsonNode event = record.get("managedSession"); - wakeInput |= WakeRecoveryStore.isWakeInput(event); - ManagedHostedRecoveryRecords.validateEvent( - event.path("kind").asText(), - event.path("payload"), - tenantId, - request.workspaceId(), - sessionId, - closureReader, - hostedReferences); - if ("hosted.cleanup".equals(event.path("kind").asText())) { - HostedCleanupReferences.apply(jdbc, tenantId, request.workspaceId(), sessionId, - event.path("payload"), reader, revision, now.getTime()); - } - } - } - boolean v2 = authority != null && jdbc.queryForObject( - "SELECT lifecycle_protocol_version FROM ma_operation WHERE" - + " operation_kind IN ('CLOSE', 'ARCHIVE', 'DELETE')" - + " AND tenant_id = ? AND session_id = ? AND" - + " operation_id = ?", Integer.class, - tenantId, sessionId, authority.operationId()) == 2; - boolean resourcePhase = v2 && WorkspaceLifecycleStore.resourceClaim(jdbc, tenantId, sessionId, authority); - if (authority != null && extensionRecords.hasNewLifecycleDispatch(tenantId, sessionId, validated.recordBytes(), - reader, v2, resourcePhase)) { - if (lifecycleExecution == null || lifecycleSessions == null) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked("workspace_lifecycle_protocol_unavailable"); - } - try { - lifecycleExecution.authorizeLifecycle(lifecycleSessions.requireSession(tenantId, sessionId), authority); - } catch (com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException refusal) { - if (refusal.getStatusCode() == 409 && "workspace_unavailable".equals(refusal.getCode())) { - throw com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.blocked( - "workspace_lifecycle_authorization_revoked"); - } - if (refusal.getStatusCode() == 409) { - throw new ManagedAgentException(409, refusal.getCode(), refusal.getMessage()); - } - throw refusal; - } - } - if (authority == null && lifecycleProtocolFenced(tenantId, sessionId)) { - extensionRecords.requireLifecycleSettlement(tenantId, sessionId, validated.recordBytes(), - reader); - } - var applied = - csiOriginal == null - ? extensionRecords.apply(tenantId, request.workspaceId(), sessionId, - request.firstSequence(), request.eventCount(), - validated.recordBytes(), reader, now.getTime()) - : extensionRecords.applyNative( - tenantId, - request.workspaceId(), - sessionId, - request.firstSequence(), - request.eventCount(), - validated.recordBytes(), - reader, - now.getTime()); - if (csiOriginal != null) { - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - JdbcCsiActivationAdmission.acceptCommit( - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection), - csiOriginal, - CSI_JSON.valueToTree(request), - applied.records(), - head.journalRevision(), - head.committedSequence(), - head.lastCommitDigest(), - head.writerId(), - now.toInstant().toEpochMilli(), - continuation.progress()); - return null; - }); - requireWriter( - head, - request.writerId(), - request.writerGeneration(), - writerToken, - databaseNow(), - true); - } - var receiptEvents = applied.receipts(); - if (actions != null) { - actions.apply( - tenantId, - request.workspaceId(), - sessionId, request.firstSequence(), request.eventCount(), - validated.recordBytes(), - reader, hostedReferences); - } - requireCsiWriterCurrent( - continuation, head, request.writerId(), request.writerGeneration(), writerToken); - jdbc.update("INSERT INTO ma_session_journal_tx" - + " (tenant_id, workspace_id, session_id," - + " journal_revision, command_key_hash," - + " transaction_id, operation, command_id," - + " content_digest, first_sequence, last_sequence," - + " event_count, events_digest," - + " previous_commit_digest, commit_digest," - + " writer_generation, writer_id," - + " writer_token_hash, activation_epoch," - + " latest_checkpoint_resource_id," - + " record_encoding, record_bytes, byte_length," - + " record_digest, gmt_create) VALUES (?, ?, ?, ?," - + " ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?," - + " ?," - + " 'identity', ?, ?, ?, ?)", - tenantId, request.workspaceId(), sessionId, revision, - commandKeyHash(request.operation(), request.commandId()), - request.transactionId(), request.operation(), - request.commandId(), request.contentDigest(), - request.firstSequence(), request.lastSequence(), - request.eventCount(), request.eventsDigest(), - request.previousCommitDigest(), request.commitDigest(), - request.writerGeneration(), request.writerId(), - tokenHash(writerToken), request.activationEpoch(), - request.latestCheckpointResourceId(), - validated.recordBytes(), validated.recordBytes().length, - validated.recordDigest(), now.getTime()); - ActivationChange activation = activationChange( - applied.lastActivation()); - if (placement != null && "QUIESCING".equals(placement.state()) && activation != null - && "active".equals(activation.phase()) - && (!"active".equals(head.activationPhase()) || !Objects.equals(activation.id(), head.activationId()) - || !Objects.equals(activation.epoch(), head.activationEventEpoch()))) { - throw HarnessPlacementStore.blocked("harness_placement_quiescing"); - } - // Never certify a skewed activation projection as current. - Long activationHeadRevision = head.activationHeadRevision(); - if (activation != null || (activationHeadRevision != null - && activationHeadRevision == head.journalRevision())) { - activationHeadRevision = revision; - } - requireCsiWriterCurrent( - continuation, head, request.writerId(), request.writerGeneration(), writerToken); - jdbc.update("UPDATE ma_session_head SET" - + " journal_revision = ?, committed_sequence = ?," - + " wake_pending = wake_pending OR ?," - + " wake_head_revision = CASE WHEN wake_head_revision = ? THEN ? ELSE wake_head_revision END," - + " cleanup_head_revision = CASE WHEN cleanup_head_revision = ? THEN ? ELSE cleanup_head_revision END," - + " last_commit_digest = ?, activation_epoch = ?," - + " latest_checkpoint_resource_id = COALESCE(?," - + " latest_checkpoint_resource_id)," - + " activation_id = ?, activation_phase = ?," - + " activation_event_epoch = ?," - + " activation_expires_at = ?," - + " activation_head_revision = ?, storage_version = ?, gmt_modified = ?" - + " WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ?" - + " AND session_id = ?", - revision, request.lastSequence(), - wakeInput, - head.journalRevision(), revision, head.journalRevision(), revision, request.commitDigest(), - request.activationEpoch(), - request.latestCheckpointResourceId(), - activation == null ? head.activationId() - : activation.id(), - activation == null ? head.activationPhase() - : activation.phase(), - activation == null ? head.activationEventEpoch() - : Long.valueOf(activation.epoch()), - activation == null ? head.activationExpiresAt() - : activation.expiresAt(), - activationHeadRevision, Math.max(head.storageVersion(), promotes ? 2 : 1), now.getTime(), JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), tenantId, sessionId); - if (toolResults != null && csiOriginal == null) { - toolResults.captureEvents(tenantId, request.workspaceId(), sessionId, revision, receiptEvents); - } - ManagedUserMessageStore.capture(jdbc, tenantId, sessionId, applied.records()); - publishJournalAfterCommit(validated.recordBytes()); - return new CommitReceipt(revision, request.transactionId(), - request.commandId(), request.operation(), - request.firstSequence(), request.lastSequence(), - request.lastSequence(), request.commitDigest(), false); - } - - // The authoritative-journal wire (design 2026-10-04-managed-agent-event-transport, - // MQ2b): hands the committed journal record to the transport only after the - // transaction is durable, and never from a replayed commit (those return - // before this point). A no-op when no transport is enabled. - private void publishJournalAfterCommit(byte[] recordBytes) { - JournalEventTransport transport = journalEventTransport; - if (transport == null) { - return; - } - if (!TransactionSynchronizationManager.isSynchronizationActive()) { - transport.publishCommitted(recordBytes); - return; - } - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - transport.publishCommitted(recordBytes); - } - }); + return commits.commit(tenantId, sessionId, writerToken, request, authority); } @Transactional(readOnly = true) public RestoreHead restore(String tenantId, String workspaceId, String sessionId, String writerToken) { - validateScope(tenantId, workspaceId, sessionId); - requireCredential(tenantId, workspaceId, sessionId, writerToken); - HeadRow head = requireHead(tenantId, sessionId); - requireHeadScope(head, tenantId, workspaceId, sessionId); - requireReadGrant(head, writerToken); - return restoreHead(head); - } - - private RestoreHead restoreHead(HeadRow head) { - boolean renewed = "active".equals(head.activationPhase()) && !jdbc.queryForList( - "SELECT 1 FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" - + " AND activation_epoch = ? AND activation_lease_until IS NOT NULL LIMIT 1", - head.tenantId(), head.sessionId(), head.activationEpoch()).isEmpty(); - var lease = renewed ? activationLeaseRow(head).lease() : null; - return new RestoreHead(head.state(), head.storageVersion(), - head.writerGeneration(), head.journalRevision(), - head.committedSequence(), head.lastCommitDigest(), - head.activationEpoch(), head.latestCheckpointResourceId(), - head.compactedThroughRevision(), head.recoveryStatus(), - head.recoveryDetailCode(), lease); - } - - private record ActivationLeaseRow(long revision, String workerId, ManagedSessionStoreModels.ActivationLease lease) { - } - - private ActivationLeaseRow activationLeaseRow(HeadRow head) { - var rows = jdbc.query("SELECT journal_revision, record_bytes, record_digest, activation_lease_until," - + " activation_renewal_seq FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" - + " AND activation_epoch = ? AND operation IN ('installActivation', 'renewActivation')" - + " ORDER BY journal_revision DESC LIMIT 1", (result, index) -> { - try { - byte[] raw = result.getBytes("record_bytes"); - if (raw == null || !sha256(raw).equals(result.getString("record_digest"))) { - throw journalCorrupt(); - } - String first = new String(raw, StandardCharsets.UTF_8).split("\n", 2)[0]; - JsonNode event = CSI_TERMINAL_JSON.readTree(first).path("managedSession"); - JsonNode payload = event.path("payload"); - if (!"activation.changed".equals(event.path("kind").asText()) - || !"active".equals(payload.path("phase").asText())) { - throw journalCorrupt(); - } - long renewal = payload.path("renewalSeq").asLong(0); - long until = payload.path("expiresAt").asLong(-1); - Long savedUntil = result.getObject("activation_lease_until", Long.class); - Long savedRenewal = result.getObject("activation_renewal_seq", Long.class); - if ((savedUntil == null) != (savedRenewal == null) - || savedUntil != null && (savedUntil < until || savedRenewal <= renewal - || savedRenewal > ManagedSessionStoreModels.MAX_SAFE_COUNTER)) { - throw journalCorrupt(); - } - var lease = new ManagedSessionStoreModels.ActivationLease( - payload.path("activationId").textValue(), payload.path("epoch").asLong(), - savedRenewal == null ? renewal : savedRenewal, - savedUntil == null ? until : savedUntil); - if (!Objects.equals(head.activationId(), lease.activationId()) - || !Objects.equals(head.activationEventEpoch(), lease.epoch()) - || !Objects.equals(head.activationExpiresAt(), lease.expiresAt())) { - throw journalCorrupt(); - } - return new ActivationLeaseRow(result.getLong("journal_revision"), - payload.path("workerId").textValue(), lease); - } catch (java.io.IOException error) { - throw journalCorrupt(); - } - }, head.tenantId(), head.sessionId(), head.activationEpoch()); - if (rows.size() != 1) { - throw journalCorrupt(); - } - return rows.getFirst(); + return historyQueries.restore(tenantId, workspaceId, sessionId, writerToken); } @Transactional(readOnly = true) public TransactionPage transactions(String tenantId, String workspaceId, String sessionId, String writerToken, long afterRevision, int limit) { - validateScope(tenantId, workspaceId, sessionId); - requireCredential(tenantId, workspaceId, sessionId, writerToken); - if (afterRevision < 0 - || afterRevision > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || limit < 1 || limit > 100) { - throw invalid("afterRevision and limit are outside their bounds."); - } - HeadRow head = requireHead(tenantId, sessionId); - requireHeadScope(head, tenantId, workspaceId, sessionId); - requireReadGrant(head, writerToken); - List candidates = jdbc.query( - "SELECT journal_revision, byte_length" - + " FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ?" - + " AND journal_revision > ?" - + " ORDER BY journal_revision LIMIT ?", - (result, row) -> new TransactionSize( - result.getLong("journal_revision"), - result.getLong("byte_length")), - tenantId, sessionId, afterRevision, - limit + 1); - int selectedCount = 0; - long selectedBytes = 0; - long expectedRevision = afterRevision + 1; - for (TransactionSize candidate : candidates) { - if (candidate.journalRevision() != expectedRevision - || candidate.journalRevision() - > head.journalRevision() - || candidate.byteLength() < 1 || candidate.byteLength() - > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { - throw journalCorrupt(); - } - expectedRevision++; - if (selectedCount == limit || selectedCount > 0 - && selectedBytes + candidate.byteLength() - > ManagedSessionStoreModels - .MAX_TRANSACTION_BYTES) { - break; - } - selectedBytes += candidate.byteLength(); - selectedCount++; - } - if (afterRevision < head.journalRevision() - && (candidates.isEmpty() - || candidates.size() < limit + 1 - && candidates.get(candidates.size() - 1) - .journalRevision() - != head.journalRevision())) { - throw journalCorrupt(); - } - List rows = selectedCount == 0 ? List.of() - : jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + "" - + " FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ?" - + " AND journal_revision > ?" - + " ORDER BY journal_revision LIMIT ?", - transactionMapper, tenantId, sessionId, - afterRevision, selectedCount); - if (rows.size() != selectedCount) { - throw journalCorrupt(); - } - for (int index = 0; index < rows.size(); index++) { - TransactionRow row = rows.get(index); - TransactionSize candidate = candidates.get(index); - if (row.journalRevision() != candidate.journalRevision() - || row.byteLength() != candidate.byteLength()) { - throw journalCorrupt(); - } - } - boolean hasMore = candidates.size() > selectedCount; - rows.forEach(row -> requireTransactionWorkspace(row, workspaceId)); - List transactions = rows.stream() - .map(this::storedTransaction).toList(); - long nextRevision = transactions.isEmpty() ? afterRevision - : transactions.get(transactions.size() - 1) - .journalRevision(); - return new TransactionPage(transactions, nextRevision, hasMore); + return historyQueries.transactions(tenantId, workspaceId, sessionId, writerToken, afterRevision, limit); } @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public ManagedSessionStoreModels.RecentTransactionPage recentTransactions(String tenantId, String workspaceId, String sessionId, String writerToken, Long throughRevision, Long beforeRevision, int limit) { - long through = historyRevision(tenantId, workspaceId, sessionId, writerToken, throughRevision); - if (limit < 1 || limit > 100 || beforeRevision != null && (beforeRevision < 1 || beforeRevision > through)) { - throw invalid("History page position or limit is outside its bounds."); - } - long upper = beforeRevision == null ? through : beforeRevision - 1; - List candidates = jdbc.query("SELECT journal_revision, byte_length FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ? AND journal_revision <= ?" - + " ORDER BY journal_revision DESC LIMIT ?", - (row, index) -> new TransactionSize(row.getLong("journal_revision"), row.getLong("byte_length")), - tenantId, sessionId, upper, limit + 1); - int selected = 0; - long bytes = 0; - long expected = upper; - for (TransactionSize candidate : candidates) { - if (candidate.journalRevision() != expected-- || candidate.byteLength() < 1 - || candidate.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { - throw journalCorrupt(); - } - if (selected == limit || selected > 0 && bytes + candidate.byteLength() - > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { - break; - } - bytes += candidate.byteLength(); - selected++; - } - if (upper > 0 && (candidates.isEmpty() || candidates.size() < limit + 1 - && candidates.getLast().journalRevision() != 1)) { - throw journalCorrupt(); - } - List rows = selected == 0 ? List.of() : jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + " FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ? AND journal_revision <= ?" - + " ORDER BY journal_revision DESC LIMIT ?", transactionMapper, tenantId, sessionId, upper, selected); - if (rows.size() != selected) { - throw journalCorrupt(); - } - List transactions = new ArrayList<>(); - for (int index = 0; index < rows.size(); index++) { - TransactionRow row = rows.get(index); - if (row.journalRevision() != candidates.get(index).journalRevision() - || row.byteLength() != candidates.get(index).byteLength()) { - throw journalCorrupt(); - } - requireTransactionWorkspace(row, workspaceId); - transactions.add(storedTransaction(row)); - } - boolean more = candidates.size() > selected; - Long next = more ? transactions.getLast().journalRevision() : null; - java.util.Collections.reverse(transactions); - restore(tenantId, workspaceId, sessionId, writerToken); - return new ManagedSessionStoreModels.RecentTransactionPage(List.copyOf(transactions), through, next, more); + return historyQueries.recentTransactions(tenantId, workspaceId, sessionId, writerToken, throughRevision, beforeRevision, limit); } @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public ManagedSessionStoreModels.CommandTransaction commandTransaction(String tenantId, String workspaceId, String sessionId, String writerToken, Long throughRevision, String operation, String commandId) { - long through = historyRevision(tenantId, workspaceId, sessionId, writerToken, throughRevision); - if (StringUtils.isBlank(operation) || operation.length() > 4096 - || StringUtils.isBlank(commandId) || commandId.length() > 512) { - throw invalid("History command identity is invalid."); - } - String key = commandKeyHash(operation, commandId); - var sizes = jdbc.query("SELECT journal_revision, byte_length FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ? AND command_key_hash = ? AND journal_revision <= ?", - (row, index) -> new TransactionSize(row.getLong("journal_revision"), row.getLong("byte_length")), - tenantId, sessionId, key, through); - StoredTransaction transaction = null; - if (!sizes.isEmpty()) { - TransactionSize size = sizes.getFirst(); - if (sizes.size() != 1 || size.byteLength() < 1 - || size.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { - throw journalCorrupt(); - } - TransactionRow row = findTransactionByCommand(tenantId, sessionId, key); - if (row == null || row.journalRevision() != size.journalRevision() || row.byteLength() != size.byteLength()) { - throw journalCorrupt(); - } - requireTransactionWorkspace(row, workspaceId); - if (!operation.equals(row.operation()) || !commandId.equals(row.commandId()) - || row.byteLength() < 1 || row.byteLength() > ManagedSessionStoreModels.MAX_TRANSACTION_BYTES) { - throw journalCorrupt(); - } - transaction = storedTransaction(row); - } - restore(tenantId, workspaceId, sessionId, writerToken); - return new ManagedSessionStoreModels.CommandTransaction(through, transaction); + return historyQueries.commandTransaction(tenantId, workspaceId, sessionId, writerToken, throughRevision, operation, commandId); } - private long historyRevision(String tenantId, String workspaceId, String sessionId, String writerToken, - Long throughRevision) { - RestoreHead head = restore(tenantId, workspaceId, sessionId, writerToken); - if (head.compactedThroughRevision() != 0) { - throw conflict(ManagedSessionStoreModels.ERROR_RECOVERY_CONFLICT, "History prefix is no longer retained."); - } - if (throughRevision != null && (throughRevision < 0 - || throughRevision > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || throughRevision > head.journalRevision())) { - throw invalid("History prefix is outside its committed bounds."); - } - long through = throughRevision == null ? head.journalRevision() : throughRevision; - if (through > 0) { - var prefix = jdbc.query("SELECT workspace_id FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ? AND journal_revision = ?", - (row, index) -> row.getString("workspace_id"), tenantId, sessionId, through); - if (prefix.size() != 1 || !workspaceId.equals(prefix.getFirst())) { - throw journalCorrupt(); - } - } - return through; - } - - /** Proves only an untouched initial journal; it never opens or fences a writer. */ @Transactional(propagation = Propagation.NOT_SUPPORTED, readOnly = true) public boolean hasPristineInitialJournal(String tenantId, String sessionId, String workspaceId) { - try { - HeadRow head = requireHead(tenantId, sessionId); - if (!Set.of("ACTIVE", "SEALED").contains(head.state()) - || !"READY".equals(head.recoveryStatus()) || head.storageVersion() != STORAGE_VERSION - || head.compactedThroughRevision() != 0 || head.latestCheckpointResourceId() != null - || head.journalRevision() < 1 || head.committedSequence() < 0 - || workspaceId != null && !workspaceId.equals(head.workspaceId())) { - return false; - } - var rows = jdbc.query("SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() - + " FROM ma_session_journal_tx WHERE tenant_id = ? AND session_id = ?" - + " ORDER BY journal_revision", transactionMapper, tenantId, sessionId); - long revision = 0; - long sequence = 0; - long activationEpoch = 0; - String digest = null; - for (TransactionRow row : rows) { - if (row.journalRevision() != ++revision || !head.workspaceId().equals(row.workspaceId()) - || row.latestCheckpointResourceId() != null || row.activationEpoch() < activationEpoch - || !Objects.equals(digest, row.previousCommitDigest())) { - return false; - } - storedTransaction(row); - validateUtf8JsonLines(row.recordBytes(), revision == 1 ? 2 : row.eventCount() + 1); - String[] lines = new String(row.recordBytes(), StandardCharsets.UTF_8).split("\n"); - if (revision == 1) { - if (!"session.create".equals(row.operation()) || row.eventCount() != 0 - || row.firstSequence() != 0 || row.lastSequence() != 0 || lines.length != 2 - || row.commitDigest() != null || row.eventsDigest() != null || row.activationEpoch() != 0 - || !("session.create:" + sessionId).equals(row.transactionId()) - || !row.transactionId().equals(row.commandId()) - || !row.recordDigest().equals(row.contentDigest())) { - return false; - } - JsonNode engine = CSI_TERMINAL_JSON.readTree(lines[0]); - JsonNode header = CSI_TERMINAL_JSON.readTree(lines[1]); - JsonNode value = header.path("managedSession"); - requireInitialResource(value.path("definitionRef"), head, "managed-definition"); - requireInitialResource(value.path("rootSnapshotRef"), head, "managed-root"); - if (!"session_execution_engine".equals(engine.path("subtype").asText()) - || !"managed".equals(engine.path("systemPayload").path("engine").asText()) - || !sessionId.equals(engine.path("sessionId").asText()) - || !"managed_session_header_v1".equals(header.path("subtype").asText()) - || !sessionId.equals(header.path("sessionId").asText()) - || value.size() != 7 || value.path("formatVersion").asInt() != STORAGE_VERSION - || !"managed-session/1".equals(value.path("minimumReader").asText()) - || !"managed".equals(value.path("engine").asText()) - || !"hosted-harness".equals(value.path("createdBy").asText()) - || value.has("baseTranscriptProof") || !initialScope(value, head)) { - return false; - } - } else { - if (row.eventCount() < 1 || lines.length != row.eventCount() + 1 - || row.firstSequence() != sequence + 1 - || row.lastSequence() != sequence + row.eventCount() || row.commitDigest() == null) { - return false; - } - var events = CSI_JSON.createArrayNode(); - boolean activationChanged = false; - for (int index = 0; index < row.eventCount(); index++) { - JsonNode record = CSI_TERMINAL_JSON.readTree(lines[index]); - JsonNode event = record.path("managedSession"); - JsonNode payload = event.path("payload"); - String kind = event.path("kind").asText(); - if (!sessionId.equals(record.path("sessionId").asText()) - || !"managed_session_event_v1".equals(record.path("subtype").asText()) - || event.path("v").asInt() != STORAGE_VERSION - || event.path("sequence").asLong(-1) != ++sequence || !initialScope(event, head) - || !("activation.changed".equals(kind) - && "activation".equals(payload.path("subject").path("type").asText()) - && payload.path("activationId").isTextual() - && payload.path("activationId").equals(payload.path("subject").path("scopeId")) - && payload.path("activationId").equals(payload.path("subject").path("activationId")) - && payload.path("epoch").equals(payload.path("subject").path("epoch")) - && payload.path("epoch").asLong() > 0 - && payload.path("epoch").asLong() == row.activationEpoch() - || "domain.committed".equals(kind) - && "session_metadata".equals(payload.path("domain").asText()))) { - return false; - } - if ("domain.committed".equals(kind)) { - requireInitialResource(payload.path("recordRef"), head, "managed-session_metadata"); - } else { - activationChanged = true; - if (!payload.path("installRef").isNull()) { - requireInitialResource(payload.path("installRef"), head, "managed-activation-install"); - } - if (!payload.path("boundaryRef").isNull()) { - requireInitialResource(payload.path("boundaryRef"), head, "managed-activation-boundary"); - } - } - events.add(event); - } - if (!activationChanged && row.activationEpoch() != activationEpoch) { - return false; - } - JsonNode markerRecord = CSI_TERMINAL_JSON.readTree(lines[lines.length - 1]); - JsonNode marker = markerRecord.path("managedSession"); - if (!"managed_session_commit_v1".equals(markerRecord.path("subtype").asText()) - || !sessionId.equals(markerRecord.path("sessionId").asText()) - || !row.transactionId().equals(marker.path("transactionId").asText()) - || !row.commandId().equals(marker.path("commandId").asText()) - || !row.operation().equals(marker.path("operation").asText()) - || !row.contentDigest().equals(marker.path("contentDigest").asText()) - || row.firstSequence() != marker.path("firstSequence").asLong(-1) - || row.lastSequence() != marker.path("lastSequence").asLong(-1) - || row.eventCount() != marker.path("eventCount").asInt(-1) - || !row.eventsDigest().equals(marker.path("eventsDigest").asText()) - || !INITIAL_DIGESTS.digest(CSI_JSON.convertValue(events, Object.class)) - .equals("sha256:" + row.eventsDigest()) - || !INITIAL_DIGESTS.digest(CSI_JSON.convertValue(marker, Object.class)) - .equals("sha256:" + row.commitDigest()) - || !Objects.equals(digest, marker.path("previousCommitDigest").isNull() - ? null : marker.path("previousCommitDigest").asText())) { - return false; - } - } - digest = row.commitDigest(); - activationEpoch = row.activationEpoch(); - } - return revision == head.journalRevision() && sequence == head.committedSequence() - && activationEpoch == head.activationEpoch() - && Objects.equals(digest, head.lastCommitDigest()) && head.equals(requireHead(tenantId, sessionId)); - } catch (IOException | RuntimeException unverified) { - return false; - } - } - - private static boolean initialScope(JsonNode value, HeadRow head) { - JsonNode key = value.path("sessionKey"); - return head.tenantId().equals(key.path("tenantId").asText()) - && head.workspaceId().equals(key.path("workspaceId").asText()) - && head.sessionId().equals(key.path("sessionId").asText()); - } - - private void requireInitialResource(JsonNode ref, HeadRow head, String kind) throws IOException { - WorkspaceRecoveryReader.validateRef(ref); - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(head.tenantId(), head.sessionId()); - var resources = jdbc.query("SELECT " + ManagedSchemaProjection.RESOURCE.columns() - + " FROM ma_resource WHERE resource_key = ? AND session_scope_key = ? AND resource_id = ?", - resourceMapper, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, ref.path("resourceId").asText()), scope, ref.path("resourceId").asText()); - if (resources.size() != 1) { - throw journalCorrupt(); - } - ResourceRow resource = resources.getFirst(); - requireResourceScope(resource, head.tenantId(), head.workspaceId(), head.sessionId(), ref.path("resourceId").asText()); - if (!"MYSQL_INLINE".equals(resource.storageKind()) || !kind.equals(resource.kind()) - || !kind.equals(ref.path("kind").asText()) || resource.schemaVersion() != ref.path("schemaVersion").asInt() - || resource.byteLength() != ref.path("byteLength").asLong() || !resource.digest().equals(ref.path("digest").asText())) { - throw journalCorrupt(); - } - verifyStoredResource(resource); - if ("managed-definition".equals(kind)) { - JsonNode definition = CSI_TERMINAL_JSON.readTree(resource.bytes()); - if (definition == null || !definition.isObject()) { - throw journalCorrupt(); - } - } + return historyQueries.hasPristineInitialJournal(tenantId, sessionId, workspaceId); } public StoredResource readResource(String tenantId, String workspaceId, String sessionId, String resourceId, String writerToken) { - return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken, true); + return resourceReads.readResource(tenantId, workspaceId, sessionId, resourceId, writerToken); } StoredResource readSnapshotResource(String tenantId, String workspaceId, String sessionId, String resourceId, String writerToken) { - return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken, false); - } - - private StoredResource readResource(String tenantId, String workspaceId, - String sessionId, String resourceId, String writerToken, boolean updateVerification) { - validateScope(tenantId, workspaceId, sessionId); - requireCredential(tenantId, workspaceId, sessionId, writerToken); - if (updateVerification && ManagedNativeJournalSnapshots.isSnapshotId(resourceId)) { - throw invalid("Native snapshot resources require the journal-snapshots read API."); - } - validateStableId(resourceId, "resourceId"); - HeadRow head = requireHead(tenantId, sessionId); - requireHeadScope(head, tenantId, workspaceId, sessionId); - requireReadGrant(head, writerToken); - try (var lease = outputRetention.read(tenantId, sessionId)) { - String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - ResourceRow resource = findResource(scopeKey, resourceId, updateVerification); - if (resource == null) { - throw new ManagedAgentException(404, - ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND, - "The Managed Session resource does not exist."); - } - requireResourceScope(resource, tenantId, workspaceId, sessionId, - resourceId); - verifyStoredResource(resource); - if (updateVerification) { - jdbc.update("UPDATE ma_resource SET" - + " last_verified_at = ? WHERE session_scope_key = ?" - + " AND resource_id = ?", - databaseNow(), scopeKey, resourceId); - } - byte[] bytes = "TOOL_PUBLICATION".equals(resource.storageKind()) - ? readPublicationObject(resource, lease, updateVerification) : resource.bytes(); - var result = new StoredResource(resource.resourceId(), resource.kind(), - resource.schemaVersion(), resource.byteLength(), - resource.digest(), bytes); - lease.check(); - return result; - } + return resourceReads.readSnapshotResource(tenantId, workspaceId, sessionId, resourceId, writerToken); } public StoredResource readOwnerResource( @@ -1971,607 +270,41 @@ public class ManagedSessionStore String sessionId, String resourceId, String writerToken) { - if (ManagedNativeJournalSnapshots.isSnapshotId(resourceId)) { - requireCredential(tenantId, workspaceId, sessionId, writerToken); - throw invalid("Native snapshot resources require the journal-snapshots read API."); - } - if (org.springframework.transaction.support.TransactionSynchronizationManager - .isActualTransactionActive() - || org.springframework.transaction.support.TransactionSynchronizationManager - .hasResource(jdbc.getDataSource())) { - throw new IllegalStateException( - "Owner resource reads require a fresh transaction boundary"); - } - validateScope(tenantId, workspaceId, sessionId); - requireCredential(tenantId, workspaceId, sessionId, writerToken); - validateStableId(resourceId, "resourceId"); - var guarded = - jdbc.queryForList( - "SELECT csi_guard FROM ma_session" - + " WHERE tenant_id = ? AND session_id = ? AND csi_guard = TRUE", - tenantId, - sessionId); - if (guarded.isEmpty()) { - return readResource(tenantId, workspaceId, sessionId, resourceId, writerToken); - } - NativeRead admission = - ownerReads.execute( - status -> { - var continuation = lockCsiContinuation(tenantId, sessionId); - var original = continuation == null ? null : continuation.original(); - if (original == null) { - throw nativeReadConflict(); - } - if (original.state() == com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord.State.DRAINING) { - jdbc.execute((org.springframework.jdbc.core.ConnectionCallback) connection -> { - JdbcCsiActivationAdmission.requireCheckpointContinuationRead( - org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(connection), - original, continuation.progress(), resourceId); - return null; - }); - } else { - original.requireAdmission(); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, workspaceId, sessionId); - requireCsiWriter(original, head); - requireReadGrant(head, writerToken); - ResourceRow resource = - nativeResourceMetadata(tenantId, sessionId, resourceId); - if (resource == null) { - throw new ManagedAgentException( - 404, - ManagedSessionStoreModels.ERROR_RESOURCE_NOT_FOUND, - "The Managed Session resource does not exist."); - } - requireResourceScope( - resource, tenantId, workspaceId, sessionId, resourceId); - if (!"MYSQL_INLINE".equals(resource.storageKind()) - || resource.objectKey() != null - || resource.objectVersionId() != null - || resource.encryptionKeyId() != null) { - throw conflict( - "csi_inline_resource_required", - "The original owner reader requires an inline resource."); - } - String id = java.util.UUID.randomUUID().toString(); - requireCsiReadGrant(continuation, head, writerToken); - long started = ToolPublicationRetentionStore.now(jdbc); - long expires = - Math.addExact( - started, - ToolPublicationRetentionStore.READ_BUDGET_MILLIS); - requireCsiLease(continuation, expires, expires); - long readBindingVersion = continuation.progress().sealedBindingVersion() == null ? original.version() : continuation.progress().sealedBindingVersion(); - var evidence = - CSI_JSON.createObjectNode() - .put("read_id", id) - .put("tenant_id", tenantId) - .put("workspace_id", workspaceId) - .put("session_id", sessionId) - .put("request_key", original.request().requestKey()) - .put("binding_id", original.bindingId()) - .put("runtime_generation", original.generation()) - .put("binding_version", readBindingVersion) - .put("writer_id", head.writerId()) - .put("writer_generation", head.writerGeneration()) - .put("writer_token_hash", head.leaseTokenHash()) - .put("journal_revision", head.journalRevision()) - .put("activation_id", head.activationId()) - .put("activation_epoch", head.activationEpoch()) - .put("resource_id", resourceId) - .put("kind", resource.kind()) - .put("schema_version", resource.schemaVersion()) - .put("byte_length", resource.byteLength()) - .put("sha256", resource.digest()) - .put("storage_kind", resource.storageKind()) - .put("resource_state", resource.state()) - .put("started_at", started) - .put("delivery_expires_at", expires); - String encoded = evidence.toString(); - String receipt = - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key("csi-read", id); - jdbc.update( - "INSERT INTO ma_request_receipt (receipt_key, namespace, tenant_key, tenant_id, scope_kind, scope_key, scope_id, idempotency_key, idempotency_digest, request_digest, operation, state, session_id, result_json, gmt_create, gmt_modified) VALUES (?, 'SESSION_COMMAND', ?, ?, 'SESSION', ?, ?, ?, ?, ?, 'CSI_RESOURCE_READ', 'OPEN', ?, ?, ?, ?)", - receipt, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(tenantId), - tenantId, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key("csi-read", tenantId, sessionId), - sessionId, - id, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key(id), - sha256(encoded.getBytes(StandardCharsets.UTF_8)), - sessionId, - encoded, - started, - started); - requireCsiReadGrant(continuation, head, writerToken); - jdbc.update( - "INSERT INTO ma_read_lease (lease_id, tenant_key, session_key," - + " retirement_generation, expires_at) VALUES (?, ?, ?, 0," - + " ?)", - id, - ToolPublicationRetentionStore.hash(tenantId), - ToolPublicationRetentionStore.hash(sessionId), - expires); - var persisted = nativeReadReceipt(id, false); - return new NativeRead(id, original, head, resource, persisted, expires); - }); - ResourceRow fetched; - try { - fetched = fetchNativeResource(admission); - } catch (RuntimeException | Error failure) { - try { - finishNativeRead(admission, null, false, writerToken); - } catch (RuntimeException | Error completion) { - failure.addSuppressed(completion); - } - throw failure; - } - ManagedAgentException failure = finishNativeRead(admission, fetched, true, writerToken); - if (failure != null) { - throw failure; - } - return new StoredResource( - fetched.resourceId(), - fetched.kind(), - fetched.schemaVersion(), - fetched.byteLength(), - fetched.digest(), - fetched.bytes()); + return resourceReads.readOwnerResource(tenantId, workspaceId, sessionId, resourceId, writerToken); } - private java.util.Map nativeReadReceipt(String id, boolean lock) { - return jdbc.queryForMap( - "SELECT " + ManagedSchemaProjection.REQUEST_RECEIPT.columns() + " FROM ma_request_receipt WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND operation = 'CSI_RESOURCE_READ'" - + (lock ? " FOR UPDATE" : ""), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - "csi-read", id)); - } - - private void completeNativeRead( - NativeRead admission, String state, Long endedAt, String outcome) { - com.fasterxml.jackson.databind.node.ObjectNode evidence; - try { - evidence = - (com.fasterxml.jackson.databind.node.ObjectNode) - CSI_JSON.readTree((String) admission.persisted().get("result_json")); - } catch (java.io.IOException error) { - throw new IllegalStateException("Original read receipt is corrupt", error); - } - evidence.put("ended_at", endedAt).put("outcome_code", outcome); - if (jdbc.update( - "UPDATE ma_request_receipt SET state = ?, result_json = ?, gmt_modified = ? WHERE receipt_key = ? AND namespace = 'SESSION_COMMAND' AND operation = 'CSI_RESOURCE_READ' AND state = 'OPEN'", - state, - evidence.toString(), - ToolPublicationRetentionStore.now(jdbc), - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - "csi-read", admission.id())) - != 1) { - throw nativeReadConflict(); - } - } - - private ResourceRow nativeResourceMetadata(String tenant, String session, String resource) { - var rows = - jdbc.query( - "SELECT tenant_id, workspace_id, session_id, resource_id, kind," - + " schema_version, byte_length, sha256, storage_kind, NULL AS" - + " inline_bytes, object_key, object_version_id, encryption_key_id," - + " state, capture_state, deletion_operation_key FROM ma_resource WHERE" - + " session_scope_key = ? AND resource_id = ? AND resource_key = ? FOR" - + " UPDATE", - resourceMapper, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), - resource, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), resource)); - return rows.isEmpty() ? null : rows.getFirst(); - } - - private ResourceRow fetchNativeResource(NativeRead admission) { - try (var connection = jdbc.getDataSource().getConnection(); - var statement = - connection.prepareStatement( - "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE session_scope_key = ? AND resource_id = ? AND resource_key = ?")) { - statement.setQueryTimeout(10); - statement.setString( - 1, com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(admission.head().tenantId(), admission.head().sessionId())); - statement.setBytes( - 2, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity( - admission.resource().resourceId())); - statement.setString( - 3, - com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.key( - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey( - admission.head().tenantId(), admission.head().sessionId()), - admission.resource().resourceId())); - try (var rows = statement.executeQuery()) { - return rows.next() ? resourceMapper.mapRow(rows, 0) : null; - } - } catch (SQLException failure) { - throw new org.springframework.dao.DataAccessResourceFailureException( - "Original resource fetch did not complete", failure); - } - } - - private ManagedAgentException finishNativeRead( - NativeRead admission, ResourceRow fetched, boolean returned, String writerToken) { - return ownerReads.execute( - status -> { - HeadRow admittedHead = admission.head(); - var continuation = - lockCsiContinuation(admittedHead.tenantId(), admittedHead.sessionId()); - var original = continuation == null ? null : continuation.original(); - if (original == null - || !original.request().equals(admission.original().request()) - || !original.bindingId().equals(admission.original().bindingId()) - || original.generation() != admission.original().generation() - || original.version() < admission.original().version()) { - throw nativeReadConflict(); - } - original.requireAdmission(); - HeadRow head = - requireHeadForUpdate(admittedHead.tenantId(), admittedHead.sessionId()); - var persisted = nativeReadReceipt(admission.id(), true); - if (!persisted.equals(admission.persisted())) { - throw nativeReadConflict(); - } - if (!returned) { - requireCsiReadGrant(continuation, head, writerToken); - completeNativeRead(admission, "UNKNOWN", null, "jdbc_fetch_unknown"); - return null; - } - ManagedAgentException failure = null; - try { - requireHeadScope( - head, - admittedHead.tenantId(), - admittedHead.workspaceId(), - admittedHead.sessionId()); - requireReadGrant(head, writerToken); - if (!Objects.equals(head.writerId(), admittedHead.writerId()) - || head.writerGeneration() != admittedHead.writerGeneration()) { - throw writerConflict(); - } - var lease = - jdbc.queryForList( - "SELECT expires_at, retirement_generation FROM" - + " ma_read_lease WHERE lease_id = ? AND tenant_key = ?" - + " AND session_key = ? FOR UPDATE", - admission.id(), - ToolPublicationRetentionStore.hash(head.tenantId()), - ToolPublicationRetentionStore.hash(head.sessionId())); - long now = ToolPublicationRetentionStore.now(jdbc); - if (lease.size() != 1 - || ((Number) lease.getFirst().get("expires_at")).longValue() - != admission.expires() - || admission.expires() <= now - || ((Number) lease.getFirst().get("retirement_generation")) - .longValue() - != 0) { - throw conflict( - "tool_output_read_expired", - "The original output read delivery lease expired."); - } - ResourceRow current = - nativeResourceMetadata( - head.tenantId(), - head.sessionId(), - admission.resource().resourceId()); - if (!sameNativeResource(admission.resource(), fetched) - || !sameNativeResource(admission.resource(), current)) { - throw resourceCorrupt(); - } - verifyStoredResource(fetched); - requireCsiReadGrant(continuation, head, writerToken); - if (admission.expires() <= ToolPublicationRetentionStore.now(jdbc)) { - throw nativeReadConflict(); - } - jdbc.update( - "UPDATE ma_resource SET last_verified_at = ?" - + " WHERE session_scope_key = ? AND resource_id = ?", - databaseNow(), - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(head.tenantId(), head.sessionId()), - fetched.resourceId()); - } catch (ManagedAgentException rejected) { - failure = rejected; - } - requireCsiReadGrant(continuation, head, writerToken); - if (admission.expires() <= ToolPublicationRetentionStore.now(jdbc)) { - throw nativeReadConflict(); - } - completeNativeRead( - admission, - "RETURNED", - ToolPublicationRetentionStore.now(jdbc), - failure == null ? "verified" : failure.getCode()); - requireCsiReadGrant(continuation, head, writerToken); - jdbc.update("DELETE FROM ma_read_lease WHERE lease_id = ?", admission.id()); - return failure; - }); - } - - private static boolean sameNativeResource(ResourceRow expected, ResourceRow actual) { - return actual != null - && Objects.equals(expected.tenantId(), actual.tenantId()) - && Objects.equals(expected.workspaceId(), actual.workspaceId()) - && Objects.equals(expected.sessionId(), actual.sessionId()) - && Objects.equals(expected.resourceId(), actual.resourceId()) - && Objects.equals(expected.kind(), actual.kind()) - && expected.schemaVersion() == actual.schemaVersion() - && expected.byteLength() == actual.byteLength() - && Objects.equals(expected.digest(), actual.digest()) - && Objects.equals(expected.storageKind(), actual.storageKind()) - && Objects.equals(expected.state(), actual.state()) - && actual.objectKey() == null - && actual.objectVersionId() == null - && actual.encryptionKeyId() == null; - } - - private static ManagedAgentException nativeReadConflict() { - return conflict( - "csi_resource_read_conflict", - "The original resource read identity is unavailable."); - } - - private record NativeRead( - String id, - JdbcCsiFilesRetirementGuard.Original original, - HeadRow head, - ResourceRow resource, - java.util.Map persisted, - long expires) {} - @Transactional public ToolResultResourceRef publishAgentInstructions(String tenantId, String workspaceId, String sessionId, String instructions) { - validateScope(tenantId, workspaceId, sessionId); - ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); - byte[] bytes = instructions.getBytes(StandardCharsets.UTF_8); - if (bytes.length > ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES) { - throw payloadTooLarge("Agent instructions exceed 64 KiB."); - } - return publishAgentResource(tenantId, workspaceId, sessionId, - "managed-agent-instructions", "agent-instructions-", bytes); + return resourceWrites.publishAgentInstructions(tenantId, workspaceId, sessionId, instructions); } @Transactional public ToolResultResourceRef publishAgentSkills(String tenantId, String workspaceId, String sessionId, List> skills) { - validateScope(tenantId, workspaceId, sessionId); - ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); - return publishAgentResource(tenantId, workspaceId, sessionId, - "managed-agent-skills", "agent-skills-", AgentDefinitionExtensions.skillsBytes(skills)); + return resourceWrites.publishAgentSkills(tenantId, workspaceId, sessionId, skills); } @Transactional public ToolResultResourceRef publishInheritedExtensionResource(String tenantId, String workspaceId, String sessionId, StoredResource source) { - validateScope(tenantId, workspaceId, sessionId); - if (ManagedNativeJournalSnapshots.isSnapshotId(source.resourceId())) { - throw invalid("Native snapshot resources require fenced snapshot publication."); - } - ManagedLegacySessionGuard.requireLegacyMutation(jdbc, tenantId, sessionId); - if (!Set.of("managed-extension-config-bundle", "managed-extension-contributions", "managed-extension-contributions-part").contains(source.kind()) - || source.schemaVersion() != 1 || source.byteLength() > ManagedSessionStoreModels.MAX_INLINE_RESOURCE_BYTES - || source.bytes().length != source.byteLength() || !sha256(source.bytes()).equals(source.digest())) { - throw invalid("Invalid inherited extension resource"); - } - return publishAgentResource(tenantId, workspaceId, sessionId, source.kind(), source.bytes(), source.resourceId()); - } - - private ToolResultResourceRef publishAgentResource(String tenantId, - String workspaceId, String sessionId, String kind, String prefix, byte[] bytes) { - return publishAgentResource(tenantId, workspaceId, sessionId, kind, bytes, prefix + sha256(bytes)); - } - - private ToolResultResourceRef publishAgentResource(String tenantId, String workspaceId, String sessionId, - String kind, byte[] bytes, String resourceId) { - String digest = sha256(bytes); - String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - Timestamp now = databaseNow(); - ResourceRow existing = findResource(scopeKey, resourceId); - if (existing == null) { - jdbc.update( - "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id," - + " workspace_id, session_id, resource_id, kind, schema_version," - + " byte_length, sha256, storage_kind, inline_bytes, publish_command_id," - + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?," - + " ?, 1, ?, ?, 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resourceId), - scopeKey, - tenantId, - workspaceId, - sessionId, - resourceId, - kind, - bytes.length, - digest, - bytes, - resourceId, - now.getTime(), - now.getTime()); - } else { - requireResourceScope(existing, tenantId, workspaceId, sessionId, resourceId); - verifyStoredResource(existing); - if (!kind.equals(existing.kind()) - || existing.schemaVersion() != 1 - || existing.byteLength() != bytes.length - || !digest.equals(existing.digest())) { - throw conflict("managed_session_resource_conflict", - "Agent configuration resource does not match."); - } - } - return new ToolResultResourceRef(resourceId, kind, 1, bytes.length, digest); + return resourceWrites.publishInheritedExtensionResource(tenantId, workspaceId, sessionId, source); } @Transactional public ToolResultResourceRef publishToolResult(String tenantId, String sessionId, String writerToken, PublishToolResultRequest request) { - return publishToolResult(tenantId, sessionId, writerToken, request, null); + return resourceWrites.publishToolResult(tenantId, sessionId, writerToken, request); } @Transactional public ToolResultResourceRef publishToolResult(String tenantId, String sessionId, String writerToken, PublishToolResultRequest request, com.alibaba.qwen.code.runtimebroker.RuntimeLifecycleAuthority authority) { - validateScope(tenantId, request.workspaceId(), sessionId); - requireCredential(tenantId, request.workspaceId(), sessionId, - writerToken); - checkLifecycleWriter(tenantId, sessionId, authority); - validateStableId(request.writerId(), "writerId"); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - validateStableId(request.resourceId(), "resourceId"); - if (ManagedNativeJournalSnapshots.isSnapshotId(request.resourceId())) { - throw invalid("Native snapshot resources require fenced snapshot publication."); - } - validateDigest(request.digest(), "resource digest", false); - int limit = toolResultLimit(request.kind()); - if (limit == 0 || request.schemaVersion() != 1 || request.byteLength() < 1) { - throw invalid("Tool result resource kind, version or length is invalid."); - } - if (request.byteLength() > limit || request.bytesBase64() != null - && request.bytesBase64().length() > 4 * ((limit + 2) / 3)) { - throw payloadTooLarge("Tool result resource exceeds its byte limit."); - } - byte[] bytes = decodeBase64(request.bytesBase64(), limit, "resource bytesBase64"); - if (bytes.length != request.byteLength() || !sha256(bytes).equals(request.digest())) { - throw invalid("Tool result bytes do not match their length or digest."); - } - requirePhysicalSessionOpen(tenantId, request.workspaceId(), sessionId); - // Tool-result publication lane: keeps the tenant CAS row before the - // Session root the CSI check locks and before the head row, the - // order retention reads take (CAS row, root, head) and the same as - // before #13800 decoupled ordinary traffic. - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - // After both bounded waits, so the CSI check's read does not pin this - // transaction's snapshot ahead of them. - requirePlacement(tenantId, request.workspaceId(), sessionId, writerToken, request.writerId(), false); - var continuation = lockCsiContinuation(tenantId, sessionId); - var csiOriginal = continuation == null ? null : continuation.original(); - if (csiOriginal != null) { - csiOriginal.requireContinuation(); - } - HeadRow head = requireHeadForUpdate(tenantId, sessionId); - requireHeadScope(head, tenantId, request.workspaceId(), sessionId); - // Unlike the other writer paths, a v2 claim fences this lane even - // for the claiming lifecycle authority: tool results are never - // published against a Session claimed under lifecycle protocol v2. - requireNoLifecycleClaim(tenantId, sessionId); - requireCsiWriter(csiOriginal, head); - Timestamp now = databaseNow(); - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, now, true); - requireInstalledCsiWriter(csiOriginal); - String scopeKey = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenantId, sessionId); - ResourceRow existing = findResource(scopeKey, request.resourceId()); - requireCsiWriterCurrent( - continuation, head, request.writerId(), request.writerGeneration(), writerToken); - if (existing == null) { - jdbc.update( - "INSERT INTO ma_resource (resource_key, session_scope_key, tenant_id," - + " workspace_id, session_id, resource_id, kind, schema_version," - + " byte_length, sha256, storage_kind, inline_bytes, publish_command_id," - + " state, gmt_create, last_verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?," - + " ?, 'MYSQL_INLINE', ?, ?, 'PUBLISHED', ?, ?)", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, request.resourceId()), - scopeKey, - tenantId, - request.workspaceId(), - sessionId, - request.resourceId(), - request.kind(), - request.schemaVersion(), - request.byteLength(), - request.digest(), - bytes, - request.resourceId(), - now.getTime(), - now.getTime()); - } else { - requireResourceScope(existing, tenantId, request.workspaceId(), sessionId, request.resourceId()); - verifyStoredResource(existing); - if (!request.kind().equals(existing.kind()) || request.schemaVersion() != existing.schemaVersion() - || request.byteLength() != existing.byteLength() || !request.digest().equals(existing.digest())) { - throw conflict( - "managed_session_resource_conflict", - "A resourceId was reused with different content metadata."); - } - } - requireCsiCurrentTime(continuation); - return new ToolResultResourceRef(request.resourceId(), request.kind(), request.schemaVersion(), - request.byteLength(), request.digest()); + return resourceWrites.publishToolResult(tenantId, sessionId, writerToken, request, authority); } static int toolResultLimit(String kind) { - if (kind == null) { - return 0; - } - return switch (kind) { - case "managed-tool-result-content" -> 1024 * 1024; - case "managed-tool-result-page" -> 256 * 1024; - case "managed-tool-result-manifest" -> 64 * 1024; - default -> 0; - }; - } - - /** A committed resource of this transaction's Session, verified. */ - private StoredResource storedResource(String scopeKey, String tenantId, - String workspaceId, String sessionId, String resourceId) { - ResourceRow resource = findResource(scopeKey, resourceId); - if (resource == null) { - throw conflict(ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, - "A referenced Managed Session resource is missing."); - } - requireResourceScope(resource, tenantId, workspaceId, sessionId, - resourceId); - if (!"REFERENCED".equals(resource.state()) - && !("PUBLISHED".equals(resource.state()) - && Set.of("managed-agent-instructions", "managed-agent-skills").contains(resource.kind()))) { - throw conflict(ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, - "A referenced Managed Session resource is missing."); - } - verifyStoredResource(resource); - return new StoredResource(resource.resourceId(), resource.kind(), - resource.schemaVersion(), resource.byteLength(), - resource.digest(), resource.bytes()); - } - - private void persistTransaction( - String tenantId, - String sessionId, - String writerTokenHash, - CommitTransactionRequest request, - long revision, - ValidatedCommit validated, - Timestamp now) { - jdbc.update( - "INSERT INTO ma_session_journal_tx (tenant_id, workspace_id, session_id, journal_revision, command_key_hash, transaction_id, operation, command_id, content_digest, first_sequence, last_sequence, event_count, events_digest, previous_commit_digest, commit_digest, writer_generation, writer_id, writer_token_hash, activation_epoch, latest_checkpoint_resource_id, record_encoding, record_bytes, byte_length, record_digest, gmt_create) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'identity', ?, ?, ?, ?)", - tenantId, - request.workspaceId(), - sessionId, - revision, - commandKeyHash(request.operation(), request.commandId()), - request.transactionId(), - request.operation(), - request.commandId(), - request.contentDigest(), - request.firstSequence(), - request.lastSequence(), - request.eventCount(), - request.eventsDigest(), - request.previousCommitDigest(), - request.commitDigest(), - request.writerGeneration(), - request.writerId(), - writerTokenHash, - request.activationEpoch(), - request.latestCheckpointResourceId(), - validated.recordBytes(), - validated.recordBytes().length, - validated.recordDigest(), - now.getTime()); + return JournalStoreRows.toolResultLimit(kind); } record TerminalCandidate(CommitTransactionRequest request, @@ -2581,1351 +314,44 @@ public class ManagedSessionStore void requireCsiApplicationCredential(java.sql.Connection target, com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, String writerToken) throws SQLException { - authority.requireConnection(target); - if (!org.springframework.jdbc.datasource.DataSourceUtils.isConnectionTransactional( - org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(target), jdbc.getDataSource())) { - throw nativeReadConflict(); - } - var scope = authority.original().request().getScope(); - String session = authority.original().request().getIsolationKey(); - requireCredential(scope.getTenantId(), scope.getWorkspaceId(), session, writerToken); - requireCsiJournalIsolation(); - HeadRow head = requireHeadForUpdate(scope.getTenantId(), session); - requireHeadScope(head, scope.getTenantId(), scope.getWorkspaceId(), session); - requireCsiWriter(authority.original(), head); - if (!secureEquals(head.leaseTokenHash(), tokenHash(writerToken))) { - throw nativeReadConflict(); - } + fencing.requireCsiApplicationCredential(target, authority, writerToken); } void requireCsiHistoricalCredential(String tenant, String workspace, String session, String token, String retainedHash) { - requireCredential(tenant, workspace, session, token); - String presented = tokenHash(token); - if (retainedHash == null || !retainedHash.matches("[0-9a-f]{64}") || !secureEquals(retainedHash, presented)) { - throw nativeReadConflict(); - } + fencing.requireCsiHistoricalCredential(tenant, workspace, session, token, retainedHash); } TerminalCandidate preflightCsiTerminal(java.sql.Connection target, com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, String writerToken, byte[] exactBody, JdbcRuntimeBindingRepository bindings) throws SQLException { - authority.requirePreCut(target); - if (!org.springframework.jdbc.datasource.DataSourceUtils.isConnectionTransactional( - org.springframework.jdbc.datasource.DataSourceUtils.getTargetConnection(target), jdbc.getDataSource()) - || bindings == null || !bindings.usesDataSource(jdbc.getDataSource()) - || exactBody == null || exactBody.length == 0 || exactBody.length > 128 * 1024) { - throw nativeReadConflict(); - } - var original = authority.original(); - CommitTransactionRequest request = csiTerminalRequest(exactBody); - String tenant = original.request().getScope().getTenantId(); - String session = original.request().getIsolationKey(); - validateScope(tenant, request.workspaceId(), session); - requireCredential(tenant, request.workspaceId(), session, writerToken); - requireCsiJournalIsolation(); - validateCommit(request); - HeadRow head = requireHeadForUpdate(tenant, session); - requireHeadScope(head, tenant, request.workspaceId(), session); - requireCsiWriter(original, head); - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); - validateHead(head, request); - var proof = JdbcCsiActivationAdmission.terminalPreflight(target, authority, CSI_JSON.valueToTree(request), bindings); - return new TerminalCandidate(request, proof, head.leaseTokenHash()); + return commits.preflightCsiTerminal(target, authority, writerToken, exactBody, bindings); } String commitCsiTerminal(java.sql.Connection target, com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Authority authority, String writerToken, byte[] exactBody, JdbcRuntimeBindingRepository bindings) throws SQLException { - authority.requireConnection(target); - if (authority.stage() != com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationRetirement.Stage.FROZEN) { - throw nativeReadConflict(); - } - var frozen = WorkspaceCsiApplicationJson.object(authority.frozenBytes(), WorkspaceCsiApplicationInventory.MAX_BYTES); - byte[] saved = WorkspaceCsiApplicationJson.base64(frozen.path("commitBodyBase64"), 128 * 1024); - if (!java.util.Arrays.equals(saved, exactBody) - || !com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof.sha256(saved) - .equals(frozen.path("preparedCommitSha256").textValue())) { - throw nativeReadConflict(); - } - var candidate = preflightCsiTerminal(target, authority, writerToken, exactBody, bindings); - var request = candidate.request(); - String tenant = authority.original().request().getScope().getTenantId(); - String session = authority.original().request().getIsolationKey(); - HeadRow head = requireHeadForUpdate(tenant, session); - var validated = validateCommit(request); - Timestamp now = databaseNow(); - String writtenAt = now.toInstant().toString(); - long revision = increment(head.journalRevision(), "journal revision"); - Runnable fence = () -> { - try { - authority.requireCurrentTime(target); - } catch (SQLException error) { - throw new org.springframework.dao.DataAccessResourceFailureException("Original terminal time is unavailable", error); - } - requireWriter(head, request.writerId(), request.writerGeneration(), writerToken, databaseNow(), true); - }; - commitResources(com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session), tenant, session, request, revision, now, validated.resources(), fence); - fence.run(); - persistTransaction(tenant, session, candidate.writerTokenSha256(), request, revision, validated, now); - fence.run(); - int changed = jdbc.update("UPDATE ma_session_head SET state = 'SEALED'," - + " journal_revision = ?, wake_head_revision = CASE WHEN wake_head_revision = ? THEN ? ELSE wake_head_revision END, committed_sequence = ?, last_commit_digest = ?," - + " activation_phase = 'released', activation_head_revision = ?, gmt_modified = ?" - + " WHERE head_kind = 'MANAGED_SESSION' AND tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND state = 'ACTIVE'" - + " AND journal_revision = ? AND committed_sequence = ? AND last_commit_digest = ?", - revision, head.journalRevision(), revision, candidate.proof().terminalSequence(), request.commitDigest(), revision, now.getTime(), - JdbcRuntimeBindingRepository.harnessDrainKey(tenant), JdbcRuntimeBindingRepository.harnessDrainKey(session), tenant, session, - head.journalRevision(), head.committedSequence(), head.lastCommitDigest()); - if (changed != 1) { - throw nativeReadConflict(); - } - authority.requireCurrentTime(target); - return writtenAt; - } - - private static CommitTransactionRequest csiTerminalRequest(byte[] bytes) { - WorkspaceCsiApplicationJson.object(bytes, 128 * 1024); - try { - return CSI_TERMINAL_JSON.readValue(bytes, CommitTransactionRequest.class); - } catch (IOException malformed) { - throw invalid("Invalid original terminal request."); - } + return commits.commitCsiTerminal(target, authority, writerToken, exactBody, bindings); } static CommitTransactionRequest validateCsiTerminalBytes(byte[] bytes) { - var request = csiTerminalRequest(bytes); - validateCommit(request); - return request; - } - - private void validatePagedCheckpoint(StoredResource stored, String tenant, String workspace, String session, - ManagedHostedRecoveryRecords.HistoricalResources resources) { - JsonNode body = com.alibaba.qwen.code.managedagent.repository.ManagedExtensionRecordStore.parse(new String(stored.bytes(), StandardCharsets.UTF_8)); - if (body == null || !ManagedCheckpointHistory.paged(body)) { - return; - } - if (!ManagedCheckpointHistory.PROFILE.equals(body.path("storageProfile").asText())) { - throw invalid("Unsupported checkpoint storage profile."); - } - JsonNode history = body.path("history"); - ManagedExtensionRecords.closed(history, Set.of("ref", "toolCount", "bindingCount"), "checkpoint history"); - resources.checkpointHistory(history.path("ref")); - StoredResource page = resources.apply(history.path("ref").path("resourceId").asText()); - JsonNode saved = ManagedCheckpointHistory.page(page.bytes()); - if (!saved.path("sessionKey").equals(body.path("identity").path("sessionKey")) - || !saved.path("turnId").equals(body.path("identity").path("turnId")) - || !saved.path("promptId").equals(body.path("identity").path("promptId")) - || !saved.path("toolCount").equals(history.path("toolCount")) - || !saved.path("bindingCount").equals(history.path("bindingCount"))) { - throw invalid("Checkpoint history identity or coverage conflicts."); - } - } - - private void validateCheckpointHistory(JsonNode ref, String tenant, String workspace, String session, - long previousRevision, Set declared, java.util.function.Function resources) { - try { - ManagedCheckpointHistory.pageRef(ref); - StoredResource stored = resources.apply(ref.path("resourceId").asText()); - requireCheckpointResource(ref, stored); - JsonNode page = ManagedCheckpointHistory.page(stored.bytes()); - JsonNode key = page.path("sessionKey"); - if (!tenant.equals(key.path("tenantId").asText()) || !workspace.equals(key.path("workspaceId").asText()) - || !session.equals(key.path("sessionId").asText())) { - throw invalid("Checkpoint history belongs to another Session."); - } - Long origin = priorCheckpointResourceRevision(tenant, workspace, session, stored.resourceId(), previousRevision); - long ceiling = origin == null ? previousRevision : origin; - for (JsonNode nested : checkpointReferences(page)) { - StoredResource dependency = resources.apply(nested.path("resourceId").asText()); - requireCheckpointResource(nested, dependency); - Long owned = priorCheckpointResourceRevision(tenant, workspace, session, dependency.resourceId(), ceiling); - if (owned == null && (origin != null || !declared.contains(dependency.resourceId()))) { - throw invalid("Checkpoint history dependency has no committed owner."); - } - } - long tools = page.path("toolCount").longValue() - page.path("items").size(); - long bindings = page.path("bindingCount").longValue() - page.path("bindings").size(); - JsonNode previous = page.path("previous"); - if (tools < 0 || bindings < 0) { - throw invalid("Checkpoint history count conflicts."); - } - if (previous.isNull()) { - if (tools != 0 || bindings != 0) { - throw invalid("Checkpoint history predecessor is missing."); - } - } else { - JsonNode predecessor = ManagedCheckpointHistory.page(resources.apply(previous.path("resourceId").asText()).bytes()); - if (!predecessor.path("sessionKey").equals(key) - || !predecessor.path("turnId").equals(page.path("turnId")) - || !predecessor.path("promptId").equals(page.path("promptId")) - || predecessor.path("toolCount").longValue() != tools - || predecessor.path("bindingCount").longValue() != bindings) { - throw invalid("Checkpoint history predecessor conflicts."); - } - } - } catch (IllegalArgumentException | com.alibaba.qwen.code.runtimebroker.RuntimeBrokerException error) { - throw invalid("Invalid checkpoint history page."); - } - } - - private static void requireCheckpointResource(JsonNode ref, StoredResource stored) { - ManagedExtensionRecords.durableRef(ref, "checkpoint history dependency"); - if (!stored.kind().equals(ref.path("kind").asText()) - || stored.schemaVersion() != ref.path("schemaVersion").intValue() - || stored.byteLength() != ref.path("byteLength").longValue() - || !stored.digest().equals(ref.path("digest").asText()) - || stored.bytes() == null || stored.bytes().length != stored.byteLength() - || !ToolPublicationContract.sha256(stored.bytes()).equals(stored.digest())) { - throw invalid("Checkpoint history resource metadata conflicts."); - } - } - - private static List checkpointReferences(JsonNode value) { - List result = new ArrayList<>(); - if (value.isObject() && value.has("resourceId")) { - result.add(value); - } else if (value.isContainerNode()) { - for (JsonNode child : value) { - result.addAll(checkpointReferences(child)); - } - } - return result; - } - - private Long priorCheckpointResourceRevision(String tenant, String workspace, String session, - String resourceId, long maximum) { - String scope = com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, session); - List origins = jdbc.query("SELECT x.owner_revision FROM ma_resource_ref x JOIN ma_session_journal_tx t" - + " ON t.tenant_id = x.tenant_id AND t.workspace_id = x.workspace_id AND t.session_id = x.session_id" - + " AND t.journal_revision = x.owner_revision WHERE x.resource_key = ? AND x.session_scope_key = ?" - + " AND x.tenant_id = ? AND x.workspace_id = ? AND x.session_id = ? AND x.resource_id = ?" - + " AND x.owner_kind = 'JOURNAL' AND x.owner_key = ? AND x.owner_revision = x.journal_revision" - + " AND x.owner_revision <= ? ORDER BY x.owner_revision LIMIT 1", - (row, index) -> row.getLong(1), com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scope, resourceId), scope, tenant, workspace, session, - resourceId, scope, maximum); - return origins.isEmpty() ? null : origins.getFirst(); - } - - private void commitResources(String scopeKey, String tenantId, - String sessionId, CommitTransactionRequest request, - long revision, Timestamp now, - List resources, - Runnable writeFence) { - Set resourceIds = new HashSet<>(); - for (ValidatedResource input : resources) { - CommitResource resource = input.resource(); - if (!resourceIds.add(resource.resourceId())) { - throw invalid("resources contains a duplicate resourceId."); - } - ResourceRow existing = findResource(scopeKey, - resource.resourceId()); - writeFence.run(); - if (existing == null) { - if (input.bytes() == null) { - throw conflict( - ManagedSessionStoreModels.ERROR_RESOURCE_MISSING, - "A referenced Managed Session resource is" - + " missing."); - } - jdbc.update("INSERT INTO ma_resource" - + " (resource_key, session_scope_key, tenant_id," - + " workspace_id, session_id, resource_id," - + " kind, schema_version, byte_length, sha256," - + " storage_kind, inline_bytes," - + " publish_command_id, state, gmt_create," - + " last_verified_at) VALUES (?, ?, ?, ?, ?, ?," - + " ?, ?, ?, ?, 'MYSQL_INLINE', ?, ?," - + " 'REFERENCED', ?, ?)", - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resource.resourceId()), scopeKey, tenantId, request.workspaceId(), sessionId, - resource.resourceId(), resource.kind(), - resource.schemaVersion(), resource.byteLength(), - resource.digest(), input.bytes(), request.commandId(), - now.getTime(), now.getTime()); - } else { - requireResourceScope(existing, tenantId, - request.workspaceId(), sessionId, - resource.resourceId()); - verifyStoredResource(existing); - if (!resource.kind().equals(existing.kind()) - || resource.schemaVersion() - != existing.schemaVersion() - || resource.byteLength() != existing.byteLength() - || !resource.digest().equals(existing.digest())) { - throw conflict("managed_session_resource_conflict", - "A resourceId was reused with different" - + " content metadata."); - } - } - jdbc.update( - "INSERT INTO ma_resource_ref (session_scope_key, owner_kind, owner_key," - + " owner_revision, resource_key, tenant_id, workspace_id, session_id," - + " journal_revision, resource_id, gmt_create) VALUES (?, 'JOURNAL', ?, ?," - + " ?, ?, ?, ?, ?, ?, ?)", - scopeKey, - scopeKey, - revision, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resource.resourceId()), - tenantId, - request.workspaceId(), - sessionId, - revision, - resource.resourceId(), - now.getTime()); - } - } - - private static ValidatedCommit validateCommit( - CommitTransactionRequest request) { - validateStableId(request.transactionId(), "transactionId"); - validateText(request.operation(), "operation", MAX_TEXT_BYTES); - validateStableId(request.commandId(), "commandId"); - validateDigest(request.contentDigest(), "contentDigest", false); - validateDigest(request.eventsDigest(), "eventsDigest", true); - validateDigest(request.previousCommitDigest(), - "previousCommitDigest", true); - validateDigest(request.commitDigest(), "commitDigest", true); - validateDigest(request.recordDigest(), "recordDigest", false); - validateCounter(request.writerGeneration(), "writerGeneration", 1); - validateCounter(request.expectedJournalRevision(), - "expectedJournalRevision", 0); - validateCounter(request.expectedCommittedSequence(), - "expectedCommittedSequence", 0); - validateCounter(request.firstSequence(), "firstSequence", 0); - validateCounter(request.lastSequence(), "lastSequence", 0); - validateCounter(request.activationEpoch(), "activationEpoch", 0); - if (request.latestCheckpointResourceId() != null) { - validateStableId(request.latestCheckpointResourceId(), - "latestCheckpointResourceId"); - } - if (request.eventCount() < 0 - || request.eventCount() - > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS - || request.recordCount() < 1 - || request.recordCount() - > ManagedSessionStoreModels.MAX_TRANSACTION_EVENTS - + 1) { - throw invalid("eventCount or recordCount is outside its bound."); - } - byte[] recordBytes = decodeBase64(request.recordBytesBase64(), - ManagedSessionStoreModels.MAX_TRANSACTION_BYTES, - "recordBytesBase64"); - if (!sha256(recordBytes).equals(request.recordDigest())) { - throw invalid("recordDigest does not match recordBytesBase64."); - } - validateUtf8JsonLines(recordBytes, request.recordCount()); - if ("session.create".equals(request.operation())) { - if (request.eventCount() != 0 || request.firstSequence() != 0 - || request.lastSequence() != 0 - || request.recordCount() != 2 - || request.eventsDigest() != null - || request.previousCommitDigest() != null - || request.commitDigest() != null - || request.latestCheckpointResourceId() != null) { - throw invalid("The session.create transaction must use" - + " sequence zero and no commit digest chain."); - } - } else { - if (request.eventCount() < 1 - || request.firstSequence() < 1 - || request.lastSequence() < request.firstSequence() - || request.lastSequence() - request.firstSequence() + 1 - != request.eventCount() - || request.eventsDigest() == null - || request.commitDigest() == null - || request.recordCount() != request.eventCount() + 1) { - throw invalid("The transaction event range or record count" - + " is invalid."); - } - } - List resources = new ArrayList<>(); - long inlineBytes = 0; - List requestedResources = request.resources() == null - ? List.of() : request.resources(); - if (requestedResources.size() - > ManagedSessionStoreModels.MAX_RESOURCES_PER_TRANSACTION) { - throw invalid("resources exceeds its item limit."); - } - for (CommitResource resource : requestedResources) { - if (resource == null) { - throw invalid("resources must not contain null entries."); - } - validateStableId(resource.resourceId(), "resourceId"); - validateStableId(resource.kind(), "resource kind"); - if (ManagedNativeJournalSnapshots.isSnapshotKind(resource.kind()) - || ManagedNativeJournalSnapshots.isSnapshotId(resource.resourceId())) { - throw invalid("Native snapshot resources require fenced snapshot publication."); - } - validateDigest(resource.digest(), "resource digest", false); - if (resource.schemaVersion() < 1 || resource.byteLength() < 0) { - throw invalid("Resource version or length is invalid."); - } - int maxResourceBytes = ManagedSessionStoreModels.inlineResourceBytes(resource.kind()); - if (resource.byteLength() > maxResourceBytes - && !("managed-tool-outcome".equals(resource.kind()) - && resource.bytesBase64() == null)) { - throw new ManagedAgentException(501, - ManagedSessionStoreModels.ERROR_OSS_DISABLED, - "Resources larger than " + (maxResourceBytes / 1024) + " KiB require the disabled" - + " OSS storage path."); - } - byte[] bytes = resource.bytesBase64() == null ? null - : decodeBase64(resource.bytesBase64(), - maxResourceBytes, - "resource bytesBase64"); - if (bytes != null && (bytes.length != resource.byteLength() - || !sha256(bytes).equals(resource.digest()))) { - throw invalid("Resource bytes do not match their length or" - + " digest."); - } - if (bytes != null) { - inlineBytes += bytes.length; - if (inlineBytes - > ManagedSessionStoreModels.MAX_INLINE_TRANSACTION_BYTES) { - throw payloadTooLarge("Inline resources exceed their" - + " transaction byte limit."); - } - } - resources.add(new ValidatedResource(resource, bytes)); - } - if (request.latestCheckpointResourceId() != null - && resources.stream().map(ValidatedResource::resource) - .noneMatch(resource -> request - .latestCheckpointResourceId() - .equals(resource.resourceId()) - && "managed-checkpoint" - .equals(resource.kind()))) { - throw invalid("latestCheckpointResourceId must identify a" - + " managed-checkpoint resource in this transaction."); - } - return new ValidatedCommit(recordBytes, request.recordDigest(), - resources); - } - - private void validateHead(HeadRow head, - CommitTransactionRequest request) { - if (request.expectedJournalRevision() != head.journalRevision() - || request.expectedCommittedSequence() - != head.committedSequence() - || !Objects.equals(request.previousCommitDigest(), - head.lastCommitDigest())) { - throw conflict("managed_session_head_conflict", - "The Managed Session head changed; reload before" - + " retrying."); - } - if (request.activationEpoch() < head.activationEpoch()) { - throw conflict("managed_session_activation_conflict", - "The activation epoch is stale."); - } - if (!"READY".equals(head.recoveryStatus())) { - throw conflict("managed_session_recovery_blocked", - "The Managed Session is blocked from new commits."); - } - if ("session.create".equals(request.operation())) { - if (head.journalRevision() != 0 - || head.committedSequence() != 0) { - throw conflict("managed_session_head_conflict", - "The Managed Session genesis is already committed."); - } - } else if (head.journalRevision() == 0 - || request.firstSequence() - != increment(head.committedSequence(), - "committed sequence")) { - throw conflict("managed_session_head_conflict", - "The transaction does not extend the committed" - + " sequence."); - } - } - - private CommitReceipt replay(TransactionRow existing, - CommitTransactionRequest request, String recordDigest, - String writerToken) { - if (!existing.operation().equals(request.operation()) - || !existing.commandId().equals(request.commandId()) - || !existing.transactionId() - .equals(request.transactionId()) - || !existing.contentDigest().equals(request.contentDigest()) - || !existing.recordDigest().equals(recordDigest) - || existing.firstSequence() != request.firstSequence() - || existing.lastSequence() != request.lastSequence() - || existing.eventCount() != request.eventCount() - || !Objects.equals(existing.eventsDigest(), - request.eventsDigest()) - || !Objects.equals(existing.previousCommitDigest(), - request.previousCommitDigest()) - || existing.writerGeneration() - != request.writerGeneration() - || !existing.writerId().equals(request.writerId()) - || !secureEquals(existing.writerTokenHash(), - tokenHash(writerToken)) - || existing.activationEpoch() != request.activationEpoch() - || !Objects.equals(existing.latestCheckpointResourceId(), - request.latestCheckpointResourceId()) - || !Objects.equals(existing.commitDigest(), - request.commitDigest())) { - throw conflict( - ManagedSessionStoreModels.ERROR_IDEMPOTENCY_CONFLICT, - "The command key was reused with different content."); - } - return new CommitReceipt(existing.journalRevision(), - existing.transactionId(), existing.commandId(), - existing.operation(), existing.firstSequence(), - existing.lastSequence(), existing.lastSequence(), - existing.commitDigest(), true); - } - - private void requireWriter(HeadRow head, String writerId, - long writerGeneration, String writerToken, Timestamp now, - boolean requireUnexpired) { - if (!"ACTIVE".equals(head.state()) - || writerGeneration != head.writerGeneration() - || !writerId.equals(head.writerId()) - || !secureEquals(tokenHash(writerToken), - head.leaseTokenHash()) - || requireUnexpired && (head.writerLeaseUntil() == null - || !head.writerLeaseUntil().after(now))) { - throw writerConflict(); - } - } - - private void requireReadGrant(HeadRow head, String writerToken) { - ToolPublicationRetentionStore.requireLive(jdbc, head.tenantId(), head.sessionId()); - Timestamp now = databaseNow(); - if (!"ACTIVE".equals(head.state()) - || !secureEquals(tokenHash(writerToken), - head.leaseTokenHash()) - || head.writerLeaseUntil() == null - || !head.writerLeaseUntil().after(now)) { - throw writerConflict(); - } - } - - /** - * Converts the last activation.changed payload of a committed transaction, so the head row can - * answer authorization reads without rescanning the journal. The payload was already parsed by - * the extension record pass of this commit. - */ - private static ActivationChange activationChange(JsonNode payload) { - if (payload == null) { - return null; - } - return new ActivationChange( - ToolPublicationContract.text(payload, "activationId"), - ToolPublicationContract.text(payload, "phase"), - payload.path("epoch").asLong(), - ManagedExtensionRecords.millisLenient( - payload.path("expiresAt"))); - } - - private HeadRow findHeadForUpdate(String tenantId, String sessionId) { - // Ordinary writers serialize on their own Session's rows only: the - // Session root, then the head — the per-Session order retirement - // also takes behind its tenant guard. Both are locking reads, so - // neither pins this transaction's REPEATABLE READ snapshot, and - // requireLive below reads after the waits end. The tenant-wide CAS - // row stays with explicit publication paths so ordinary writes never - // queue on it (#13800). - ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); - List rows = - jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION' FOR UPDATE", - headMapper, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); - return rows.isEmpty() ? null : rows.get(0); - } - - private HeadRow requireHeartbeatHead(String tenantId, String sessionId) { - // An ordinary heartbeat holds no tenant fence (#13800): retirement - // serializes with it on the Session root, which the CSI check locked - // before its first consistent read, and on the head row, locked here - // before requireLive reads. - var heads = - jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" - + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'" - + " FOR UPDATE", - headMapper, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - ToolPublicationRetentionStore.requireLive(jdbc, tenantId, sessionId); - if (heads.isEmpty()) { - throw sessionNotFound(); - } - return heads.getFirst(); + return JournalCommit.validateCsiTerminalBytes(bytes); } JdbcCsiFilesRetirementGuard.Original lockCsiOriginal(String tenantId, String sessionId) { - var continuation = lockCsiContinuation(tenantId, sessionId); - return continuation == null ? null : continuation.original(); + return fencing.lockCsiOriginal(tenantId, sessionId); } - private CsiContinuation lockCsiContinuation(String tenantId, String sessionId) { - Boolean pinned = jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - var target = - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection); - if (!org.springframework.jdbc.datasource.DataSourceUtils - .isConnectionTransactional(target, jdbc.getDataSource())) { - var privatePins = - jdbc.query( - "SELECT csi_guard FROM ma_session WHERE tenant_id =" - + " ? AND session_id = ? AND csi_guard = TRUE", - (row, index) -> row.getBoolean("csi_guard"), - tenantId, - sessionId); - if (!privatePins.isEmpty()) { - throw new IllegalStateException( - "CSI mutation requires its original transaction" - + " connection"); - } - return null; - } - // An ordinary Session stops here, before the - // tenant-wide guard rows and the slot/binding - // range locks: taking them on every writer path - // would couple per-turn traffic of unrelated - // Sessions again (#13800). The root is locked - // first, by primary key once it exists, so its pin - // is read as committed now even under an older - // REPEATABLE READ snapshot, no gap is taken that - // another tenant's first writer inserts into, and - // no consistent read below runs before this wait. - // The runtime rows are then read plainly: a CSI - // slot or binding is only created under a pinned - // root, a pin never changes after its row is - // inserted, and roots are never deleted, so an - // unpinned root can only have legacy markers, - // which no snapshot here is older than. - return ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId) - || JdbcCsiFilesRetirementGuard.hasCsiRuntimeMarker( - target, tenantId, sessionId); - }); - if (!Boolean.TRUE.equals(pinned)) { - return null; - } - // A pinned Session takes the tenant guards behind its root, bounded - // and named like every other per-turn lane: on renew, commit and - // recovery state these are the first tenant-wide waits (#13800). - // Every other lane takes the guards before the root, so a guard-first - // operation on this same Session meanwhile resolves as an InnoDB - // deadlock. Either side may be rolled back: this side's waits are - // named by lockBounded, the other side's root wait by - // namedRootDeadlock (#13800 R1-1). - lockBounded(() -> WorkspaceLifecycleStore.lockPlacement(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - lockBounded(() -> ToolPublicationRetentionStore.lockTenant(jdbc, tenantId, - GUARD_ACQUIRE_TIMEOUT_SECONDS)); - return jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - var target = - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection); - var original = - JdbcCsiFilesRetirementGuard.lockManagedSession( - target, tenantId, sessionId); - if (original != null) { - requireCsiJournalIsolation(); - } - return original == null - ? null - : new CsiContinuation( - original, - com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationProgress.lock(target, original)); - }); - } - - private void requireCsiJournalIsolation() { - if (journalEventTransport != null) { - throw conflict("csi_journal_transport_unsupported", - "The private CSI lane requires an isolated journal without an event transport."); - } - } - - private void requireCsiCurrentTime(CsiContinuation continuation) { - if (continuation != null) { - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - continuation - .progress() - .requireCurrentTime( - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection)); - return null; - }); - } - } - - private void requireCsiLease( - CsiContinuation continuation, long existingExpiry, long targetExpiry) { - if (continuation != null) { - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - continuation - .progress() - .requireLease( - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection), - existingExpiry, - targetExpiry); - return null; - }); - } - } - - private void requireCsiWriterCurrent( - CsiContinuation continuation, - HeadRow head, - String writerId, - long generation, - String token) { - if (continuation != null) { - requireCsiLease( - continuation, - epochMillis(head.writerLeaseUntil()), - epochMillis(head.writerLeaseUntil())); - requireWriter(head, writerId, generation, token, databaseNow(), true); - } - } - - private void requireCsiReadGrant(CsiContinuation continuation, HeadRow head, String token) { - requireReadGrant(head, token); - requireCsiLease( - continuation, - epochMillis(head.writerLeaseUntil()), - epochMillis(head.writerLeaseUntil())); - } - - private record CsiContinuation( - JdbcCsiFilesRetirementGuard.Original original, - com.alibaba.qwen.code.runtimebroker.persistence.JdbcCsiApplicationProgress.Continuation progress) {} - - private static void requireCsiWriter( - JdbcCsiFilesRetirementGuard.Original original, HeadRow head) { - if (original != null && head.writerGeneration() < 1) { - throw conflict( - "csi_original_writer_unavailable", - "The original CSI writer cannot be replaced."); - } - } - - private void requireInstalledCsiWriter(JdbcCsiFilesRetirementGuard.Original original) { - if (original != null) { - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - JdbcCsiActivationAdmission.lockNativeHead(connection, original); - return null; - }); - } - } - - private void requireCsiHistoryOwner(JdbcCsiFilesRetirementGuard.Original original) { - if (csiBindings == null) { - throw conflict( - "csi_native_identity_unavailable", - "CSI history requires original encrypted Runtime identity"); - } - jdbc.execute( - (org.springframework.jdbc.core.ConnectionCallback) - connection -> { - var target = - org.springframework.jdbc.datasource.DataSourceUtils - .getTargetConnection(connection); - var binding = - csiBindings.findByIdForUpdate(target, original.bindingId()); - if (binding == null - || !binding.getRequest().equals(original.request()) - || binding.getGeneration() != original.generation() - || binding.getVersion() != original.version()) { - throw conflict( - "csi_native_identity_unavailable", - "Original execution bootstrap is unavailable"); - } - WorkspaceCsiRuntimeIdentity.fileHistoryBoot(binding); - return null; - }); - } - - private HeadRow requireHeadForUpdate(String tenantId, String sessionId) { - HeadRow head = findHeadForUpdate(tenantId, sessionId); - if (head == null) { - throw sessionNotFound(); - } - return head; - } - - private HeadRow requireHead(String tenantId, String sessionId) { - List rows = - jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_HEAD.columns() + " FROM ma_session_head WHERE tenant_key = ? AND harness_key = ? AND" - + " tenant_id = ? AND session_id = ? AND head_kind = 'MANAGED_SESSION'", - headMapper, - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - if (rows.isEmpty()) { - throw sessionNotFound(); - } - return rows.get(0); - } - - private TransactionRow findTransactionByCommand(String tenantId, - String sessionId, String commandKeyHash) { - List rows = jdbc.query( - "SELECT " + ManagedSchemaProjection.SESSION_JOURNAL_TX.columns() + " FROM ma_session_journal_tx" - + " WHERE tenant_id = ? AND session_id = ?" - + " AND command_key_hash = ?", - transactionMapper, tenantId, sessionId, commandKeyHash); - return rows.isEmpty() ? null : rows.get(0); - } - - private ResourceRow findResource(String scopeKey, String resourceId) { - return findResource(scopeKey, resourceId, true); - } - - private ResourceRow findResource(String scopeKey, String resourceId, boolean lock) { - List rows = - jdbc.query( - "SELECT " + ManagedSchemaProjection.RESOURCE.columns() + " FROM ma_resource WHERE resource_key = ? AND session_scope_key = ?" - + " AND resource_id = ?" + (lock ? " FOR UPDATE" : ""), - resourceMapper, - com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.resourceKey(scopeKey, resourceId), - scopeKey, - resourceId); - return rows.isEmpty() ? null : rows.get(0); - } - - private static TransactionRow transactionRow(ResultSet result) - throws SQLException { - return new TransactionRow(result.getString("workspace_id"), - result.getLong("journal_revision"), - result.getString("transaction_id"), - result.getString("operation"), - result.getString("command_id"), - result.getString("content_digest"), - result.getLong("first_sequence"), - result.getLong("last_sequence"), - result.getInt("event_count"), - result.getString("events_digest"), - result.getString("previous_commit_digest"), - result.getString("commit_digest"), - result.getLong("writer_generation"), - result.getString("writer_id"), - result.getString("writer_token_hash"), - result.getLong("activation_epoch"), - result.getString("latest_checkpoint_resource_id"), - result.getString("record_encoding"), - result.getBytes("record_bytes"), - result.getLong("byte_length"), - result.getString("record_digest")); - } - - private StoredTransaction storedTransaction(TransactionRow row) { - if (!"identity".equals(row.recordEncoding()) - || row.recordBytes() == null - || row.recordBytes().length != row.byteLength() - || !sha256(row.recordBytes()).equals(row.recordDigest())) { - throw journalCorrupt(); - } - return new StoredTransaction(row.journalRevision(), - row.transactionId(), row.operation(), row.commandId(), - row.contentDigest(), row.firstSequence(), - row.lastSequence(), row.eventCount(), row.eventsDigest(), - row.previousCommitDigest(), row.commitDigest(), - row.writerGeneration(), row.activationEpoch(), - row.latestCheckpointResourceId(), - row.recordEncoding(), Base64.getEncoder() - .encodeToString(row.recordBytes()), - row.byteLength(), row.recordDigest()); - } - - /** - * Whether a writer still holds the Session's journal under a lease that has not expired by - * database time. A Harness renews that lease while it holds the Session and seals the writer - * when it closes the Session. - */ @Transactional public boolean hasLiveWriter(String tenantId, String sessionId) { - List leases = - jdbc.query( - "SELECT writer_lease_until FROM ma_session_head WHERE tenant_key = ? AND" - + " harness_key = ? AND tenant_id = ? AND session_id = ? AND state =" - + " 'ACTIVE' FOR UPDATE", - (result, row) -> ManagedSchemaTime.timestamp(result, "writer_lease_until"), - JdbcRuntimeBindingRepository.harnessDrainKey(tenantId), - JdbcRuntimeBindingRepository.harnessDrainKey(sessionId), - tenantId, - sessionId); - return !leases.isEmpty() && leases.getFirst() != null - && leases.getFirst().after(databaseNow()); - } - - private Timestamp databaseNow() { - Timestamp now = jdbc.queryForObject("SELECT " + ManagedSchemaTime.SQL_NOW, - (row, index) -> ManagedSchemaTime.timestamp(row, 1)); - if (now == null) { - throw new IllegalStateException("Database time is unavailable"); - } - return now; - } - - private WriterGrant grant(HeadRow head, Timestamp leaseUntil, - boolean replayed) { - return new WriterGrant(head.writerGeneration(), epochMillis(leaseUntil), - head.journalRevision(), head.committedSequence(), - head.lastCommitDigest(), head.activationEpoch(), replayed); - } - - private static long epochMillis(Timestamp timestamp) { - return timestamp.getTime(); - } - - private static Timestamp plusMillis(Timestamp timestamp, long millis) { - return Timestamp.from(timestamp.toInstant().plusMillis(millis)); - } - - private static Timestamp laterOf(Timestamp left, Timestamp right) { - return left != null && left.after(right) ? left : right; - } - - private static long increment(long value, String label) { - if (value < 0 - || value >= ManagedSessionStoreModels.MAX_SAFE_COUNTER) { - throw conflict("managed_session_counter_exhausted", - "The " + label + " cannot advance."); - } - return value + 1; - } - - private static void validateScope(String tenantId, String workspaceId, - String sessionId) { - validateText(tenantId, "tenantId", 128); - validateKeyComponent(workspaceId, "workspaceId"); - validateKeyComponent(sessionId, "sessionId"); - } - - private static void validateKeyComponent(String value, String label) { - validateStableId(value, label); - if (value.contains("/") || value.contains("\\") - || value.chars().allMatch(character -> character == '.')) { - throw invalid(label + " is not a safe key component."); - } - } - - private static void validateStableId(String value, String label) { - validateText(value, label, MAX_ID_BYTES); - } - - private static void validateText(String value, String label, - int maxBytes) { - if (!isValidText(value, maxBytes)) { - throw invalid(label + " is invalid."); - } - } - - private static boolean isValidText(String value, int maxBytes) { - if (value == null || value.isEmpty() || value.chars().anyMatch(character -> character < 32 || character == 127)) { - return false; - } - try { - return com.alibaba.qwen.code.runtimebroker.schema.ManagedSchemaKeys.identity(value).length <= maxBytes; - } catch (IllegalArgumentException invalidUnicode) { - return false; - } - } - - private static void validateDigest(String value, String label, - boolean nullable) { - if (value == null && nullable) { - return; - } - if (value == null || !DIGEST.matcher(value).matches()) { - throw invalid(label + " must be a lowercase SHA-256 digest."); - } - } - - private static byte[] decodeBase64(String value, int maxBytes, - String label) { - if (value == null) { - throw invalid(label + " is required."); - } - byte[] decoded; - try { - decoded = Base64.getDecoder().decode(value); - } catch (IllegalArgumentException error) { - throw invalid(label + " is not valid Base64."); - } - if (decoded.length > maxBytes) { - throw payloadTooLarge(label + " exceeds its byte limit."); - } - return decoded; - } - - private void verifyStoredResource(ResourceRow resource) { - if (resource.deletionOperation() != null - || resource.captureState() != null && !"VERIFIED".equals(resource.captureState()) - || resource.resourceId().startsWith("capture-segment-") - || resource.resourceId().startsWith("capture-seal-")) { - throw resourceCorrupt(); - } - if ("TOOL_PUBLICATION".equals(resource.storageKind())) { - if (!"REFERENCED".equals(resource.state()) || resource.bytes() != null - || resource.objectKey() == null || resource.objectVersionId() != null - || resource.encryptionKeyId() != null - || !"managed-tool-outcome".equals(resource.kind()) - || resource.byteLength() > 2L * 1024 * 1024) { - throw resourceCorrupt(); - } - Long matching = - jdbc.queryForObject( - "SELECT COUNT(*) FROM ma_output_capture p JOIN ma_resource o ON" - + " o.scope_key = p.scope_key AND o.publication_id =" - + " p.publication_id WHERE p.tenant_id = ? AND p.workspace_id = ?" - + " AND p.session_id = ? AND p.admission_resource_id = ? AND" - + " p.producer_phase IN ('FINISHED', 'REFERENCED') AND" - + " p.quarantined = FALSE AND o.slot_key = X'61646d697373696f6e'" - + " AND o.capture_state = 'VERIFIED' AND o.resource_id = ? AND" - + " o.capture_object_key = ? AND o.sha256 = ? AND o.byte_length =" - + " ?", - Long.class, - resource.tenantId(), - resource.workspaceId(), - resource.sessionId(), - resource.resourceId(), - resource.resourceId(), - resource.objectKey(), - resource.digest(), - resource.byteLength()); - if (matching == null || matching != 1) { - throw resourceCorrupt(); - } - return; - } - if (!"MYSQL_INLINE".equals(resource.storageKind()) - || !("REFERENCED".equals(resource.state()) - || "PUBLISHED".equals(resource.state()) && resource.schemaVersion() == 1 - && (resource.byteLength() > 0 && resource.byteLength() <= toolResultLimit(resource.kind()) - || "managed-agent-instructions".equals(resource.kind()) - && resource.byteLength() >= 0 - && resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_INSTRUCTIONS_BYTES - || "managed-agent-skills".equals(resource.kind()) - && resource.byteLength() > 0 - && resource.byteLength() <= ManagedSessionStoreModels.MAX_AGENT_SKILLS_BYTES)) - || resource.bytes() == null - || resource.objectKey() != null - || resource.objectVersionId() != null - || resource.encryptionKeyId() != null - || resource.bytes().length != resource.byteLength() - || !sha256(resource.bytes()).equals(resource.digest())) { - throw resourceCorrupt(); - } - if ("managed-tool-outcome".equals(resource.kind())) { - Long quarantined = jdbc.queryForObject("SELECT COUNT(*) FROM ma_output_capture" - + " WHERE tenant_id = ? AND workspace_id = ? AND session_id = ?" - + " AND admission_resource_id = ? AND quarantined = TRUE", - Long.class, resource.tenantId(), resource.workspaceId(), - resource.sessionId(), resource.resourceId()); - if (quarantined != null && quarantined > 0) { - throw resourceCorrupt(); - } - } - } - - private InputStream guardedPublicationResource(ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease) { - lease.requireScope(resource.tenantId(), resource.sessionId()); - var rows = jdbc.queryForList("SELECT scope_key, publication_id FROM ma_resource" - + " WHERE scope_key = ? AND resource_id = ? AND object_key = ?", - ToolPublicationDataStore.scope(resource.tenantId(), resource.workspaceId(), resource.sessionId()), - resource.resourceId(), resource.objectKey()); - if (rows.size() != 1) { - throw resourceCorrupt(); - } - return outputRetention.open(resource.objectKey(), publicationObjects, lease, () -> {}); - } - - private byte[] readPublicationObject(ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease) { - return readPublicationObject(resource, lease, true); - } - - private byte[] readPublicationObject(ResourceRow resource, ToolPublicationRetentionStore.ReadLease lease, boolean quarantine) { - if (publicationObjects == null) { - throw resourceCorrupt(); - } - try (InputStream stream = guardedPublicationResource(resource, lease)) { - byte[] bytes = stream.readNBytes(Math.toIntExact(resource.byteLength()) + 1); - if (bytes.length != resource.byteLength() - || !sha256(bytes).equals(resource.digest())) { - var error = resourceCorrupt(); - try { - if (quarantine) { - outputRetention.quarantineResource( - ToolPublicationDataStore.scope(resource.tenantId(), resource.workspaceId(), resource.sessionId()), - resource.resourceId(), resource.objectKey()); - } - } catch (RuntimeException cleanup) { - error.addSuppressed(cleanup); - } - throw error; - } - return bytes; - } catch (IOException error) { - throw new IllegalStateException("Tool publication object read failed", error); - } - } - - private static ManagedAgentException resourceCorrupt() { - return new ManagedAgentException(500, - "managed_session_resource_corrupt", - "The Managed Session resource failed verification."); - } - - private static void validateCounter(long value, String label, - long minimum) { - if (value < minimum - || value > ManagedSessionStoreModels.MAX_SAFE_COUNTER) { - throw invalid(label + " is outside its safe integer bound."); - } - } - - private static void validateLeaseMillis(Long leaseMillis) { - if (leaseMillis == null - || leaseMillis < ManagedSessionStoreModels.MIN_LEASE_MILLIS - || leaseMillis > ManagedSessionStoreModels.MAX_LEASE_MILLIS) { - throw invalid("leaseMillis is outside its bound."); - } - } - - private static void validateUtf8JsonLines(byte[] bytes, - int expectedRecords) { - try { - StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)); - } catch (CharacterCodingException error) { - throw invalid("recordBytesBase64 is not valid UTF-8."); - } - int records = 0; - int lineLength = 0; - for (byte value : bytes) { - if (value == '\n') { - if (lineLength == 0) { - throw invalid("recordBytesBase64 contains a blank line."); - } - if (lineLength > ManagedSessionStoreModels.MAX_EVENT_BYTES) { - throw payloadTooLarge("A Managed Session record exceeds" - + " its byte limit."); - } - records++; - lineLength = 0; - } else { - lineLength++; - } - } - if (lineLength != 0 || records != expectedRecords) { - throw invalid("recordBytesBase64 must end with a newline and" - + " match recordCount."); - } - } - - private void requireHeadScope(HeadRow head, String tenantId, - String workspaceId, String sessionId) { - if (!tenantId.equals(head.tenantId()) - || !workspaceId.equals(head.workspaceId()) - || !sessionId.equals(head.sessionId())) { - throw sessionNotFound(); - } - if (head.storageVersion() < 1 || head.storageVersion() > com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedStorageVersions.SUPPORTED) { - throw conflict("managed_session_storage_version_unsupported", - "The Managed Session storage version is unsupported."); - } - readerVersion.requireReadable(head.storageVersion()); - if (!HEAD_STATES.contains(head.state()) - || !RECOVERY_STATES.contains(head.recoveryStatus()) - || head.writerGeneration() < 1 - || head.writerGeneration() - > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || head.journalRevision() < 0 - || head.journalRevision() - > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || head.committedSequence() < 0 - || head.committedSequence() - > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || head.activationEpoch() < 0 - || head.activationEpoch() - > ManagedSessionStoreModels.MAX_SAFE_COUNTER - || head.compactedThroughRevision() < 0 - || head.compactedThroughRevision() - > head.journalRevision() - || head.lastCommitDigest() != null - && !DIGEST.matcher(head.lastCommitDigest()).matches() - || head.latestCheckpointResourceId() != null - && !isValidText(head.latestCheckpointResourceId(), - MAX_ID_BYTES) - || ("ACTIVE".equals(head.state()) - || "SEALED".equals(head.state())) - && (!isValidText(head.writerId(), MAX_ID_BYTES) - || head.writerLeaseUntil() == null - || head.leaseTokenHash() == null - || !DIGEST.matcher(head.leaseTokenHash()) - .matches())) { - throw headCorrupt(); - } - } - - private static void requireResourceScope(ResourceRow resource, - String tenantId, String workspaceId, String sessionId, - String resourceId) { - if (!tenantId.equals(resource.tenantId()) - || !workspaceId.equals(resource.workspaceId()) - || !sessionId.equals(resource.sessionId()) - || !resourceId.equals(resource.resourceId())) { - throw sessionNotFound(); - } - } - - private static void requireTransactionWorkspace(TransactionRow row, - String workspaceId) { - if (!workspaceId.equals(row.workspaceId())) { - throw new ManagedAgentException(500, - ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, - "The Managed Session transaction scope is corrupt."); - } - } - - private static String tokenHash(String writerToken) { - if (writerToken == null - || !WRITER_TOKEN.matcher(writerToken).matches()) { - throw new ManagedAgentException(400, - "invalid_writer_token", - ManagedSessionStoreModels.WRITER_TOKEN_HEADER - + " must contain 32-512 Base64URL characters."); - } - return sha256(writerToken.getBytes(StandardCharsets.UTF_8)); - } - - private void ensureSessionRoot(String tenantId, String sessionId) { - // Take the root row exclusively first: ordinary writers lock it - // without a tenant guard (#13800); keep that order before - // initializing the session event stream. - ToolPublicationRetentionStore.lockSessionRoot(jdbc, tenantId, sessionId); - long now = epochMillis(databaseNow()); - jdbc.update( - "INSERT IGNORE INTO ma_event_stream (tenant_id, session_id, stream_kind, stream_id," - + " gmt_modified) VALUES (?, ?, 'SESSION', X'', ?)", - tenantId, - sessionId, - now); + return writerLifecycle.hasLiveWriter(tenantId, sessionId); } static String commandKeyHash(String operation, String commandId) { - return sha256((operation + "\u0000" + commandId) - .getBytes(StandardCharsets.UTF_8)); + return JournalStoreRows.commandKeyHash(operation, commandId); } - private static String sha256(byte[] value) { - return DigestUtils.sha256Hex(value); - } - - private static boolean secureEquals(String left, String right) { - return left != null && right != null && MessageDigest.isEqual( - left.getBytes(StandardCharsets.US_ASCII), - right.getBytes(StandardCharsets.US_ASCII)); - } - - private static ManagedAgentException sessionNotFound() { - return new ManagedAgentException(404, - "managed_session_not_found", - "The Managed Session does not exist."); - } - - private static ManagedAgentException writerConflict() { - return conflict(ManagedSessionStoreModels.ERROR_WRITER_CONFLICT, - "The Managed Session writer grant is stale or unavailable."); - } - - // Package-private: ToolPublicationStore's evidence reads answer a - // damaged journal with the same fault the session store raises. static ManagedAgentException journalCorrupt() { - return new ManagedAgentException(500, - ManagedSessionStoreModels.ERROR_JOURNAL_CORRUPT, - "The Managed Session transaction failed verification."); - } - - private static ManagedAgentException headCorrupt() { - return new ManagedAgentException(500, - ManagedSessionStoreModels.ERROR_HEAD_CORRUPT, - "The Managed Session head failed verification."); - } - - private static ManagedAgentException invalid(String message) { - return new ManagedAgentException(400, - ManagedSessionStoreModels.ERROR_INVALID_REQUEST, message); - } - - private static ManagedAgentException payloadTooLarge(String message) { - return new ManagedAgentException(413, - ManagedSessionStoreModels.ERROR_PAYLOAD_TOO_LARGE, message); - } - - private static ManagedAgentException conflict(String code, String message) { - return new ManagedAgentException(409, code, message); - } - - private record HeadRow(String tenantId, String workspaceId, - String sessionId, String state, int storageVersion, - long writerGeneration, String writerId, - Timestamp writerLeaseUntil, String leaseTokenHash, - long journalRevision, long committedSequence, - String lastCommitDigest, long activationEpoch, - String latestCheckpointResourceId, - long compactedThroughRevision, String recoveryStatus, - String recoveryDetailCode, String activationId, - String activationPhase, Long activationEventEpoch, - Long activationExpiresAt, Long activationHeadRevision) { - } - - private record TransactionRow(String workspaceId, long journalRevision, - String transactionId, String operation, String commandId, - String contentDigest, long firstSequence, long lastSequence, - int eventCount, String eventsDigest, - String previousCommitDigest, String commitDigest, - long writerGeneration, String writerId, String writerTokenHash, - long activationEpoch, String latestCheckpointResourceId, - String recordEncoding, byte[] recordBytes, long byteLength, - String recordDigest) { - } - - private record ActivationChange(String id, String phase, long epoch, - Long expiresAt) { - } - - private record TransactionSize(long journalRevision, long byteLength) { - } - - private record ResourceRow(String tenantId, String workspaceId, - String sessionId, String resourceId, String kind, - int schemaVersion, long byteLength, String digest, - String storageKind, byte[] bytes, String objectKey, - String objectVersionId, String encryptionKeyId, String state, String captureState, String deletionOperation) { - } - - private record ValidatedResource(CommitResource resource, byte[] bytes) { - } - - private record ValidatedCommit(byte[] recordBytes, String recordDigest, - List resources) { + return JournalStoreRows.journalCorrupt(); } } diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiRuntimeMain.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiRuntimeMain.java index a4a4f73a5a4..521604ce1f7 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiRuntimeMain.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiRuntimeMain.java @@ -103,8 +103,11 @@ public final class WorkspaceCsiRuntimeMain { required("QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY"))); var sessions = new JdbcRuntimeSessionRepository(source); var http = new HttpRuntimeTransport(); - var nativeStore = new ManagedSessionStore(jdbc); - nativeStore.setCsiBindings(bindings); + var nativeStore = new ManagedSessionStore(jdbc, + new com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore(jdbc), null, null, + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy.inProcess(), + null, null, null, null, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), bindings); var application = new WorkspaceCsiApplicationStore(jdbc, manager, nativeStore, bindings, sessions, new com.alibaba.qwen.code.runtimebroker.adapter.HttpRuntimeTransport()); var access = new WorkspaceCsiRuntimeAccess(jdbc, manager, json, properties, request.registration(), request.sessionId(), request.runtimeRequestKey(), bindings, sessions, http, new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); diff --git a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiWorkerAckMain.java b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiWorkerAckMain.java index 38ea1768a82..03e25afa0eb 100644 --- a/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiWorkerAckMain.java +++ b/packages/managed-agent-server/server/src/main/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiWorkerAckMain.java @@ -50,8 +50,12 @@ public final class WorkspaceCsiWorkerAckMain { required("QWEN_MANAGED_AGENT_RUNTIME_CREDENTIAL_KEY"))); var runtimeSessions = new JdbcRuntimeSessionRepository(source); var executions = new JdbcToolExecutionRepository(source); - var sessionStore = new ManagedSessionStore(jdbc); - sessionStore.configureHarnessRules(new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); + var sessionStore = new ManagedSessionStore(jdbc, + new com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore(jdbc), null, + new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules(), + com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy.inProcess(), + null, null, null, null, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), null); var grants = new ToolPublicationStore(jdbc, manager, sessionStore, executions, bindings, new ToolPublicationStore.Capacity(16L * 1024 * 1024, 64L * 1024 * 1024, 256L * 1024 * 1024, 64)); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HookAdmissionHistory.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HookAdmissionHistory.java index afa081b873a..3e9c1e49ff0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HookAdmissionHistory.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HookAdmissionHistory.java @@ -2,7 +2,6 @@ package com.alibaba.qwen.code.managedagent; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -66,7 +65,7 @@ final class HookAdmissionHistory { inTransaction( () -> new ExtensionRecordJournal( - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), tenantId, workspaceId, sessionId) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedCleanupRetentionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedCleanupRetentionTest.java index 4074f7bbd94..d4e40f9ab9e 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedCleanupRetentionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/HostedCleanupRetentionTest.java @@ -4,7 +4,6 @@ import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionSt import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitTransactionRequest; import com.alibaba.qwen.code.managedagent.persistence.ManagedRuntimeRetentionGuard; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRecord; import com.alibaba.qwen.code.runtimebroker.RuntimeDrainReceipt; import com.alibaba.qwen.code.runtimebroker.RuntimeLease; @@ -74,7 +73,7 @@ class HostedCleanupRetentionTest { transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("key", new byte[32])); runtimeSessions = new JdbcRuntimeSessionRepository(source); - journal = transactions.execute(ignored -> new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + journal = transactions.execute(ignored -> new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), "tenant", "workspace", "harness").open()); scope = new RuntimeScope("tenant", "workspace", "1", "/workspace", "sha256:" + "a".repeat(64), "session"); original = retire(new RuntimeProvisionRequest(scope, "harness", "local-process", "storage")); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentDefinitionAdmissionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentDefinitionAdmissionTest.java index a528d0e3874..71f51cf64cb 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentDefinitionAdmissionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentDefinitionAdmissionTest.java @@ -607,7 +607,7 @@ class ManagedAgentDefinitionAdmissionTest { var definitions = new com.alibaba.qwen.code.managedagent.persistence.ManagedAgentDefinitionStore(jdbc, new RequestDigests()); assertThat(definitions.pinnedContent(session).get("instructions")) .isEqualTo("Review in Chinese."); - var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore(jdbc)); + var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var first = resources.publishAgentInstructions(tenant, "workspace", session.sessionId(), "Review in Chinese."); assertThat(resources.publishAgentInstructions(tenant, "workspace", session.sessionId(), diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java index f00003d5ef4..57c6d4443d5 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentMySqlIT.java @@ -287,8 +287,8 @@ class ManagedAgentMySqlIT { TransactionTemplate transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); - ManagedSessionStore firstInstance = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); - ManagedSessionStore secondInstance = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + ManagedSessionStore firstInstance = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); + ManagedSessionStore secondInstance = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String storeTenant = "mysql-private-store-" + runId; String sessionId = "mysql-private-session-" + runId; String workspaceId = "mysql-private-workspace-" + runId; @@ -529,7 +529,7 @@ class ManagedAgentMySqlIT { TransactionTemplate transactions = new TransactionTemplate(new DataSourceTransactionManager(dataSource)); - ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); WriterGrant grant = inTransaction( transactions, @@ -978,7 +978,7 @@ class ManagedAgentMySqlIT { transactions, () -> new ExtensionRecordJournal( - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), tenant, "mysql-extension-workspace", session) @@ -1129,7 +1129,7 @@ class ManagedAgentMySqlIT { transactions, () -> new ExtensionRecordJournal( - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), tenant, "mysql-journal-workspace", session) @@ -1229,7 +1229,7 @@ class ManagedAgentMySqlIT { inTransaction( transactions, () -> - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)) + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc) .sealWriter( tenant, session, @@ -1310,7 +1310,7 @@ class ManagedAgentMySqlIT { transactions, () -> new ExtensionRecordJournal( - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), tenant, "mysql-journal-workspace", session) @@ -1588,7 +1588,7 @@ class ManagedAgentMySqlIT { "session.closed", "session.archived"); - ManagedSessionStore journals = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + ManagedSessionStore journals = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "mysql-lifecycle-writer-token-00000000"; assertThatThrownBy(() -> inTransaction(transactions, () -> journals.acquireWriter(tenant, sessionId, token, @@ -1752,7 +1752,7 @@ class ManagedAgentMySqlIT { return super.update(sql, arguments); } }; - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var tx = new TransactionTemplate(new DataSourceTransactionManager(isolated)); tx.setIsolationLevel( org.springframework.transaction.TransactionDefinition @@ -2277,7 +2277,7 @@ class ManagedAgentMySqlIT { Flyway.configure().dataSource(source).locations("classpath:db/managed-schema").load().migrate(); JdbcTemplate jdbc = new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source); TransactionTemplate transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); - ManagedSessionStore resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + ManagedSessionStore resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String tenant = "mysql-skills-" + UUID.randomUUID(); String session = UUID.randomUUID().toString(); String token = "s".repeat(32); @@ -2289,7 +2289,7 @@ class ManagedAgentMySqlIT { () -> resources.publishAgentSkills(tenant, "workspace", session, skills)); assertThat(inTransaction(transactions, () -> resources.publishAgentSkills(tenant, "workspace", session, skills))).isEqualTo(published); - var reloaded = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source))) + var reloaded = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source)) .readResource(tenant, "workspace", session, published.resourceId(), token); assertThat(new ObjectMapper().readTree(reloaded.bytes())).isEqualTo(new ObjectMapper().valueToTree(skills)); assertThat(reloaded.digest()).isEqualTo(published.digest()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentStoreFactoryWiringTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentStoreFactoryWiringTest.java index 8338bd6a259..1f55a373813 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentStoreFactoryWiringTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedAgentStoreFactoryWiringTest.java @@ -83,9 +83,9 @@ class ManagedAgentStoreFactoryWiringTest { "definitions", ManagedAgentDefinitionStore.class, "sessionWorktrees", SessionWorktreeStore.class, "journalEventTransport", JournalEventTransport.class) - .forEach((field, type) -> assertThat(ReflectionTestUtils.getField(target, field)) + .forEach((field, type) -> assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, field)) .as(field).isSameAs(context.getBean(type))); - var records = (Advised) ReflectionTestUtils.getField(target, "configRecords"); + var records = (Advised) com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "configRecords"); assertThat(records).isNotNull(); assertThat(context.getBeanFactory().containsSingleton("factoryRecordStore")).isFalse(); Object resolved = records.getTargetSource().getTarget(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedApprovalChangeTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedApprovalChangeTest.java index 974d406e45f..05dc34f7b92 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedApprovalChangeTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedApprovalChangeTest.java @@ -13,7 +13,6 @@ import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellApprovalSelect import com.alibaba.qwen.code.managedagent.model.RequestDigests; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentDefinitionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; @@ -133,7 +132,7 @@ class ManagedApprovalChangeTest { @Test void liveApprovalReadsRequireTheCurrentWriterAndReturnTheDurableSelection() { - var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "live-approval-writer-token-0123456789"; var grant = transaction().execute(status -> privateStore.acquireWriter(tenant, session, token, new AcquireWriterRequest("ws", "writer", 60_000L))); @@ -155,7 +154,7 @@ class ManagedApprovalChangeTest { @Test void liveApprovalReadsReturnTheSelectionForTheFilesV3Profile() throws Exception { session = customSession("hosted-workspace-files/3", "default"); - var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "live-approval-v3-writer-token-0123456789"; var grant = transaction().execute(status -> privateStore.acquireWriter(tenant, session, token, new AcquireWriterRequest("ws", "writer", 60_000L))); @@ -623,7 +622,7 @@ class ManagedApprovalChangeTest { "{\"expiresAt\":" + (System.currentTimeMillis() + 100_000) + "}"); } if (shape.equals("recovery")) { - transaction().execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter(tenant, session, + transaction().execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter(tenant, session, "model-selection-writer-token-0123456789", new AcquireWriterRequest("ws", "writer", 60_000L))); jdbc.update("UPDATE ma_session_head SET recovery_status='BLOCKED' WHERE tenant_id=?", tenant); } @@ -718,7 +717,7 @@ class ManagedApprovalChangeTest { when(harness.supportsLiveApprovalSelection()).thenReturn(true); transaction().execute(status -> service.changeWebShellSessionApproval(tenant, "actor", session, "stored-rules", "default", 0, null, rules)); - var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "rules-writer-token-01234567890123456789"; var grant = transaction().execute(status -> privateStore.acquireWriter(tenant, session, token, new AcquireWriterRequest("ws", "writer", 60_000L))); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedArtifactApiIntegrationTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedArtifactApiIntegrationTest.java index e18d0bdfdd7..c01fbe55e6d 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedArtifactApiIntegrationTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedArtifactApiIntegrationTest.java @@ -15,7 +15,6 @@ import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionSt import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellEvent; import com.alibaba.qwen.code.managedagent.model.RequestDigests; import com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedToolDetailsStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; @@ -529,7 +528,7 @@ class ManagedArtifactApiIntegrationTest { @Test void exposesOriginalCommittedBytesAfterWriterSealAndSessionClose() throws Exception { new TransactionTemplate(fixture.manager()).executeWithoutResult(ignored -> - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc())).sealWriter("tenant-1", "session-1", "a".repeat(32), + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc()).sealWriter("tenant-1", "session-1", "a".repeat(32), new SealWriterRequest("workspace-1", "writer-1", 1))); assertThat(fixture.jdbc().queryForObject("SELECT state FROM ma_session_head", String.class)) .isEqualTo("SEALED"); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedContextCompressionMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedContextCompressionMySqlIT.java index 9770d6d836d..69eaa722be5 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedContextCompressionMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedContextCompressionMySqlIT.java @@ -69,7 +69,7 @@ class ManagedContextCompressionMySqlIT extends ManagedContextCompressionOperatio @Test void projectionBackfillAndNativeCompactionCommitRemainMonotonic() throws Exception { var records = new com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore(jdbc, store); - var nativeStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore(jdbc, records)); + var nativeStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, records); var journal = tx(() -> new ExtensionRecordJournal(nativeStore, "tenant", "workspace", session).open()); var summary = "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8); var resource = ManagedExtensionRecordStoreTest.hookResource("race-summary", "managed-message", summary); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java index ca676ba7bba..5a63afb84a5 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedExtensionConfigurationAdmissionTest.java @@ -17,7 +17,6 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.util.AopTestUtils; -import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; @@ -46,7 +45,7 @@ class ManagedExtensionConfigurationAdmissionTest { @BeforeEach void enableOnlyTheStoreAdmissionBranch() { Object target = AopTestUtils.getUltimateTargetObject(sessions); - ReflectionTestUtils.setField(target, "workspaceFilesEnabled", true); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "workspaceFilesEnabled", true); } @Test diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelContextTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelContextTest.java index da401b020f8..f61f21fdaf5 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelContextTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelContextTest.java @@ -76,7 +76,7 @@ class ManagedModelContextTest { }; jdbc = new ManagedSchemaJdbcTemplate(counted); records = new ManagedExtensionRecordStore(jdbc); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc, records)); + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, records); transactions = new TransactionTemplate(new DataSourceTransactionManager(counted)); } @@ -361,7 +361,7 @@ class ManagedModelContextTest { return null; }).when(guard).requireContextCompressionCommit(TENANT, "manual", "op_manual"); records = new ManagedExtensionRecordStore(jdbc, guard); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc, records)); + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, records); var journal = open(TENANT, "manual", true); byte[] bytes = "{}".getBytes(StandardCharsets.UTF_8); var pinned = ManagedExtensionRecordStoreTest.hookResource("pinned", "managed-message", bytes); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelSelectionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelSelectionTest.java index 670b5ac0296..1be74c161b6 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelSelectionTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedModelSelectionTest.java @@ -12,7 +12,6 @@ import com.alibaba.qwen.code.managedagent.model.ApiModels.WebShellModelSelection import com.alibaba.qwen.code.managedagent.model.RequestDigests; import com.alibaba.qwen.code.managedagent.model.StoreModels.SessionLineage; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.session.HarnessCoordinator; import com.alibaba.qwen.code.managedagent.session.ManagedAgentService; @@ -265,7 +264,7 @@ class ManagedModelSelectionTest { "{\"expiresAt\":" + (System.currentTimeMillis() + 100_000) + "}"); } if (shape.equals("recovery")) { - transaction().execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter(tenant, session, + transaction().execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter(tenant, session, "model-selection-writer-token-0123456789", new AcquireWriterRequest("ws", "writer", 60_000L))); jdbc.update("UPDATE ma_session_head SET recovery_status='BLOCKED' WHERE tenant_id=?", tenant); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedNativeJournalSnapshotsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedNativeJournalSnapshotsTest.java index e5d43973f00..2d9c9adfd34 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedNativeJournalSnapshotsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedNativeJournalSnapshotsTest.java @@ -51,7 +51,7 @@ class ManagedNativeJournalSnapshotsTest { ManagedSchema.initialize(source); jdbc = new FaultJdbc(source); tx = new TransactionTemplate(new DataSourceTransactionManager(source)); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); snapshots = new ManagedNativeJournalSnapshots(jdbc, sessions, JSON); journal = new ExtensionRecordJournal(sessions, TENANT, WORKSPACE, SESSION); tx.executeWithoutResult(status -> journal.open()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSchemaJournalAtomicityTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSchemaJournalAtomicityTest.java index ed46766c286..ae3583a269f 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSchemaJournalAtomicityTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSchemaJournalAtomicityTest.java @@ -3,7 +3,6 @@ package com.alibaba.qwen.code.managedagent; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels.CommitResource; import com.alibaba.qwen.code.managedagent.persistence.AutomationLedgerStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -52,7 +51,7 @@ class ManagedSchemaJournalAtomicityTest { transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); journal = new ExtensionRecordJournal( - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), tenant, "workspace", session); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), tenant, "workspace", session); transactions.executeWithoutResult(status -> journal.open()); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java index 769b4ab82ef..d5acd838ecc 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionCreationSkillsTest.java @@ -179,8 +179,8 @@ class ManagedSessionCreationSkillsTest { properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); properties.getExtensions().setAllowedActors(List.of("actor")); Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); - Object profiles = org.springframework.test.util.ReflectionTestUtils.getField(target, "definitionToolProfiles"); - org.springframework.test.util.ReflectionTestUtils.setField(target, "definitionToolProfiles", List.of("hosted-workspace-mcp/1")); + Object profiles = com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "definitionToolProfiles"); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "definitionToolProfiles", List.of("hosted-workspace-mcp/1")); try { var definition = json.createObjectNode(); definition.putObject("model"); @@ -206,7 +206,7 @@ class ManagedSessionCreationSkillsTest { assertThat(store.requireSession(tenant, session).toolProfile()).isEqualTo("hosted-workspace-mcp/1"); assertThat(snapshots.initialSelection(tenant, session).get("extensions")).isEqualTo(List.of()); } finally { - org.springframework.test.util.ReflectionTestUtils.setField(target, "definitionToolProfiles", profiles); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "definitionToolProfiles", profiles); } } @@ -218,8 +218,8 @@ class ManagedSessionCreationSkillsTest { "definitionToolProfiles", List.of("hosted-workspace-files/1", "hosted-workspace-shell/1")); Map previous = new java.util.LinkedHashMap<>(); for (var entry : values.entrySet()) { - previous.put(entry.getKey(), org.springframework.test.util.ReflectionTestUtils.getField(target, entry.getKey())); - org.springframework.test.util.ReflectionTestUtils.setField(target, entry.getKey(), entry.getValue()); + previous.put(entry.getKey(), com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, entry.getKey())); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, entry.getKey(), entry.getValue()); } boolean previousManaged = properties.getManagedExtensions().isEnabled(); try { @@ -240,7 +240,7 @@ class ManagedSessionCreationSkillsTest { properties.getManagedExtensions().setEnabled(true); properties.getExtensions().setArtifactDirectory(archiveDirectory.toString()); properties.getExtensions().setAllowedActors(List.of("actor")); - org.springframework.test.util.ReflectionTestUtils.setField(target, "managedExtensionExecutionEnabled", true); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "managedExtensionExecutionEnabled", true); var selection = request(agent, List.of()); selection.putArray("extensionPins"); String selected = create(selection, "extension-shell", false).required("id").asText(); @@ -257,7 +257,7 @@ class ManagedSessionCreationSkillsTest { assertThat(store.requireSession(tenant, filesSession).publicChildAgentsVersion()).isZero(); } finally { for (var entry : previous.entrySet()) { - org.springframework.test.util.ReflectionTestUtils.setField(target, entry.getKey(), entry.getValue()); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, entry.getKey(), entry.getValue()); } properties.getManagedExtensions().setEnabled(previousManaged); properties.getExtensions().setEnabled(false); @@ -355,15 +355,15 @@ class ManagedSessionCreationSkillsTest { private void enableBuiltinShell() { Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); - previousShellEnabled = (Boolean) org.springframework.test.util.ReflectionTestUtils.getField(target, "workspaceShellEnabled"); - org.springframework.test.util.ReflectionTestUtils.setField(target, "workspaceShellEnabled", true); + previousShellEnabled = (Boolean) com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "workspaceShellEnabled"); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "workspaceShellEnabled", true); } @AfterEach void restoreBuiltinShell() { if (previousShellEnabled != null) { Object target = org.springframework.test.util.AopTestUtils.getUltimateTargetObject(store); - org.springframework.test.util.ReflectionTestUtils.setField(target, "workspaceShellEnabled", previousShellEnabled); + com.alibaba.qwen.code.managedagent.VerificationFixtures.setAgentStoreField(target, "workspaceShellEnabled", previousShellEnabled); previousShellEnabled = null; } } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionQueryTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionQueryTest.java index ea27dc510d6..a436d87a122 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionQueryTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionQueryTest.java @@ -40,7 +40,7 @@ class ManagedSessionQueryTest { DataSource source = dataSource(); ManagedSchema.initialize(source); jdbc = new ManagedSchemaJdbcTemplate(source); - sessions = VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + sessions = VerificationFixtures.managedSessionStore(jdbc); tx = new TransactionTemplate(new DataSourceTransactionManager(source)); journal = new ExtensionRecordJournal(sessions, TENANT, WORKSPACE, SESSION); tx.executeWithoutResult(status -> journal.open()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java index 2e4692a09e7..d15b3669210 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ManagedSessionStoreProcessFixtureMain.java @@ -48,8 +48,8 @@ public final class ManagedSessionStoreProcessFixtureMain { System.getenv().getOrDefault("D1_MYSQL_PASSWORD", "")); TransactionTemplate transactions = new TransactionTemplate( new DataSourceTransactionManager(dataSource)); - ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore( - new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource))); + ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore( + new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource)); String action = required("D1_PROCESS_ACTION"); switch (action) { case "commit-and-crash" -> commitAndCrash(store, transactions); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/PublicationJournalFixture.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/PublicationJournalFixture.java index 6f81e80159e..c31f3d11a31 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/PublicationJournalFixture.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/PublicationJournalFixture.java @@ -74,9 +74,18 @@ public final class PublicationJournalFixture { public WorkspaceCsiReservationStore.Reservation csiReservation; private PublicationJournalFixture(DataSource source) { + this(source, null, null); + } + + private PublicationJournalFixture(DataSource source, + com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore objects, + com.alibaba.qwen.code.managedagent.persistence.ManagedToolResultStore results) { jdbc = new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source); manager = new DataSourceTransactionManager(source); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, + new com.alibaba.qwen.code.managedagent.persistence.ManagedExtensionRecordStore(jdbc), null, + null, null, objects, results, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), null); bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("key", new byte[32]), () -> "binding-1"); @@ -89,8 +98,14 @@ public final class PublicationJournalFixture { } public static PublicationJournalFixture create(DataSource source, WorkspaceCsiRegistration registration) { + return create(source, registration, null, null); + } + + public static PublicationJournalFixture create(DataSource source, WorkspaceCsiRegistration registration, + com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore objects, + com.alibaba.qwen.code.managedagent.persistence.ManagedToolResultStore results) { PublicationJournalFixture fixture = - new PublicationJournalFixture(source); + new PublicationJournalFixture(source, objects, results); fixture.populate(source, registration, false); return fixture; } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLockOrderMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLockOrderMySqlIT.java index 6f85192fa66..bf345b2c77a 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLockOrderMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationLockOrderMySqlIT.java @@ -11,7 +11,6 @@ import com.alibaba.qwen.code.managedagent.contracts.workspace.WorkspaceSelection import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.maintenance.WorkspaceCsiSessionMain; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationAdmissionStore; import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationDataStore; @@ -293,7 +292,7 @@ class ToolPublicationLockOrderMySqlIT { new RuntimeResourceHandle("kubernetes-workspace", 3, Map.of("podUid", "fixture-original")), Instant.now(), Instant.now())); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); transaction.executeWithoutResult(status -> sessions.acquireWriter("tenant", sessionId, WRITER_TOKEN, new AcquireWriterRequest("workspace", "writer", 60_000L))); // InnoDB rolls back the transaction with the smaller undo log; diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationStoreTest.java index e6be5608699..5d1dfe4084b 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/ToolPublicationStoreTest.java @@ -116,13 +116,26 @@ class ToolPublicationStoreTest { } private void initialize(javax.sql.DataSource source, boolean csiWorkspace) { + initialize(source, csiWorkspace, null); + } + + private void initialize(javax.sql.DataSource source, boolean csiWorkspace, + ToolPublicationObjectStore objects) { com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema.initialize(source); csiRegistration = csiWorkspace ? new WorkspaceCsiRegistration("tenant-1", "fixture-storage", "fixture-cluster", "fixture", "fixture-claim", "fixture-pvc", "fixture-volume", "fixture-pv", "fixture.csi", "fixture-handle", "fixture-backend", "fixture-serial", "/workspace", 1) : null; - journal = PublicationJournalFixture.create(source, csiRegistration); + jdbc = new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(source); + manager = new DataSourceTransactionManager(source); + projectionProperties = new ManagedAgentProperties(); + projectionProperties.getArtifacts().setEnabled(true); + publicWorkspaces = org.mockito.Mockito.mock(ManagedWorkspaceRegistry.class); + publicSessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedAgentStore(jdbc, JSON, java.time.Clock.systemUTC(), events -> {}, + publicWorkspaces, projectionProperties, new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); + publicResults = new ManagedToolResultStore(jdbc, manager, publicSessions); + journal = PublicationJournalFixture.create(source, csiRegistration, objects, publicResults); jdbc = journal.jdbc; manager = journal.manager; sessions = journal.sessions; @@ -133,13 +146,6 @@ class ToolPublicationStoreTest { checkpoint = journal.checkpoint; csiStore = csiWorkspace ? new WorkspaceCsiReservationStore(jdbc, manager, JSON) : null; csiReservation = journal.csiReservation; - projectionProperties = new ManagedAgentProperties(); - projectionProperties.getArtifacts().setEnabled(true); - publicWorkspaces = org.mockito.Mockito.mock(ManagedWorkspaceRegistry.class); - publicSessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedAgentStore(jdbc, JSON, java.time.Clock.systemUTC(), events -> {}, - publicWorkspaces, projectionProperties, new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); - publicResults = new ManagedToolResultStore(jdbc, manager, publicSessions); - sessions.setToolResults(publicResults); } javax.sql.DataSource publicationDataSource() { @@ -887,6 +893,22 @@ class ToolPublicationStoreTest { @ValueSource(booleans = {false, true}) void commitsLargeBlockedOutcomeThroughVerifiedCatalogObject(boolean ambient) { List lockOrder = new java.util.ArrayList<>(); + Map objects = new java.util.HashMap<>(); + ToolPublicationObjectStore bucket = new ToolPublicationObjectStore() { + @Override + public void putIfAbsent(String key, byte[] bytes) { + objects.putIfAbsent(key, bytes.clone()); + } + + @Override + public InputStream open(String key) { + return new ByteArrayInputStream(objects.get(key)); + } + + @Override + public void validateConfiguration() { + } + }; initialize(new DelegatingDataSource(publicationDataSource()) { @Override public Connection getConnection() throws SQLException { @@ -924,25 +946,8 @@ class ToolPublicationStoreTest { } }); } - }); + }, false, bucket); reserve(); - Map objects = new java.util.HashMap<>(); - ToolPublicationObjectStore bucket = new ToolPublicationObjectStore() { - @Override - public void putIfAbsent(String key, byte[] bytes) { - objects.putIfAbsent(key, bytes.clone()); - } - - @Override - public InputStream open(String key) { - return new ByteArrayInputStream(objects.get(key)); - } - - @Override - public void validateConfiguration() { - } - }; - sessions.setPublicationObjects(bucket); var data = new ToolPublicationDataStore(jdbc, manager, store, sessions, bucket, Duration.ofMinutes(2), Duration.ofSeconds(30), VERIFICATION_BUDGET); JsonNode key = binding.get("sessionKey"); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/VerificationFixtures.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/VerificationFixtures.java index bc9716b4ec1..46ae9bb9bd9 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/VerificationFixtures.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/VerificationFixtures.java @@ -37,12 +37,68 @@ public final class VerificationFixtures { null, lifecycle, definitions, null, null, null); } - public static ManagedSessionStore configure(ManagedSessionStore store) { - store.configureHarnessRules( - new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); - store.configureReaderVersion( - new com.alibaba.qwen.code.managedagent.config.ManagedSessionReaderVersion()); - return store; + public static ManagedSessionStore managedSessionStore(org.springframework.jdbc.core.JdbcTemplate jdbc) { + return managedSessionStore(jdbc, new ManagedExtensionRecordStore(jdbc), null); + } + + public static ManagedSessionStore managedSessionStore(org.springframework.jdbc.core.JdbcTemplate jdbc, + ManagedExtensionRecordStore records) { + return managedSessionStore(jdbc, records, null); + } + + public static ManagedSessionStore managedSessionStore(org.springframework.jdbc.core.JdbcTemplate jdbc, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy credentials) { + return managedSessionStore(jdbc, new ManagedExtensionRecordStore(jdbc), null, + null, null, null, null, null, credentials, null); + } + + public static ManagedSessionStore managedSessionStore(org.springframework.jdbc.core.JdbcTemplate jdbc, + ManagedExtensionRecordStore records, ManagedActionStore actions) { + return managedSessionStore(jdbc, records, actions, null, null, null, null, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), null); + } + + public static ManagedSessionStore managedSessionStore(org.springframework.jdbc.core.JdbcTemplate jdbc, + ManagedExtensionRecordStore records, ManagedActionStore actions, + WorkspaceExecutionStore execution, + com.alibaba.qwen.code.managedagent.contracts.session.SessionQueryStore sessions, + com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore objects, + ManagedToolResultStore results, + com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport transport, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy credentials, + com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository bindings) { + return new ManagedSessionStore(jdbc, records, actions, + new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules(), + new com.alibaba.qwen.code.managedagent.config.ManagedSessionReaderVersion(), + execution, sessions, objects, results, transport, credentials, bindings); + } + + public static java.util.List agentStoreOwners(Object store, String name) { + var owners = java.util.Arrays.stream(store.getClass().getDeclaredFields()) + .filter(field -> field.getType().getSimpleName().startsWith("Agent")) + .map(field -> org.springframework.test.util.ReflectionTestUtils.getField(store, field.getName())) + .filter(owner -> org.springframework.util.ReflectionUtils.findField(owner.getClass(), name) != null) + .toList(); + if (owners.isEmpty()) { + throw new IllegalArgumentException("No Agent collaborator owns " + name); + } + return owners; + } + + public static Object agentStoreField(Object store, String name) { + var owners = agentStoreOwners(store, name); + Object value = org.springframework.test.util.ReflectionTestUtils.getField(owners.getFirst(), name); + for (Object owner : owners) { + org.assertj.core.api.Assertions.assertThat(org.springframework.test.util.ReflectionTestUtils.getField(owner, name)) + .as(name).isSameAs(value); + } + return value; + } + + public static void setAgentStoreField(Object store, String name, Object value) { + for (Object owner : agentStoreOwners(store, name)) { + org.springframework.test.util.ReflectionTestUtils.setField(owner, name, value); + } } public static SessionWorktreeStore configure(SessionWorktreeStore store) { diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java index a862f27a0da..b028c5698f3 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionCloseMySqlIT.java @@ -11,7 +11,6 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationAdmission; import com.alibaba.qwen.code.managedagent.persistence.ManagedActionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.runtimebroker.WorkspaceExecutionProfile; import com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema; @@ -111,7 +110,7 @@ class WorkspaceSessionCloseMySqlIT { void closeSerializesNewWriterAndPreservesAnAlreadyAcquiredWriter() throws Exception { var first = store(Clock.systemUTC()); var second = store(Clock.systemUTC()); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var tenant = "mysql-close-writer-" + UUID.randomUUID(); var session = create(first, tenant); try (var pool = Executors.newSingleThreadExecutor()) { @@ -212,8 +211,8 @@ class WorkspaceSessionCloseMySqlIT { @Test void closeWriterCheckWaitsForAnUncommittedRenewalBeforeEvaluatingExpiry() throws Exception { var first = store(Clock.systemUTC()); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); - var otherJournal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(new ManagedSchemaJdbcTemplate(source))); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); + var otherJournal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(new ManagedSchemaJdbcTemplate(source)); var tenant = "mysql-close-renew-" + UUID.randomUUID(); var session = create(first, tenant); long generation = transaction(() -> journal.acquireWriter(tenant, session, TOKEN, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java index 2a0b6bc71ce..c5a793ccf21 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/WorkspaceSessionRetentionMySqlIT.java @@ -11,7 +11,6 @@ import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationKind; import com.alibaba.qwen.code.managedagent.model.StoreModels.OperationRecord; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedToolResultStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationRetentionStore; @@ -198,7 +197,7 @@ class WorkspaceSessionRetentionMySqlIT { assertThat(jdbc.queryForObject("SELECT recovery_protected FROM ma_session WHERE retirement_generation > 0", Boolean.class)).isTrue(); var retention = new ToolPublicationRetentionStore(jdbc, new DataSourceTransactionManager(source)); assertThat(retention.observe(Duration.ZERO)).singleElement().extracting(ToolPublicationRetentionStore.Candidate::blocker).isEqualTo("recovery_protected"); - assertThatThrownBy(() -> transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter(tenant, session, TOKEN, + assertThatThrownBy(() -> transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter(tenant, session, TOKEN, new AcquireWriterRequest("workspace", "new-writer", 60_000L)))) .isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("tool_output_session_retired")); } @@ -264,7 +263,7 @@ class WorkspaceSessionRetentionMySqlIT { awaitLatch(completingPublicLock); var writer = pool.submit(() -> { try { - return transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter(tenant, session, TOKEN, + return transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter(tenant, session, TOKEN, new AcquireWriterRequest("workspace", "late", 60_000L))); } catch (ManagedAgentException rejected) { return rejected; @@ -325,7 +324,7 @@ class WorkspaceSessionRetentionMySqlIT { MySqlLockProbe.awaitLockWaits(jdbc, schema, 1, "the queued writer renewal"); var renewal = pool.submit(() -> { try { - return transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).renewWriter(tenant, session, TOKEN, + return transaction(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).renewWriter(tenant, session, TOKEN, new RenewWriterRequest("workspace", "writer", 1, 60_000L))); } catch (ManagedAgentException rejected) { return rejected; @@ -352,7 +351,7 @@ class WorkspaceSessionRetentionMySqlIT { // Restore a residual private writer to exercise the retirement safety check. jdbc.update("UPDATE ma_session_head SET state = 'ACTIVE', writer_lease_until = (" + ManagedSchemaTime.SQL_NOW + " + 60000)"); var deletion = deleteClaim(store, tenant, session); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var renewed = new CountDownLatch(1); var release = new CountDownLatch(1); try (var pool = Executors.newFixedThreadPool(2)) { @@ -441,7 +440,7 @@ class WorkspaceSessionRetentionMySqlIT { private String closed(ManagedAgentStore store, String tenant, boolean journal) { String session = create(store, tenant); if (journal) { - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var grant = transaction(() -> sessions.acquireWriter(tenant, session, TOKEN, new AcquireWriterRequest("workspace", "writer", 60_000L))); transaction(() -> sessions.sealWriter(tenant, session, TOKEN, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/ToolPublicationControllerTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/ToolPublicationControllerTest.java index e014797c5e4..63037bbca5f 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/ToolPublicationControllerTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/api/ToolPublicationControllerTest.java @@ -183,8 +183,7 @@ class ToolPublicationControllerTest { properties.getSessionStore().setBindingKey("0123456789abcdef0123456789abcdef"); properties.getToolPublication().setEntryConcurrency(1); WriterCredentialPolicy credentials = new WriterCredentialPolicy(properties); - ManagedSessionStore sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); - sessions.setCredentials(credentials); + ManagedSessionStore sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, credentials); writerToken = credentials.issue(TENANT, WORKSPACE, SESSION); var grant = new TransactionTemplate(manager).execute(transaction -> sessions.acquireWriter( TENANT, SESSION, writerToken, new AcquireWriterRequest(WORKSPACE, "writer", 60_000L))); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiSessionMainTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiSessionMainTest.java index 79043244a2c..0af363b3a8b 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiSessionMainTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/maintenance/WorkspaceCsiSessionMainTest.java @@ -12,7 +12,6 @@ import com.alibaba.qwen.code.managedagent.exception.ManagedAgentException; import com.alibaba.qwen.code.managedagent.model.RequestDigests; import com.alibaba.qwen.code.managedagent.model.StoreModels; import com.alibaba.qwen.code.managedagent.persistence.ManagedAgentStore; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.managedagent.persistence.ManagedWorkspaceRegistry; import com.alibaba.qwen.code.managedagent.persistence.ToolPublicationRetentionStore; import com.alibaba.qwen.code.managedagent.persistence.WorkspaceCsiReservationStore; @@ -453,7 +452,7 @@ class WorkspaceCsiSessionMainTest { var original = create(request("agent-instructions")); var before = allTables(); assertPrivateRefusal(() -> new TransactionTemplate(manager).executeWithoutResult(status -> - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).publishAgentInstructions("tenant", "workspace", original.sessionId(), "instructions"))); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).publishAgentInstructions("tenant", "workspace", original.sessionId(), "instructions"))); assertThat(allTables()).isEqualTo(before); } @@ -478,7 +477,7 @@ class WorkspaceCsiSessionMainTest { var original = create(request("direct-retained-pin")); jdbc.update("UPDATE ma_session SET tool_profile = 'workspace-files/1'"); var before = allTables(); - assertThatThrownBy(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter("tenant", original.sessionId(), + assertThatThrownBy(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter("tenant", original.sessionId(), "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "writer", 60000L))) .isInstanceOf(IllegalStateException.class) .hasMessage("CSI mutation requires its original transaction connection"); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/HarnessPlacementStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/HarnessPlacementStoreTest.java index a84327a1773..a46653957e4 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/HarnessPlacementStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/HarnessPlacementStoreTest.java @@ -57,11 +57,10 @@ public class HarnessPlacementStoreTest { jdbc = new ReleaseFaultJdbc(source); tx = new TransactionTemplate(new DataSourceTransactionManager(source)); placements = new HarnessPlacementStore(jdbc); - store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); var properties = new ManagedAgentProperties(); properties.getSessionStore().setBindingKey("0123456789abcdef0123456789abcdef"); credentials = new WriterCredentialPolicy(properties); - store.setCredentials(credentials); + store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, credentials); jdbc.update("INSERT INTO ma_session (tenant_id, session_id, agent_id, gmt_create, gmt_modified)" + " VALUES (?, ?, 'qwen-code', 1, 1)", TENANT, SESSION); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/LocalStorageAliasTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/LocalStorageAliasTest.java index 6bf3e836ff9..738017858dc 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/LocalStorageAliasTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/LocalStorageAliasTest.java @@ -271,7 +271,7 @@ class LocalStorageAliasTest { String session = "retired-private"; session(tenant, "other-storage", session); var transactions = new TransactionTemplate(manager); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "a".repeat(64); byte[] bytes = ("{\"subtype\":\"session_execution_engine\"}\n{\"subtype\":\"managed_session_header_v1\"}\n").getBytes(java.nio.charset.StandardCharsets.UTF_8); transactions.execute(status -> journal.acquireWriter(tenant, session, token, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentCsiMutationMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentCsiMutationMySqlIT.java index 27899b2bddd..025ecc71031 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentCsiMutationMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentCsiMutationMySqlIT.java @@ -297,7 +297,7 @@ class ManagedAgentCsiMutationMySqlIT { jdbc.update( "INSERT INTO ma_workspace_access (tenant_id, workspace_id, actor_id, role)" + " VALUES ('tenant', 'workspace', ?, 'OPERATOR')", com.alibaba.qwen.code.managedagent.repository.ManagedWorkspaceRegistry.actorKey("tenant", "actor")); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var transaction = new TransactionTemplate(manager); transaction.setIsolationLevel(isolation); transaction.setTimeout(15); @@ -385,7 +385,7 @@ class ManagedAgentCsiMutationMySqlIT { String session = com.alibaba.qwen.code.managedagent.persistence.PrivateCsiFixture.create(jdbc, manager, json, properties, new com.alibaba.qwen.code.managedagent.contracts.workspace.PrivateCsiSession.Request(registration, "actor", "original", null, null, new WorkspaceSelection("workspace", "."))).sessionId(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var transaction = new TransactionTemplate(manager); var hold = manager.getTransaction(new DefaultTransactionDefinition()); try { diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java index 325d9c1945b..d11bbad3f66 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedAgentStoreWiringTest.java @@ -69,16 +69,16 @@ class ManagedAgentStoreWiringTest { var store = context.getBean(ManagedAgentStore.class); assertThat(AopUtils.isAopProxy(store)).isTrue(); Object target = AopTestUtils.getUltimateTargetObject(store); - assertThat(ReflectionTestUtils.getField(target, "creationSkills")) + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "creationSkills")) .isSameAs(context.getBean(ManagedSessionSkills.class)); - assertThat(ReflectionTestUtils.getField(target, "worktreeFiles")) + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "worktreeFiles")) .isSameAs(context.getBean(WorktreeFiles.class)); - assertThat(ReflectionTestUtils.getField(target, "extensionPackages")) + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "extensionPackages")) .isSameAs(context.getBean(ExtensionPackages.class)); for (String name : List.of("lifecycle", "definitions", "sessionWorktrees", "journalEventTransport")) { - assertThat(ReflectionTestUtils.getField(target, name)).as(name).isNull(); + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, name)).as(name).isNull(); } - assertThat(AopUtils.isAopProxy(ReflectionTestUtils.getField(target, "configRecords"))).isTrue(); + assertThat(AopUtils.isAopProxy(com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "configRecords"))).isTrue(); }); } @@ -103,7 +103,7 @@ class ManagedAgentStoreWiringTest { assertThat(context).hasNotFailed(); var store = context.getBean(ManagedAgentStore.class); Object target = AopTestUtils.getUltimateTargetObject(store); - var records = (Advised) ReflectionTestUtils.getField(target, "configRecords"); + var records = (Advised) com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreField(target, "configRecords"); assertThat(context.getBeanFactory().containsSingleton("recordStore")).isFalse(); Object resolved = records.getTargetSource().getTarget(); assertThat(context.getBeanFactory().containsSingleton("recordStore")).isTrue(); @@ -115,11 +115,16 @@ class ManagedAgentStoreWiringTest { @Test void dependencyReferencesAreFinalAndHaveNoPostConstructionInjection() throws Exception { + var store = new ManagedAgentStore(new JdbcTemplate(mock(DataSource.class)), new ObjectMapper(), + Clock.systemUTC(), events -> {}, mock(ManagedWorkspaceRegistry.class), + new ManagedAgentProperties(), mock(HarnessRules.class)); for (String name : List.of("worktreeFiles", "creationSkills", "extensionPackages", "lifecycle", "definitions", "sessionWorktrees", "journalEventTransport", "configRecords")) { - var field = ManagedAgentStore.class.getDeclaredField(name); - assertThat(Modifier.isFinal(field.getModifiers())).as(name).isTrue(); - assertThat(field.isAnnotationPresent(Autowired.class)).as(name).isFalse(); + for (Object owner : com.alibaba.qwen.code.managedagent.VerificationFixtures.agentStoreOwners(store, name)) { + var field = owner.getClass().getDeclaredField(name); + assertThat(Modifier.isFinal(field.getModifiers())).as(name).isTrue(); + assertThat(field.isAnnotationPresent(Autowired.class)).as(name).isFalse(); + } } assertThat(Arrays.stream(ManagedAgentStore.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(Autowired.class))).isEmpty(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeSnapshotRetirementTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeSnapshotRetirementTest.java index 5be0a63d80f..cc2c38bb5df 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeSnapshotRetirementTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedNativeSnapshotRetirementTest.java @@ -21,7 +21,7 @@ class ManagedNativeSnapshotRetirementTest { ManagedSchema.initialize(source); var jdbc = new ManagedSchemaJdbcTemplate(source); var tx = new TransactionTemplate(new DataSourceTransactionManager(source)); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var snapshots = new ManagedNativeJournalSnapshots(jdbc, sessions, new ObjectMapper()); for (String session : java.util.List.of("retiring", "protected")) { tx.executeWithoutResult(status -> new ExtensionRecordJournal(sessions, "tenant", "workspace", session).open()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaPublicationStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaPublicationStoreTest.java index 6b5a4446bff..539f9a1077a 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaPublicationStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaPublicationStoreTest.java @@ -42,10 +42,9 @@ public class ManagedSchemaPublicationStoreTest { void asyncVerificationSealsAndAdmissionReuseTheSameImmutableResourceRow() { DataSource source = dataSource(); ManagedSchema.initialize(source); - var fixture = PublicationJournalFixture.create(source); - fixture.reserve(); var bucket = new Bucket(); - fixture.sessions.setPublicationObjects(bucket); + var fixture = PublicationJournalFixture.create(source, null, bucket, null); + fixture.reserve(); var data = new ToolPublicationDataStore(fixture.jdbc, fixture.manager, fixture.store, fixture.sessions, bucket, Duration.ofMinutes(2), Duration.ofSeconds(30), new ToolPublicationDataStore.VerificationBudget(16 * 1024 * 1024, Duration.ofMinutes(25))); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaSessionStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaSessionStoreTest.java index 8fbee8a063e..de9a61165a4 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaSessionStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSchemaSessionStoreTest.java @@ -243,7 +243,7 @@ public class ManagedSchemaSessionStoreTest { @Test void projectionCleanupPreservesJournalReferencesAndRecoveryProtection() { alignProjectionClock(); - var privateBody = tx.execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)) + var privateBody = tx.execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc) .publishAgentInstructions(tenant, "workspace", session, "private instructions")); event("big", "a".repeat(70_000)); materialize(); @@ -459,7 +459,7 @@ public class ManagedSchemaSessionStoreTest { @Test void writerHeartbeatCompletesWhileMessagePageReadIsPaused() throws Exception { - var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "a".repeat(32); var grant = tx.execute(status -> privateStore.acquireWriter(tenant, session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "writer", 60_000L))); @@ -492,7 +492,7 @@ public class ManagedSchemaSessionStoreTest { tenant, session, session); - var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var privateStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); assertThatThrownBy(() -> privateStore.restore(tenant, "workspace", session, token)) .isInstanceOfSatisfying( ManagedAgentException.class, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionGuardConvoyIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionGuardConvoyIT.java index cffc4cc808d..de6d9bcccb6 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionGuardConvoyIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionGuardConvoyIT.java @@ -125,7 +125,7 @@ class ManagedSessionGuardConvoyIT { var jdbc = new ManagedSchemaJdbcTemplate(pool); var manager = new DataSourceTransactionManager(pool); var transactions = new TransactionTemplate(manager); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); int callers = 4; var ready = new CountDownLatch(callers); var release = new CountDownLatch(1); @@ -156,7 +156,7 @@ class ManagedSessionGuardConvoyIT { var jdbc = new ManagedSchemaJdbcTemplate(source); var manager = new DataSourceTransactionManager(source); var transactions = new TransactionTemplate(manager); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String tenant = "tenant-a"; String workspace = "workspace-a"; String token = "w".repeat(32); @@ -300,7 +300,7 @@ class ManagedSessionGuardConvoyIT { var jdbc = new ManagedSchemaJdbcTemplate(source); var manager = new DataSourceTransactionManager(source); var transactions = new TransactionTemplate(manager); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String tenant = "tenant-a"; String workspace = "workspace-a"; String token = "w".repeat(32); @@ -527,7 +527,7 @@ class ManagedSessionGuardConvoyIT { var jdbc = new ManagedSchemaJdbcTemplate(source); var manager = new DataSourceTransactionManager(source); var transactions = new TransactionTemplate(manager); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String tenant = "tenant-a"; String workspace = "workspace-a"; String token = "w".repeat(32); @@ -669,7 +669,7 @@ class ManagedSessionGuardConvoyIT { var pausing = new PauseAfterDataSource(source, trigger); var jdbc = new ManagedSchemaJdbcTemplate(pausing); var transactions = new TransactionTemplate(new DataSourceTransactionManager(pausing)); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var grant = transactions.execute(ignored -> store.acquireWriter(TENANT, "victim", TOKEN, new AcquireWriterRequest(WORKSPACE, "writer-victim", 60_000L))); long deadlocksBefore = MySqlLockProbe.innodbDeadlocks(jdbc); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionNativeReadTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionNativeReadTest.java index 135e43bd295..d602d4714fe 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionNativeReadTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionNativeReadTest.java @@ -84,7 +84,7 @@ class ManagedSessionNativeReadTest { binding = bindings.compareAndSet(binding, binding.withAttestation(lease, new RuntimeResourceHandle("kubernetes-workspace", 3, Map.of("podUid", "fixture-original")), Instant.now(), Instant.now())); - store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); transactions.execute(status -> store.acquireWriter("tenant", session, TOKEN, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "writer", 300_000L))); jdbc.update( diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionPhysicalFenceTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionPhysicalFenceTest.java index b592bedf999..9eee810d8d4 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionPhysicalFenceTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionPhysicalFenceTest.java @@ -50,7 +50,7 @@ class ManagedSessionPhysicalFenceTest { ManagedSchema.initialize(source); jdbc = new ManagedSchemaJdbcTemplate(source); transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); generation = transactions .execute( diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreCredentialTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreCredentialTest.java index c2a1aa0df00..0eb348474b0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreCredentialTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreCredentialTest.java @@ -31,11 +31,10 @@ class ManagedSessionStoreCredentialTest { void lifecycleWriterPathsRefuseForeignCredentialsBeforeStateLookup(String route) { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.getDataSource()).thenReturn(new JdbcDataSource()); - ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); ManagedAgentProperties properties = new ManagedAgentProperties(); properties.getSessionStore().setBindingKey(KEY); WriterCredentialPolicy policy = new WriterCredentialPolicy(properties); - store.setCredentials(policy); + ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, policy); reset(jdbc); for (String token : new String[]{null, "self-minted-token-self-minted-token-0", @@ -76,11 +75,11 @@ class ManagedSessionStoreCredentialTest { void publicationWriterLockRequiresTheBoundCredential() { JdbcDataSource dataSource = new JdbcDataSource(); dataSource.setURL("jdbc:h2:mem:credential-check;MODE=MySQL"); - ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore( - new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource))); ManagedAgentProperties properties = new ManagedAgentProperties(); properties.getSessionStore().setBindingKey(KEY); - store.setCredentials(new WriterCredentialPolicy(properties)); + ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore( + new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource), + new WriterCredentialPolicy(properties)); assertThatThrownBy(() -> store.lockPublicationWriter("tenant", "workspace", "session", "writer", 1, @@ -99,12 +98,11 @@ class ManagedSessionStoreCredentialTest { + "DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE"); Flyway.configure().dataSource(dataSource) .locations("classpath:db/managed-schema").load().migrate(); - ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore( - new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource))); ManagedAgentProperties properties = new ManagedAgentProperties(); properties.getSessionStore().setBindingKey(KEY); WriterCredentialPolicy policy = new WriterCredentialPolicy(properties); - store.setCredentials(policy); + ManagedSessionStore store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore( + new com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate(dataSource), policy); // Any outcome other than the credential refusal means the issued // credential passed the gate; the session itself does not exist. diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreWiringTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreWiringTest.java new file mode 100644 index 00000000000..696200dfd1c --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStoreWiringTest.java @@ -0,0 +1,193 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import com.alibaba.qwen.code.managedagent.config.ManagedSessionReaderVersion; +import com.alibaba.qwen.code.managedagent.contracts.artifact.ToolPublicationObjectStore; +import com.alibaba.qwen.code.managedagent.contracts.event.JournalEventTransport; +import com.alibaba.qwen.code.managedagent.contracts.persistence.StorageReadPolicy; +import com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy; +import com.alibaba.qwen.code.managedagent.contracts.session.SessionQueryStore; +import com.alibaba.qwen.code.managedagent.harness.contracts.HarnessRules; +import com.alibaba.qwen.code.runtimebroker.RuntimeBindingRepository; +import com.alibaba.qwen.code.runtimebroker.persistence.JdbcRuntimeBindingRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.lang.Nullable; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ManagedSessionStoreWiringTest { + private final DataSource source = mock(DataSource.class); + + private ApplicationContextRunner runner(boolean factory, String missing) { + var runner = new ApplicationContextRunner() + .withUserConfiguration(Transactions.class, factory ? Factory.class : Imported.class) + .withBean("managedJdbcTemplate", JdbcTemplate.class, () -> new JdbcTemplate(source)); + if (!missing.equals("records")) { + runner = runner.withBean(ManagedExtensionRecordStore.class, () -> mock(ManagedExtensionRecordStore.class)); + } + if (!missing.equals("actions")) { + runner = runner.withBean(ManagedActionStore.class, () -> mock(ManagedActionStore.class)); + } + if (!missing.equals("rules")) { + runner = runner.withBean(HarnessRules.class, () -> mock(HarnessRules.class)); + } + if (!missing.equals("reader")) { + runner = runner.withBean(StorageReadPolicy.class, ManagedSessionReaderVersion::new); + } + if (!missing.equals("credentials")) { + runner = runner.withBean(WriterCredentialPolicy.class, WriterCredentialPolicy::unbound); + } + return runner; + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void springAndFactoryConstructionKeepRequiredDependenciesAndTransactionProxy(boolean factory) { + runner(factory, "").run(context -> { + assertThat(context).hasNotFailed(); + var store = context.getBean(ManagedSessionStore.class); + assertThat(AopUtils.isAopProxy(store)).isTrue(); + Object target = AopTestUtils.getUltimateTargetObject(store); + assertThat(field(target, "lifecycleAuthorization", "harnessRules")).isSameAs(context.getBean(HarnessRules.class)); + assertThat(field(target, "storeRows", "readerVersion")).isSameAs(context.getBean(StorageReadPolicy.class)); + assertThat(field(target, "fencing", "credentials")).isSameAs(context.getBean(WriterCredentialPolicy.class)); + assertThat(field(target, "lifecycleAuthorization", "lifecycleExecution")).isNull(); + assertThat(field(target, "lifecycleAuthorization", "lifecycleSessions")).isNull(); + assertThat(field(target, "resourceReads", "publicationObjects")).isNull(); + assertThat(field(target, "commits", "toolResults")).isNull(); + assertThat(field(target, "fencing", "csiBindings")).isNull(); + assertThat(field(target, "commits", "journalEventTransport")).isNull(); + }); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void optionalDependenciesReachTheirOwners(boolean factory) { + var objects = mock(ToolPublicationObjectStore.class); + var results = mock(ManagedToolResultStore.class); + var transport = mock(JournalEventTransport.class); + runner(factory, "") + .withBean(ToolPublicationObjectStore.class, () -> objects) + .withBean(ManagedToolResultStore.class, () -> results) + .withBean(JournalEventTransport.class, () -> transport) + .run(context -> { + assertThat(context).hasNotFailed(); + Object target = AopTestUtils.getUltimateTargetObject(context.getBean(ManagedSessionStore.class)); + assertThat(field(target, "resourceReads", "publicationObjects")).isSameAs(objects); + assertThat(field(target, "commits", "toolResults")).isSameAs(results); + assertThat(field(target, "commits", "journalEventTransport")).isSameAs(transport); + assertThat(field(target, "fencing", "journalEventTransport")).isSameAs(transport); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"records", "actions", "rules", "reader", "credentials"}) + void requiredDependenciesStillFailStartup(String missing) { + for (boolean factory : new boolean[] {false, true}) { + runner(factory, missing).run(context -> assertThat(context).hasFailed()); + } + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3}) + void lifecycleDependenciesAreEnabledOnlyAsAPair(int present) { + for (boolean factory : new boolean[] {false, true}) { + var runner = runner(factory, ""); + var execution = mock(WorkspaceExecutionStore.class); + var sessions = mock(SessionQueryStore.class); + if ((present & 1) != 0) { + runner = runner.withBean(WorkspaceExecutionStore.class, () -> execution) + .withBean(com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles.class, + () -> mock(com.alibaba.qwen.code.managedagent.contracts.workspace.WorktreeFiles.class)); + } + if ((present & 2) != 0) { + runner = runner.withBean(SessionQueryStore.class, () -> sessions); + } + runner.run(context -> { + assertThat(context).hasNotFailed(); + Object target = AopTestUtils.getUltimateTargetObject(context.getBean(ManagedSessionStore.class)); + assertThat(field(target, "lifecycleAuthorization", "lifecycleExecution")).isSameAs(present == 3 ? execution : null); + assertThat(field(target, "lifecycleAuthorization", "lifecycleSessions")).isSameAs(present == 3 ? sessions : null); + }); + } + } + + @ParameterizedTest + @ValueSource(strings = {"same", "foreign", "non-jdbc"}) + void csiRequiresTheOriginalJdbcDataSource(String kind) { + RuntimeBindingRepository bindings; + if (kind.equals("non-jdbc")) { + bindings = mock(RuntimeBindingRepository.class); + } else { + var jdbcBindings = mock(JdbcRuntimeBindingRepository.class); + when(jdbcBindings.usesDataSource(source)).thenReturn(kind.equals("same")); + bindings = jdbcBindings; + } + runner(false, "").withBean(RuntimeBindingRepository.class, () -> bindings).run(context -> { + assertThat(context).hasNotFailed(); + Object target = AopTestUtils.getUltimateTargetObject(context.getBean(ManagedSessionStore.class)); + assertThat(field(target, "fencing", "csiBindings")).isSameAs(kind.equals("same") ? bindings : null); + }); + } + + @Test + void standaloneDefaultsRemainAndPostConstructionInjectionIsGone() { + var store = new ManagedSessionStore(new JdbcTemplate(source)); + assertThat(field(store, "lifecycleAuthorization", "harnessRules")).isNull(); + assertThat(field(store, "storeRows", "readerVersion")).isNotNull(); + ((WriterCredentialPolicy) field(store, "fencing", "credentials")).require("tenant", "workspace", "session", "token"); + assertThat(Arrays.stream(ManagedSessionStore.class.getDeclaredMethods())) + .noneMatch(method -> method.isAnnotationPresent(Autowired.class)); + assertThat(Arrays.stream(ManagedSessionStore.class.getDeclaredConstructors()) + .filter(constructor -> constructor.isAnnotationPresent(Autowired.class))).hasSize(1); + } + + private static Object field(Object store, String component, String name) { + return ReflectionTestUtils.getField(ReflectionTestUtils.getField(store, component), name); + } + + @Configuration(proxyBeanMethods = false) + @EnableTransactionManagement(proxyTargetClass = true) + static class Transactions { + @Bean + PlatformTransactionManager managedAgentTransactionManager(JdbcTemplate jdbc) { + return new DataSourceTransactionManager(jdbc.getDataSource()); + } + } + + @Configuration(proxyBeanMethods = false) + @Import(ManagedSessionStore.class) + static class Imported { } + + @Configuration(proxyBeanMethods = false) + static class Factory { + @Bean + ManagedSessionStore journal(JdbcTemplate jdbc, ManagedExtensionRecordStore records, ManagedActionStore actions, + HarnessRules rules, StorageReadPolicy reader, WriterCredentialPolicy credentials, + @Nullable WorkspaceExecutionStore execution, @Nullable SessionQueryStore sessions, + @Nullable ToolPublicationObjectStore objects, @Nullable ManagedToolResultStore results, + @Nullable JournalEventTransport transport, @Nullable RuntimeBindingRepository bindings) { + return new ManagedSessionStore(jdbc, records, actions, rules, reader, execution, sessions, + objects, results, transport, credentials, bindings); + } + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java new file mode 100644 index 00000000000..f32d00e5ab7 --- /dev/null +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ManagedStoreDecompositionTest.java @@ -0,0 +1,162 @@ +package com.alibaba.qwen.code.managedagent.persistence; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class ManagedStoreDecompositionTest { + private static final Set> COMPONENTS = Set.of( + AgentStoreRows.class, + AgentEventJournal.class, + AgentSessionQueries.class, + AgentMessageProjection.class, + AgentSessionPolicies.class, + AgentSessionCommands.class, + AgentLifecycleOperations.class, + AgentSessionAdmissions.class, + AgentTurnExecution.class, + AgentExecutionOperations.class, + JournalStoreRows.class, + JournalFencing.class, + JournalHistoryQueries.class, + JournalLifecycleAuthorization.class, + JournalResourceReads.class, + JournalResourceWrites.class, + JournalWriterLifecycle.class, + JournalCommit.class); + + @Test + void collaboratorsAreImmutableOrdinaryObjectsWithAcyclicDependencies() { + for (Class component : COMPONENTS) { + assertThat(Modifier.isFinal(component.getModifiers())).as(component.getSimpleName()).isTrue(); + assertThat(Modifier.isPublic(component.getModifiers())).isFalse(); + assertThat(component.getInterfaces()).isEmpty(); + assertThat(component.isAnnotationPresent(Component.class)).isFalse(); + for (var field : component.getDeclaredFields()) { + assertThat(Modifier.isFinal(field.getModifiers())).as(field.toString()).isTrue(); + assertThat(field.getType()).isNotIn(ManagedAgentStore.class, ManagedSessionStore.class); + } + assertThat(Arrays.stream(component.getDeclaredMethods())) + .noneMatch(method -> method.isAnnotationPresent(Transactional.class)); + requireAcyclic(component, new HashSet<>()); + } + } + + private static void requireAcyclic(Class component, Set> visiting) { + assertThat(visiting.add(component)).as("dependency cycle at %s", component.getSimpleName()).isTrue(); + for (var field : component.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && COMPONENTS.contains(field.getType())) { + requireAcyclic(field.getType(), visiting); + } + } + visiting.remove(component); + } + + @Test + void facadesKeepTheExistingEntryAndTransactionPopulation() { + assertEntries(ManagedAgentStore.class, """ + findSessionWorktree/2:- findSessionWorktrees/2:- insertSessionCommand/10::REQUIRED:DEFAULT:false + insertWorkspaceSessionCommand/1::REQUIRED:DEFAULT:false insertInternalTeamLeadSessionCommand/6::REQUIRED:DEFAULT:false findWorkspaceCreateReplay/4:- + replayWorkspaceSessionCommand/4::REQUIRED:DEFAULT:false insertCsiSessionCommand/7:- requireCsiRequest/2:- + insertChildSessionCommand/8::REQUIRED:DEFAULT:false insertChildSessionCommand/9::REQUIRED:DEFAULT:false findChildWorkspaceCwd/3:- + insertPublicChildSessionCommand/9::REQUIRED:DEFAULT:false replayChildSessionCommand/4::REQUIRED:DEFAULT:false findChildLineage/2:- + workspaceSessionOptions/2::REQUIRED:DEFAULT:false supportsPlanMode/1:- hasMcpServers/1:- + requireMcpManagement/4::REQUIRED:DEFAULT:false requireMcpSettingsCommit/3::MANDATORY:DEFAULT:false requireExtensionManagement/5::REQUIRED:DEFAULT:false + requireConfigurationCommit/4::MANDATORY:DEFAULT:false insertTurnCommand/7::REQUIRED:DEFAULT:false insertTurnCommand/8::REQUIRED:DEFAULT:false + insertCancelCommand/6::REQUIRED:DEFAULT:false insertCancelCommand/7::REQUIRED:DEFAULT:false beginSessionRename/5::REQUIRED:DEFAULT:false + beginSessionRename/6::REQUIRED:DEFAULT:false completeSessionRename/6::REQUIRED:DEFAULT:false completeSessionRename/3::REQUIRED:DEFAULT:false + abandonSessionRename/5::REQUIRED:DEFAULT:false deliverableRenames/1::REQUIRED:DEFAULT:false claimRename/3::REQUIRED:DEFAULT:false + markRenameSubmitted/2:- retryRename/3::REQUIRED:DEFAULT:false beginSessionMutation/6::REQUIRED:DEFAULT:false + beginSessionMutation/7::REQUIRED:DEFAULT:false completeSessionMutation/7::REQUIRED:DEFAULT:false abandonSessionMutation/4::REQUIRED:DEFAULT:false + beginOperation/6::REQUIRED:DEFAULT:false workspaceFilesEnabled/0:- workspaceShellEnabled/0:- + beginWorkspaceClose/7::REQUIRED:DEFAULT:false beginWorkspaceLifecycle/8::REQUIRED:DEFAULT:false beginWorkspaceLifecycle/9::REQUIRED:DEFAULT:false + hasCompletedWorkspaceClose/2:- completedWorkspaceCloses/2:- unarchiveWorkspaceSession/5::REQUIRED:DEFAULT:false + beginChildClose/8::REQUIRED:DEFAULT:false beginContextCompression/8::REQUIRED:DEFAULT:false contextCompressionState/2:- + contextCompressionStates/2:- contextCompressionRequest/3:- findContextCompressions/1:- + claimContextCompression/5::REQUIRED:DEFAULT:false requireContextCompressionCommit/3:- markContextCompressionSubmitted/2::REQUIRED:DEFAULT:false + settleContextCompression/5::REQUIRED:DEFAULT:false retryContextCompressionPreparation/3::REQUIRED:DEFAULT:false awaitContextCompression/5::REQUIRED:DEFAULT:false + completeCwdChangeOperation/5::REQUIRED:DEFAULT:false failCwdChangeOperation/6::REQUIRED:DEFAULT:false beginTaskCancelOperation/6::REQUIRED:DEFAULT:false + findDeliverableTaskCancels/1:- findParkedOperations/1:- settleParkedOperation/3::REQUIRED:DEFAULT:false + turnInputAcceptance/3:- renameAcceptance/1:- settleParkedRename/3::REQUIRED:DEFAULT:false + settleTaskCancel/7::REQUIRED:DEFAULT:false hasExecutionRegistryFacts/2:- sessionsWithExecutionRegistryFacts/2:- + findOperation/3:- findDeliverableOperations/2:- claimOperation/5::REQUIRED:DEFAULT:false + completeOperation/6::REQUIRED:DEFAULT:false failOperation/6::REQUIRED:DEFAULT:false renewLifecycleOperation/6::REQUIRED:DEFAULT:false + blockLifecycleOperation/7::REQUIRED:DEFAULT:false blockLifecycleOperation/8::REQUIRED:DEFAULT:false retryOperation/6::REQUIRED:DEFAULT:false + retryOperation/7::REQUIRED:DEFAULT:false markOperationAnswered/5::REQUIRED:DEFAULT:false replayCommand/4:- + findCommand/3:- findSession/2:- findSessionById/1:- + listSessions/5:- listSessions/6:- listSessions/7:- + findLatestLifecycleOperations/2:- findTurn/3:- findActiveTurns/2:- + findLatestTurns/2:- findModelContexts/2:- modelOptions/0:- + findModelSelections/2:- findTurnModelSelection/3:- changeSessionModel/8::REQUIRED:DEFAULT:false + findApprovalSelections/2:- findApprovalOwners/3:- findTurnApprovalSelection/3:- + changeSessionApproval/9::REQUIRED:DEFAULT:false changeSessionApproval/11::REQUIRED:DEFAULT:false changeSessionApproval/12::REQUIRED:DEFAULT:false + changeSessionApproval/15::REQUIRED:DEFAULT:false listTurns/5:- findTurnSummary/3:- + findEvents/4:- findLatestEnvironmentEvents/2:- findControlEvents/3:- + findTranscriptEvents/4:- findMessagePage/5:managedAgentTransactionManager:REQUIRED:REPEATABLE_READ:true findMessagePage/6:managedAgentTransactionManager:REQUIRED:REPEATABLE_READ:true + findMessageCoveredSequences/2:- findReplayWindow/2:- findReplayFloorTargets/1:- + advanceReplayFloor/3::REQUIRED:DEFAULT:false findMaterializationTargets/1:- materializeNextBatch/3::REQUIRED:DEFAULT:false + deferMaterializationTarget/2::REQUIRED:DEFAULT:false findDispatchable/2:- findRuntimeRecoveryParked/2:- + claimTurn/5::REQUIRED:DEFAULT:false renewTurn/5::REQUIRED:DEFAULT:false releaseTurnLease/4::REQUIRED:DEFAULT:false + markTurnRecoveryBlocked/6::REQUIRED:DEFAULT:false markTurnFailureRecovery/5::REQUIRED:DEFAULT:false requestRecoveryRecheck/3::REQUIRED:DEFAULT:false + parkTurnForRuntimeRecovery/6::REQUIRED:DEFAULT:false deferTurnRetry/5::REQUIRED:DEFAULT:false scheduleTurnRetry/6::REQUIRED:DEFAULT:false + bindHarness/5::REQUIRED:DEFAULT:false bindRecoveredHarness/6::REQUIRED:DEFAULT:false markSubmissionAttempted/4::REQUIRED:DEFAULT:false + withdrawSubmissionAttempted/4::REQUIRED:DEFAULT:false recordAdmission/6::REQUIRED:DEFAULT:false recordRecoveryAdmission/8::REQUIRED:DEFAULT:false + retractContinuationOutput/6::REQUIRED:DEFAULT:false retractHarnessTurnOutput/7::REQUIRED:DEFAULT:false retractHarnessTurnOutput/10::REQUIRED:DEFAULT:false + recordHarnessEvents/6::REQUIRED:DEFAULT:false cancelUnadmittedTurn/4::REQUIRED:DEFAULT:false cancelBeforeAdmission/4::REQUIRED:DEFAULT:false + failTurn/6::REQUIRED:DEFAULT:false failWorktreeInitialTurns/3::REQUIRED:DEFAULT:false failRecoveryBlockedTurn/5::REQUIRED:DEFAULT:false + appendPublicEventIfAbsent/7::REQUIRED:DEFAULT:false appendLiveSessionEventIfAbsent/5::REQUIRED:DEFAULT:false findLastVisibleStatusBeforeDelete/2:- + requireSession/2:- readBinding/1:- insertSessionCommand/9::REQUIRED:DEFAULT:false + """); + assertEntries(ManagedSessionStore.class, """ + authorizeLifecycle/5::REQUIRED:DEFAULT:false skipContended/2:- lockBounded/1:- + lockBoundedResult/1:- namedRootDeadlock/1:- authorizeOrdinary/4::REQUIRED:DEFAULT:false + approvalSelection/4::REQUIRED:DEFAULT:false usesDataSource/1:- lockSnapshotWriter/4:- + lockPublicationWriter/6:- acquireWriter/4::REQUIRED:DEFAULT:false acquireWriter/5::REQUIRED:DEFAULT:false + renewWriter/4::REQUIRED:DEFAULT:false renewWriter/5::REQUIRED:DEFAULT:false sealWriter/4::REQUIRED:DEFAULT:false + sealWriter/5::REQUIRED:DEFAULT:false quiesceOwnership/4::REQUIRED:DEFAULT:false releaseOwnership/4::REQUIRED:DEFAULT:false + blockRecovery/4::REQUIRED:DEFAULT:false commit/4::REQUIRED:DEFAULT:false commit/5::REQUIRED:DEFAULT:false + restore/4::REQUIRED:DEFAULT:true transactions/6::REQUIRED:DEFAULT:true recentTransactions/7::REQUIRED:READ_COMMITTED:true + commandTransaction/7::REQUIRED:READ_COMMITTED:true hasPristineInitialJournal/3::NOT_SUPPORTED:DEFAULT:true readResource/5:- + readSnapshotResource/5:- readOwnerResource/5:- publishAgentInstructions/4::REQUIRED:DEFAULT:false + publishAgentSkills/4::REQUIRED:DEFAULT:false publishInheritedExtensionResource/4::REQUIRED:DEFAULT:false publishToolResult/4::REQUIRED:DEFAULT:false + publishToolResult/5::REQUIRED:DEFAULT:false toolResultLimit/1:- requireCsiApplicationCredential/3:- + requireCsiHistoricalCredential/5:- preflightCsiTerminal/5:- commitCsiTerminal/5:- + validateCsiTerminalBytes/1:- lockCsiOriginal/2:- hasLiveWriter/2::REQUIRED:DEFAULT:false + commandKeyHash/2:- journalCorrupt/0:- + """); + } + + private static void assertEntries(Class facade, String expected) { + assertThat(facade.getAnnotation(Qualifier.class).value()).isEqualTo("managedAgentTransactionManager"); + List actual = Arrays.stream(facade.getDeclaredMethods()) + .filter(method -> !Modifier.isPrivate(method.getModifiers()) && !method.isSynthetic()) + .map(method -> { + var tx = method.getAnnotation(Transactional.class); + String attributes = tx == null ? "-" : tx.transactionManager() + ":" + tx.propagation() + + ":" + tx.isolation() + ":" + tx.readOnly(); + return method.getName() + "/" + method.getParameterCount() + ":" + attributes; + }).toList(); + assertThat(actual).containsExactlyInAnyOrderElementsOf(List.of(expected.trim().split("\\s+"))); + } + + @Test + void facadeSourcesContainNoBusinessSql() throws Exception { + Path sources = Path.of("..", "persistence", "src", "main", "java") + .resolve(ManagedAgentStore.class.getPackageName().replace('.', '/')); + for (Class facade : List.of(ManagedAgentStore.class, ManagedSessionStore.class)) { + assertThat(Files.readString(sources.resolve(facade.getSimpleName() + ".java"))) + .doesNotContain("SELECT ", "INSERT INTO ", "UPDATE ma_", "DELETE FROM "); + } + } +} diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/O4MySqlGate.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/O4MySqlGate.java index 467a878bed3..6442efa28a9 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/O4MySqlGate.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/O4MySqlGate.java @@ -58,7 +58,7 @@ public class O4MySqlGate extends ToolPublicationCollectorTest { var writer = executor.submit(() -> tx.execute(status -> { connection.set(jdbc.queryForObject("SELECT CONNECTION_ID()", Long.class)); entered.countDown(); - return com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).acquireWriter(tenant, session, + return com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "late", 60000L)); })); assertThat(entered.await(10, TimeUnit.SECONDS)).isTrue(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionResourceCollectionCollectorTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionResourceCollectionCollectorTest.java index b501b736715..b8306c5e6d0 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionResourceCollectionCollectorTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionResourceCollectionCollectorTest.java @@ -845,7 +845,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten void readAfterCollectionAnswersTheSessionRetiredOutcome() { initSession(); head(session); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); publish("collected", CONTENT, new byte[64]); @@ -1021,7 +1021,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten @Test void tamperedLiveRowStillFailsClosed() { initSession(); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); publish("tampered", CONTENT, new byte[64]); @@ -1124,7 +1124,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten void collectsRowsPublishedThroughTheRealProducerByteExact() { initSession(); head(session); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var grant = tx.execute(status -> sessions.acquireWriter(tenant, session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); publishThroughStore(sessions, grant.writerGeneration(), "produced-content", CONTENT, new byte[1024]); @@ -1375,7 +1375,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten com.alibaba.qwen.code.managedagent.repository.ManagedSessionStore.sessionScopeKey(tenant, "media-other"), "media-outcome")); assertThat(MEDIA_JSON.readTree(bytes).path("functionResponse").path("parts").get(0) .path("inlineData").path("data").asText()).isEqualTo("AQID"); - byte[] history = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).readResource(tenant, "workspace-1", session, + byte[] history = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).readResource(tenant, "workspace-1", session, original.path("historyRef").path("resourceId").asText(), MEDIA_TOKEN).bytes(); assertThat(history).isEqualTo(Base64.getDecoder().decode( original.required("historyBytesBase64").textValue())); @@ -1412,12 +1412,12 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten assertThat(ledger().get("collected_bytes")).isEqualTo(expected); assertThat(collector(true, Duration.ZERO).runOnce()).isFalse(); assertThat(ledger().get("collected_bytes")).isEqualTo(expected); - assertThatThrownBy(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).readResource(tenant, "workspace-1", + assertThatThrownBy(() -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).readResource(tenant, "workspace-1", session, "media-outcome", MEDIA_TOKEN)) .hasFieldOrPropertyWithValue("code", "tool_output_session_retired"); assertThatThrownBy(() -> readRecoveryMedia(original)).hasMessageContaining("resource_collected"); assertMediaReadable("media-other", bytes); - assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).transactions(tenant, "workspace-1", "media-other", + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).transactions(tenant, "workspace-1", "media-other", MEDIA_TOKEN, 0, 100).transactions()).hasSize(other.required("transactions").size()); } @@ -1432,7 +1432,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten + " session_scope_key = ?", Long.class, resScope); boolean reader = "reader".equals(protection); if (!reader) { - tx.executeWithoutResult(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).blockRecovery(tenant, + tx.executeWithoutResult(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).blockRecovery(tenant, session, MEDIA_TOKEN, new ManagedSessionStoreModels.BlockRecoveryRequest( "workspace-1", "media-writer", 1, "BLOCKED_RESOURCE", "media_evidence"))); } @@ -1502,7 +1502,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten } } JsonNode fixture = MEDIA_JSON.readTree(output.toFile()); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> store.acquireWriter(tenant, sessionId, MEDIA_TOKEN, new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "media-writer", 60000L))); for (JsonNode transaction : fixture.required("transactions")) { @@ -1517,7 +1517,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten } private void assertMediaReadable(String sessionId, byte[] expected) { - assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).readResource(tenant, "workspace-1", sessionId, + assertThat(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).readResource(tenant, "workspace-1", sessionId, "media-outcome", MEDIA_TOKEN).bytes()).isEqualTo(expected); } @@ -1533,7 +1533,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten } private void sealMedia(String sessionId) { - tx.executeWithoutResult(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).sealWriter(tenant, + tx.executeWithoutResult(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).sealWriter(tenant, sessionId, MEDIA_TOKEN, new ManagedSessionStoreModels.SealWriterRequest( "workspace-1", "media-writer", 1))); } @@ -1562,7 +1562,7 @@ public class SessionResourceCollectionCollectorTest extends ToolPublicationReten void mcpEvidenceSurvivesReopenAndConnectionReleaseUntilRetirement(String operationKind) throws Exception { initSession(); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var journal = tx.execute(status -> new ExtensionRecordJournal(sessions, tenant, "workspace-1", session).open()); JsonNode templates = new ObjectMapper().readTree(Files.readString(VerificationFixtures.moduleRoot().resolve("../core/src/managed-runtime/contracts/managed-mcp-record-v1.fixtures.json"))) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java index c81d3c4ae81..f86d2398d28 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/SessionRowLockOrderTest.java @@ -26,6 +26,7 @@ import com.sun.source.tree.YieldTree; import com.sun.source.util.JavacTask; import com.sun.source.util.TreeScanner; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.springframework.transaction.annotation.Transactional; import javax.lang.model.element.Modifier; @@ -71,10 +72,11 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // and a helper moved into a nested type stays visible. // - A call resolves to exactly one member: through the caller's enclosing // type chain for a bare or this. call, through the named type for a -// Type. call, or through a same-file final class declared by a parameter -// or block local. A direct `var = new Type(...)` also names that final -// class. Block scopes restore their receiver bindings; fields, other -// inference, local/anonymous classes and generic methods stay unknown. +// Type. call, or through a parsed final class declared by a parameter, +// block local or final field. A direct `var = new Type(...)` also names +// that final class. Block scopes restore their receiver bindings; +// mutable fields, other inference, local/anonymous classes and generic +// methods stay unknown. // A reference the audit cannot resolve to one member — an // overload set tied on name and arity, a call on a receiver whose type // the parse cannot see, a method reference whose arity is not spelled — @@ -96,9 +98,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // the lock — and the implicit row lock of an INSERT or UPDATE on the // session table counts, which is what admits the create path: it writes // the session row before the turn row. -// - Calls that leave the file are outside the audit; the sibling -// boundary below pins which files lock the session row or touch the -// turn row at all. A lambda handed straight into a call folds there — +// - Store facades include their Agent/Journal companion source files. +// Calls outside that source family remain outside the audit; the +// sibling boundary pins every file that locks Session or touches Turn. +// A lambda handed straight into a call folds there — // its body runs within the call — while a lambda in any other position, // or an anonymous-class method body, runs where it is invoked, which // the parse cannot see: such a body is held aside and fails closed when @@ -108,7 +111,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; // statement's spelling. // Transactionality is runtime wiring — a Spring proxy, or a // TransactionTemplate in another file's caller — not a spelling a -// same-file parse can see, so the audit pins the order, not the +// source parse can see, so the audit pins the order, not the // annotation. class SessionRowLockOrderTest { private static final Path STORE_PACKAGE = Path.of("..", "persistence", "src", "main", "java") @@ -134,6 +137,10 @@ class SessionRowLockOrderTest { // new sibling turn-row access — fails here instead of passing // unaudited. private static final Set SIBLING_SESSION_LOCKERS = Set.of( + "AgentEventJournal.java", "AgentExecutionOperations.java", "AgentLifecycleOperations.java", + "AgentSessionAdmissions.java", "AgentSessionCommands.java", "AgentSessionPolicies.java", + "AgentStoreRows.java", "AgentTurnExecution.java", + "JournalLifecycleAuthorization.java", "JournalWriterLifecycle.java", "HarnessPlacementStore.java", "ManagedActionStore.java", "ManagedExtensionAgentLaunch.java", "ManagedExtensionRecordStore.java", "ManagedLegacySessionGuard.java", @@ -152,6 +159,8 @@ class SessionRowLockOrderTest { // writes, so it is a turn reader but not a session locker and stays out of // the session-first order audit below. private static final Set SIBLING_TURN_READERS = Set.of( + "AgentExecutionOperations.java", "AgentLifecycleOperations.java", "AgentMessageProjection.java", "AgentSessionCommands.java", + "AgentSessionPolicies.java", "AgentSessionQueries.java", "AgentStoreRows.java", "AgentTurnExecution.java", "JdbcSessionRecoveryRepository.java", "AutomationLedgerStore.java", "ChildResultRelayStore.java", "HostedCleanupDebtStore.java", @@ -221,10 +230,10 @@ class SessionRowLockOrderTest { Set.of("ManagedSchemaJdbcTemplate.getSingleColumnRowMapper/1")), // Both publisher overloads avoid Session and Turn rows. Map.entry("ManagedSessionStore.java", - Set.of("ManagedSessionStore.publishAgentResource/6", - "ManagedSessionStore.sha256/1", - "ManagedSessionStore.storedTransaction/1", - "ManagedSessionStore.usesDataSource/1")), + Set.of("JournalResourceWrites.publishAgentResource/6", + "JournalStoreRows.sha256/1", + "JournalStoreRows.storedTransaction/1", + "JournalStoreRows.usesDataSource/1|ManagedSessionStore.usesDataSource/1")), Map.entry("ManagedTaskEventStore.java", Set.of("ManagedTaskEventStore.eventRow/2")), Map.entry("ManagedToolResultProjector.java", @@ -367,7 +376,7 @@ class SessionRowLockOrderTest { } // One ordered step in a member body: a SQL spelling (callee == null, - // lock/turn flag what the text bears) or a call to a same-file member + // lock/turn flag what the text bears) or a call to a parsed member // (callee set, flags unused). private record Event(boolean lock, boolean turn, Key callee) { } @@ -379,13 +388,14 @@ class SessionRowLockOrderTest { List violations, Set lockersWithoutTurnAccess, Set turnRowAccessors, Set exemptions, Set deferredBodies, boolean locksSessionRow, - boolean touchesTurnRow) { } + boolean touchesTurnRow, Set lockSources, Set turnSources, + Set turnMembers) { } @Test void mutationEntriesAcquireTheSessionRowLockBeforeAnyTurnRowAccess() throws IOException { - Audit audit = audit(Files.readString(STORE_PACKAGE.resolve( - MANAGED_AGENT_STORE))); + List sources = storeSources(STORE_PACKAGE, MANAGED_AGENT_STORE); + Audit audit = audit(readSources(sources), "ManagedAgentStore"); assertThat(audit.violations()).isEmpty(); // The derivation is only evidence if it accounts for every writer // that takes the session-row lock, so the set is asserted exactly: @@ -453,51 +463,82 @@ class SessionRowLockOrderTest { assertThat(audit.turnRowAccessors()) .containsExactlyInAnyOrder("advanceReplayFloor", "beginChildClose", "beginContextCompression", - "beginLifecycle", "beginOperation", + "beginOperation", "beginWorkspaceClose", "beginWorkspaceLifecycle", "bindHarness", "bindRecoveredHarness", "cancelBeforeAdmission", "changeSessionModel", "changeSessionApproval", "claimTurn", - "completeCwdChangeOperation", "completeHarnessTurn", - "deferTurnRetry", "failTurn", "failRecoveryBlockedTurn", "failWorktreeInitialTurns", - "settleFailedTurn", "findActiveTurns", + "completeCwdChangeOperation", "deferTurnRetry", "failTurn", "failRecoveryBlockedTurn", "failWorktreeInitialTurns", + "findActiveTurns", "findDispatchable", "findLatestTurns", "findReplayFloorTargets", "findTurn", "findTurnModelSelection", "findTurnApprovalSelection", - "findTurnSummary", "hasActiveTurn", - // #14387's lifecycle gate counts live Turns under - // its caller's session-row lock. Its recovery-blocked - // verdict can take the session-row lock through the - // cross-file legacy guard before the Turn update, a - // lock this same-file parse cannot see; both pin here - // as Turn-row accessors, not lockers. - "hasLiveTurn", + "findTurnSummary", // D7-2's Tool-recovery park: the park write and its // due scan reach the Turn row, and the cross-file // legacy guard may take the session lock before it. // Both pin here as Turn-row accessors, not lockers. "findRuntimeRecoveryParked", "parkTurnForRuntimeRecovery", - "insertCancelCommand", "insertChildSession", - "insertChildSessionCommand", "insertPublicChildSessionCommand", "insertCsiSessionCommand", + "insertCancelCommand", "insertChildSessionCommand", "insertPublicChildSessionCommand", "insertCsiSessionCommand", "insertInternalTeamLeadSessionCommand", - "insertSession", "insertSessionCommand", "insertTurn", - "insertTurnCommand", "insertWorkspaceSessionCommand", + "insertSessionCommand", "insertTurnCommand", "insertWorkspaceSessionCommand", "listTurns", "markSubmissionAttempted", "markTurnRecoveryBlocked", "markTurnFailureRecovery", "requestRecoveryRecheck", - "materializeEvent", "materializeInput", "materializeNextBatch", "recordAdmission", "recordHarnessEvents", "recordRecoveryAdmission", - "releaseTurnLease", "renewTurn", "requireTurn", - "requireTurnForUpdate", "requirePolicyChange", "requireMcpManagement", "requireMcpSettingsCommit", - "requireConfigurationCommit", "requireExtensionManagement", "requireChildExtensionBootstrap", - "requireMcpManagementIdle", "retractContinuationOutput", + "releaseTurnLease", "renewTurn", "requireMcpManagement", "requireMcpSettingsCommit", + "requireConfigurationCommit", "requireExtensionManagement", "retractContinuationOutput", "retractHarnessTurnOutput", "scheduleTurnRetry", - "settleTurnCommands", // D7-2's journal evidence for a parked input reads // its Turn and takes no lock. "turnInputAcceptance", - "updateHarnessCursor", "validateOperationStart", "withdrawSubmissionAttempted", "cancelUnadmittedTurn"); + Set movedHelperNames = Set.of( + "beginLifecycle", + "completeHarnessTurn", + "settleFailedTurn", + "hasActiveTurn", + "hasLiveTurn", + "insertChildSession", + "insertSession", + "insertTurn", + "materializeEvent", + "materializeInput", + "requireTurn", + "requireTurnForUpdate", + "requirePolicyChange", + "requireChildExtensionBootstrap", + "requireMcpManagementIdle", + "settleTurnCommands", + "updateHarnessCursor", + "validateOperationStart"); + assertThat(audit.turnMembers().stream().filter(handle -> movedHelperNames.contains( + handle.substring(handle.indexOf('.') + 1, handle.indexOf('/'))))) + .containsExactlyInAnyOrder( + "AgentLifecycleOperations.beginLifecycle/8", + "AgentLifecycleOperations.beginLifecycle/9", + "AgentLifecycleOperations.beginLifecycle/10", + "AgentLifecycleOperations.beginLifecycle/11", + "AgentTurnExecution.completeHarnessTurn/8", + "AgentTurnExecution.settleFailedTurn/7", + "AgentSessionPolicies.hasActiveTurn/2", + "AgentLifecycleOperations.hasLiveTurn/2", + "AgentSessionAdmissions.insertChildSession/10", + "AgentSessionAdmissions.insertSession/11", + "AgentSessionAdmissions.insertSession/12", + "AgentSessionAdmissions.insertSession/13", + "AgentSessionAdmissions.insertSession/14", + "AgentSessionCommands.insertTurn/7", + "AgentMessageProjection.materializeEvent/2", + "AgentMessageProjection.materializeInput/2", + "AgentStoreRows.requireTurn/3", + "AgentStoreRows.requireTurnForUpdate/3", + "AgentSessionPolicies.requirePolicyChange/2", + "AgentSessionPolicies.requireChildExtensionBootstrap/3", + "AgentSessionPolicies.requireMcpManagementIdle/1", + "AgentSessionCommands.settleTurnCommands/4", + "AgentTurnExecution.updateHarnessCursor/7", + "AgentLifecycleOperations.validateOperationStart/2"); // The references the parse cannot resolve to one member — // ItemRow.toRecord behind the lambda parameter at the item read, // and main's this::renameDelivery RowMapper references (#13682), @@ -507,18 +548,17 @@ class SessionRowLockOrderTest { // The nine-argument default delegate is covered through a real Spring proxy in // AgentStateStoreProxyTransactionTest; its deepest writer is audited here. assertThat(audit.exemptions()) - .containsExactly("ItemRow.toRecord/0", - "ManagedAgentStore.insertSessionCommand/9", - "ManagedAgentStore.readMap/1", - "ManagedAgentStore.renameDelivery/2"); + .containsExactly("AgentSessionCommands.renameDelivery/2", + "AgentStoreRows.readMap/1", + "ItemRow.toRecord/0", + "ManagedAgentStore.insertSessionCommand/9"); // The harvest is asserted against the bytecode's own enumeration, // recursively over nested types and as a multiset: a member the // parse dropped — including one of two overloads sharing a // name/arity key — would let a writer pass unaudited. assertThat(audit.members()) .as("the parse must see every compiled member") - .isEqualTo(compiledMembers(ManagedAgentStore.class, - audit.members())); + .isEqualTo(compiledMembers(sources, audit.members())); } // The pinned order is load-bearing only inside one transaction: the @@ -559,18 +599,43 @@ class SessionRowLockOrderTest { .endsWith(".java")).sorted().toList(); } Map audits = new LinkedHashMap<>(); + Map families = new HashMap<>(); for (Path source : sources) { String name = source.getFileName().toString(); if (MANAGED_AGENT_STORE.equals(name)) { continue; } - Audit audit = audit(Files.readString(source)); - audits.put(name, audit); - if (audit.locksSessionRow()) { - sessionLockers.add(name); + String facade = name.startsWith("Agent") ? MANAGED_AGENT_STORE + : name.startsWith("Journal") || name.equals("ManagedSessionStore.java") + ? "ManagedSessionStore.java" : null; + Audit audit; + if (facade == null) { + audit = audit(readSources(List.of(source))); + } else { + audit = families.get(facade); + if (audit == null) { + List selected = storeSources(STORE_PACKAGE, facade); + audit = audit(readSources(selected)); + assertThat(audit.members()).as("%s and companion members", facade) + .isEqualTo(compiledMembers(selected, audit.members())); + families.put(facade, audit); + } } - if (audit.touchesTurnRow()) { - turnReaders.add(name); + audits.put(name, audit); + if (facade == null || name.equals(facade)) { + if (audit.locksSessionRow()) { + sessionLockers.add(name); + } + if (audit.touchesTurnRow()) { + turnReaders.add(name); + } + } else { + if (audit.lockSources().contains(name)) { + sessionLockers.add(name); + } + if (audit.turnSources().contains(name)) { + turnReaders.add(name); + } } } assertThat(sessionLockers).isEqualTo(SIBLING_SESSION_LOCKERS); @@ -579,7 +644,8 @@ class SessionRowLockOrderTest { // is whatever the locker/reader intersection computed. Set orderAudited = new TreeSet<>(sessionLockers); orderAudited.retainAll(turnReaders); - assertThat(orderAudited).containsExactly("ManagedActionStore.java", + assertThat(orderAudited).containsExactly("AgentExecutionOperations.java", "AgentLifecycleOperations.java", "AgentSessionCommands.java", + "AgentSessionPolicies.java", "AgentStoreRows.java", "AgentTurnExecution.java", "ManagedActionStore.java", "ManagedUserMessageStore.java", "WorkspaceRecoveryStore.java"); // ManagedActionStore.admit takes the tenant lock before the @@ -595,7 +661,8 @@ class SessionRowLockOrderTest { } Map> exemptions = new TreeMap<>(); for (Map.Entry entry : audits.entrySet()) { - if (!entry.getValue().exemptions().isEmpty()) { + if (!entry.getKey().startsWith("Agent") && !entry.getKey().startsWith("Journal") + && !entry.getValue().exemptions().isEmpty()) { exemptions.put(entry.getKey(), entry.getValue().exemptions()); } } @@ -1894,33 +1961,411 @@ class SessionRowLockOrderTest { "badWriter"); } - // Runs the audit over one parsed compilation unit and answers the - // violation list alongside the pinned populations. - private static Audit audit(String source) { - CompilationUnitTree unit = parse(source); - Map methods = new LinkedHashMap<>(); - Map constants = new HashMap<>(); - Set finalTypes = new HashSet<>(); - for (Tree declaration : unit.getTypeDecls()) { - if (declaration instanceof ClassTree type) { - harvest(type, "", methods, constants, finalTypes); + @Test + void theAuditFollowsFinalFieldsAcrossSourceFiles() { + for (String receiver : List.of("rows", "this.rows")) { + List sources = splitStore(receiver + ".lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"); + Audit result = audit(sources); + assertThat(result.violations()).isEmpty(); + assertThat(result.lockers()).containsExactlyInAnyOrder("writer", "write", "lockSession"); + assertThat(result.turnRowAccessors()).containsExactlyInAnyOrder("writer", "write"); + assertThat(result.members()).containsExactly("AgentOperations#write/0", + "AgentRows#lockSession/0", "SyntheticStore#writer/0"); + } + } + + @Test + void theAuditResolvesFieldTypesOutsideMethodLocalTypeScopes() { + for (String receiver : List.of("rows", "this.rows")) { + for (String localType : List.of("record QueuedInput(String value) { }", + "record AgentRows(String value) { }")) { + List sources = splitStore(localType + receiver + ".lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"); + Audit result = audit(sources); + assertThat(result.violations()).isEmpty(); + assertThat(result.lockers()).containsExactlyInAnyOrder("writer", "write", "lockSession"); + List generic = new ArrayList<>(sources); + generic.set(1, generic.get(1).replace(localType, "") + .replace("void write()", " void write()")); + assertThat(audit(generic).lockers()).isEqualTo(result.lockers()); + assertThat(audit(generic).violations()).isEmpty(); } } - // A call through a type the file imports leaves the file: no - // same-file member can share an imported type's simple name. - Set importedTypes = unit.getImports().stream() - .filter(declaration -> !declaration.isStatic()) - .map(declaration -> declaration.getQualifiedIdentifier() - .toString()) - .filter(name -> !name.endsWith(".*")) - .map(name -> name.substring(name.lastIndexOf('.') + 1)) - .collect(Collectors.toSet()); + } + + @Test + void theAuditKeepsFieldLockChecksWithLocalRecords() { + String localType = "record QueuedInput(String value) { }"; + String lock = "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"; + String turn = "jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");"; + for (String receiver : List.of("rows", "this.rows")) { + String call = receiver + ".lockSession();"; + assertThat(audit(splitStore(localType + turn + call, lock)).violations()) + .containsExactlyInAnyOrder("writer", "write"); + Audit removed = audit(splitStore(localType + call + turn, "")); + assertThat(removed.turnRowAccessors()).contains("writer"); + assertThat(removed.lockers()).doesNotContain("writer"); + List hidden = new ArrayList<>(splitStore(localType + call + turn, lock)); + hidden.set(1, hidden.get(1).replace("final AgentRows rows", "final Port rows")); + assertThatThrownBy(() -> audit(hidden)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lockSession").hasMessageContaining("write"); + } + } + + @Test + void theAuditDoesNotUseOuterFieldTypesInsideClassBodies() { + for (String receiver : List.of("rows", "this.rows")) { + for (String body : List.of( + "class Local { final DangerRows rows; void run() { " + receiver + ".touch(); } }", + "Runnable action = new Runnable() { final DangerRows rows;" + + " public void run() { " + receiver + ".touch(); } };", + "class Local { void run(DangerRows rows) { rows.touch(); } }", + "Runnable action = new Runnable() { final DangerRows rows;" + + " Object value = " + receiver + ".touch(); public void run() { } };")) { + List sources = List.of(""" + class SyntheticStore { + final SafeRows rows; + void writer() { %s } + } + """.formatted(body), """ + final class SafeRows { Object touch() { return null; } } + """, """ + final class DangerRows { + Object touch() { + jdbc.update("UPDATE ma_turn SET status = 'FAILED'"); + return null; + } + } + """); + assertThatThrownBy(() -> audit(sources)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("touch").hasMessageContaining("writer"); + } + } + } + + @Test + void theAuditRejectsAMovedLockAcrossSourceFiles() { + Audit result = audit(splitStore("jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");" + + " rows.lockSession();", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");")); + assertThat(result.violations()).containsExactlyInAnyOrder("writer", "write"); + } + + @Test + void thePinnedPopulationRejectsADeletedCrossFileLock() { + Audit result = audit(splitStore("rows.lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", "")); + assertThat(result.turnRowAccessors()).contains("writer"); + assertThatThrownBy(() -> assertThat(result.lockers()).contains("writer")) + .isInstanceOf(AssertionError.class).hasMessageContaining("writer"); + } + + @Test + void theFacadePopulationRejectsASameNamedDelegatesLostLock() { + List sources = List.of(""" + class SyntheticStore { + final AgentCommands commands; + void writer() { commands.writer(); } + } + """, """ + final class AgentCommands { + void writer() { + jdbc.queryForMap("SELECT session_id FROM ma_session FOR UPDATE"); + touchTurn(); + } + void touchTurn() { jdbc.update("UPDATE ma_turn SET status = 'FAILED'"); } + } + """); + Audit original = audit(sources, "SyntheticStore"); + assertThat(original.lockers()).containsExactly("writer"); + assertThat(original.turnRowAccessors()).containsExactly("writer"); + List changed = new ArrayList<>(sources); + changed.set(0, changed.getFirst().replace("commands.writer()", "commands.touchTurn()")); + assertThat(audit(changed).lockers()).isEqualTo(audit(sources).lockers()); + Audit broken = audit(changed, "SyntheticStore"); + assertThat(broken.members()).isEqualTo(original.members()); + assertThat(broken.turnRowAccessors()).containsExactly("writer"); + assertThatThrownBy(() -> assertThat(broken.lockers()).contains("writer")) + .isInstanceOf(AssertionError.class).hasMessageContaining("writer"); + } + + @Test + void theAuditRejectsUnresolvedCrossFileFieldReceivers() { + List sources = splitStore("rows.lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"); + for (String declaration : List.of("final Port rows", "AgentRows rows", "final Object rows")) { + List changed = new ArrayList<>(sources); + changed.set(1, changed.get(1).replace("final AgentRows rows", declaration)); + assertThatThrownBy(() -> audit(changed)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lockSession").hasMessageContaining("write"); + } + List polymorphic = new ArrayList<>(sources); + polymorphic.set(2, polymorphic.get(2).replace("final class AgentRows", "class AgentRows")); + assertThatThrownBy(() -> audit(polymorphic)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lockSession"); + } + + @Test + void theAuditDistinguishesFieldsFromShadowingReceiversAcrossFiles() { + List sources = splitStore("rows.lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"); + List explicit = new ArrayList<>(sources); + explicit.set(0, explicit.get(0).replace("writer()", "writer(Port operations)") + .replace("operations.write()", "this.operations.write()")); + assertThat(audit(explicit).violations()).isEmpty(); + assertThat(audit(explicit).lockers()).contains("writer"); + List shadowed = new ArrayList<>(explicit); + shadowed.set(0, shadowed.get(0).replace("this.operations.write()", "operations.write()")); + assertThatThrownBy(() -> audit(shadowed)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("write").hasMessageContaining("writer"); + } + + @Test + void theAuditKeepsCrossFileOverloadsAmbiguous() { + List sources = splitStore("rows.lockSession(value);" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", ""); + sources = new ArrayList<>(sources); + sources.set(2, """ + final class AgentRows { + void lockSession(String value) { + jdbc.queryForMap("SELECT session_id FROM ma_session FOR UPDATE"); + } + void lockSession(Integer value) { } + } + """); + List ambiguous = sources; + assertThatThrownBy(() -> audit(ambiguous)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("AgentRows.lockSession/1"); + } + + @Test + void theAuditKeepsConstantsScopedToTheirSourceFile() { + List sources = List.of(""" + class SyntheticStore { + final AgentRows rows; + static final String SQL = "UPDATE ma_turn SET status = 'FAILED'"; + void writer() { jdbc.update(SQL); rows.lockSession(); } + } + """, """ + final class AgentRows { + static final String SQL = "SELECT session_id FROM ma_session FOR UPDATE"; + void lockSession() { jdbc.queryForMap(SQL); } + } + """); + assertThat(audit(sources).violations()).containsExactly("writer"); + List qualified = new ArrayList<>(sources); + qualified.set(0, """ + class SyntheticStore { + final AgentRows rows; + void writer() { + jdbc.update(AgentSql.SQL); + rows.lockSession(); + } + } + """); + qualified.add(""" + final class AgentSql { + static final String SQL = "UPDATE ma_turn SET status = 'FAILED'"; + } + """); + assertThat(audit(qualified).violations()).containsExactly("writer"); + } + + @Test + void theAuditKeepsImportedReceiverTypesScopedToTheirSourceFile() { + List sources = splitStore("rows.lockSession();" + + " jdbc.update(\"UPDATE ma_turn SET status = 'FAILED'\");", + "jdbc.queryForMap(\"SELECT session_id FROM ma_session FOR UPDATE\");"); + List ownPackage = sources.stream().map(source -> "package sample;\n" + source).toList(); + ownPackage = new ArrayList<>(ownPackage); + ownPackage.set(1, ownPackage.get(1).replace("package sample;", + "package sample; import sample.AgentRows;")); + assertThat(audit(ownPackage).lockers()).contains("writer"); + List external = new ArrayList<>(ownPackage); + external.set(1, external.get(1).replace("import sample.AgentRows;", "import external.AgentRows;")); + assertThatThrownBy(() -> audit(external)).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lockSession"); + List unrelated = new ArrayList<>(sources); + unrelated.add("import external.AgentRows; final class Unrelated { void pure() {} }"); + assertThat(audit(unrelated).lockers()).contains("writer"); + } + + @Test + void theAuditDoesNotCreditAnExternalTypeWithParsedMembers() { + for (String lock : List.of("AgentRows.lockSession()", "jdbc.queryForMap(AgentRows.SQL)", + "jdbc.queryForMap(AgentRows.SQL + \" \" )")) { + List sources = List.of(""" + import external.AgentRows; + class SyntheticStore { + static final String SQL = "SELECT session_id FROM ma_session FOR UPDATE"; + void writer() { + %s; + jdbc.update("UPDATE ma_turn SET status = 'FAILED'"); + } + } + """.formatted(lock), """ + final class AgentRows { + static final String SQL = "SELECT session_id FROM ma_session FOR UPDATE"; + static void lockSession() { jdbc.queryForMap(SQL); } + } + """); + Audit result = audit(sources); + assertThat(result.turnRowAccessors()).contains("writer"); + assertThat(result.lockers()).doesNotContain("writer"); + } + } + + @Test + void theAuditResolvesFieldsInTheirDeclaringType() { + Audit result = audit(List.of(""" + class SyntheticStore { + final AgentRows rows; + class Nested { + final class AgentRows { + void lockSession() { + jdbc.queryForMap("SELECT session_id FROM ma_session FOR UPDATE"); + } + } + void writer() { + rows.lockSession(); + jdbc.update("UPDATE ma_turn SET status = 'FAILED'"); + } + } + } + """, """ + final class AgentRows { + void lockSession() { } + } + """)); + assertThat(result.turnRowAccessors()).contains("writer"); + assertThat(result.lockers()).doesNotContain("writer"); + } + + @Test + void theSourceFamiliesIncludeNewCollaboratorsWithoutAClassAllowlist(@TempDir Path directory) + throws IOException { + for (String name : List.of(MANAGED_AGENT_STORE, "ManagedSessionStore.java", + "AgentRows.java", "AgentFutureWorkflow.java", "JournalRows.java", "OtherStore.java")) { + Files.writeString(directory.resolve(name), "class " + name.replace(".java", "") + " {}"); + } + assertThat(storeSources(directory, MANAGED_AGENT_STORE)) + .containsExactly(directory.resolve("AgentFutureWorkflow.java"), directory.resolve("AgentRows.java"), + directory.resolve(MANAGED_AGENT_STORE)); + assertThat(storeSources(directory, "ManagedSessionStore.java")) + .containsExactly(directory.resolve("JournalRows.java"), directory.resolve("ManagedSessionStore.java")); + } + + private static List splitStore(String writeBody, String lockBody) { + return List.of(""" + class SyntheticStore { + final AgentOperations operations; + void writer() { operations.write(); } + } + """, """ + final class AgentOperations { + final AgentRows rows; + void write() { %s } + } + """.formatted(writeBody), """ + final class AgentRows { + void lockSession() { %s } + } + """.formatted(lockBody)); + } + + private static List storeSources(Path directory, String facade) + throws IOException { + String prefix = MANAGED_AGENT_STORE.equals(facade) ? "Agent" : "Journal"; + try (var files = Files.list(directory)) { + List selected = files.filter(path -> { + String name = path.getFileName().toString(); + return name.equals(facade) || name.startsWith(prefix) && name.endsWith(".java"); + }).sorted().toList(); + assertThat(selected).contains(directory.resolve(facade)); + return selected; + } + } + + private static List readSources(List paths) throws IOException { + List sources = new ArrayList<>(); + for (Path path : paths) { + sources.add(Files.readString(path)); + } + return sources; + } + + private static Audit audit(String source) { + return audit(List.of(source)); + } + + private static Audit audit(List sources) { + return audit(sources, null); + } + + // Each source keeps its imports and unqualified constants; member + // identities and final receiver types span the complete source family. + // Facade populations retain their owner so a same-named collaborator + // cannot conceal an entry losing its lock or Turn access. + private static Audit audit(List sources, String populationOwner) { + List units = sources.stream().map(SessionRowLockOrderTest::parse).toList(); + Map methods = new LinkedHashMap<>(); + Map> memberConstants = new HashMap<>(); + Map> memberImports = new HashMap<>(); + Map> fields = new HashMap<>(); + Map qualifiedConstants = new HashMap<>(); + Set finalTypes = new HashSet<>(); + Set sourceTypes = new HashSet<>(); + for (CompilationUnitTree unit : units) { + String prefix = unit.getPackageName() == null ? "" : unit.getPackageName() + "."; + for (Tree declaration : unit.getTypeDecls()) { + if (declaration instanceof ClassTree type) { + sourceTypes.add(prefix + type.getSimpleName()); + } + } + } + for (CompilationUnitTree unit : units) { + Map localMethods = new LinkedHashMap<>(); + Map constants = new HashMap<>(); + for (Tree declaration : unit.getTypeDecls()) { + if (declaration instanceof ClassTree type) { + harvest(type, "", localMethods, constants, finalTypes, fields); + } + } + Set importedTypes = unit.getImports().stream() + .filter(declaration -> !declaration.isStatic()) + .map(declaration -> declaration.getQualifiedIdentifier().toString()) + .filter(name -> !name.endsWith(".*") && !sourceTypes.contains(name)) + .map(name -> name.substring(name.lastIndexOf('.') + 1)) + .collect(Collectors.toSet()); + for (Map.Entry member : localMethods.entrySet()) { + if (methods.putIfAbsent(member.getKey(), member.getValue()) != null) { + throw new IllegalStateException("duplicate parsed member " + member.getKey()); + } + memberConstants.put(member.getKey(), constants); + memberImports.put(member.getKey(), importedTypes); + } + constants.forEach((name, value) -> { + if (name.contains(".")) { + qualifiedConstants.put(name, value); + } + }); + } List ambiguous = new ArrayList<>(); Map> events = new LinkedHashMap<>(); Map>> heldLambdas = new LinkedHashMap<>(); for (Map.Entry member : methods.entrySet()) { + Map constants = new HashMap<>(qualifiedConstants); + constants.putAll(memberConstants.get(member.getKey())); + Set importedTypes = memberImports.get(member.getKey()); + constants.keySet().removeIf(name -> name.contains(".") + && importedTypes.contains(name.substring(0, name.indexOf('.')))); MemberScan scan = eventsOf(member.getValue(), member.getKey(), - methods, constants, importedTypes, finalTypes, ambiguous); + methods, constants, importedTypes, finalTypes, fields, ambiguous); events.put(member.getKey(), scan.events()); heldLambdas.put(member.getKey(), scan.heldLambdas()); } @@ -1959,9 +2404,9 @@ class SessionRowLockOrderTest { // An unresolvable reference is exempt only while every candidate // provably reaches neither side; the moment one does, the audit // fails closed instead of guessing an attribution. The one - // exception is a file no member of which reaches the turn row: + // exception is a source family with no member reaching the turn row: // there an attribution can move only the session lock, and a lock - // with no turn access anywhere in the file orders nothing. + // with no turn access anywhere in the family orders nothing. Set exemptions = new TreeSet<>(); for (Ambiguous reference : ambiguous) { boolean sideReaching = reference.candidates().stream() @@ -1983,6 +2428,7 @@ class SessionRowLockOrderTest { // never enters `lockers`, so only this set fails loudly when one // appears. Set turnRowAccessors = turnReaching.stream() + .filter(key -> populationOwner == null || key.owner().equals(populationOwner)) .map(Key::name) .collect(Collectors.toCollection(TreeSet::new)); Set lockers = new TreeSet<>(); @@ -1997,10 +2443,15 @@ class SessionRowLockOrderTest { || !lockReaching.contains(key)) { continue; } - // Overloads audit as separate members but name one locker. - lockers.add(key.name()); + // Overloads audit as separate members but name one locker; + // order is still checked across every owner in the family. + if (populationOwner == null || key.owner().equals(populationOwner)) { + lockers.add(key.name()); + if (!turnReaching.contains(key)) { + lockersWithoutTurnAccess.add(key.name()); + } + } if (!turnReaching.contains(key)) { - lockersWithoutTurnAccess.add(key.name()); continue; } if (lockAfterTurn(key, events, lockReaching, turnReaching, @@ -2017,7 +2468,14 @@ class SessionRowLockOrderTest { deferredBodies, events.values().stream().flatMap(List::stream) .anyMatch(Event::lock), events.values().stream().flatMap(List::stream) - .anyMatch(Event::turn)); + .anyMatch(Event::turn), + events.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(Event::lock)) + .map(entry -> entry.getKey().owner().split("\\.")[0] + ".java") + .collect(Collectors.toCollection(TreeSet::new)), + events.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(Event::turn)) + .map(entry -> entry.getKey().owner().split("\\.")[0] + ".java") + .collect(Collectors.toCollection(TreeSet::new)), + turnReaching.stream().map(Key::handle).collect(Collectors.toCollection(TreeSet::new))); } // The members of one type, recursively through its nested types, keyed @@ -2028,7 +2486,7 @@ class SessionRowLockOrderTest { // constant scores exactly like the inline spelling. private static void harvest(ClassTree type, String ownerPath, Map methods, Map constants, - Set finalTypes) { + Set finalTypes, Map> fields) { String path = ownerPath.isEmpty() ? type.getSimpleName().toString() : ownerPath + "." + type.getSimpleName(); if (type.getModifiers().getFlags().contains(Modifier.FINAL)) { @@ -2043,17 +2501,21 @@ class SessionRowLockOrderTest { } methods.put(new Key(path, method.getName().toString(), paramTypes), method); - } else if (member instanceof VariableTree field - && field.getModifiers().getFlags() + } else if (member instanceof VariableTree field) { + fields.computeIfAbsent(path, ignored -> new HashMap<>()) + .put(field.getName().toString(), field); + if (field.getModifiers().getFlags() .contains(Modifier.STATIC) - && field.getModifiers().getFlags() + && field.getModifiers().getFlags() .contains(Modifier.FINAL) - && field.getInitializer() != null - && "String".equals(String.valueOf(field.getType()))) { - constants.put(field.getName().toString(), - flatten(field.getInitializer(), constants)); + && field.getInitializer() != null + && "String".equals(String.valueOf(field.getType()))) { + String value = flatten(field.getInitializer(), constants); + constants.put(field.getName().toString(), value); + constants.put(path + "." + field.getName(), value); + } } else if (member instanceof ClassTree nested) { - harvest(nested, path, methods, constants, finalTypes); + harvest(nested, path, methods, constants, finalTypes, fields); } } } @@ -2152,6 +2614,7 @@ class SessionRowLockOrderTest { private static MemberScan eventsOf(MethodTree method, Key caller, Map methods, Map constants, Set importedTypes, Set finalTypes, + Map> fields, List ambiguous) { boolean typedReceivers = method.getTypeParameters().isEmpty(); boolean[] localClass = {false}; @@ -2163,14 +2626,27 @@ class SessionRowLockOrderTest { } }.scan(method.getBody(), null); typedReceivers &= !localClass[0]; - Map receivers = new HashMap<>(); + Set visibleFinalTypes = finalTypes.stream() + .filter(type -> !importedTypes.contains(type.split("\\.")[0])) + .collect(Collectors.toSet()); + Map fieldReceivers = new HashMap<>(); + for (String owner = caller.owner(); owner != null; + owner = owner.lastIndexOf('.') < 0 ? null : owner.substring(0, owner.lastIndexOf('.'))) { + for (VariableTree field : fields.getOrDefault(owner, Map.of()).values()) { + // Method-local types cannot change the declared type of a field. + fieldReceivers.putIfAbsent(field.getName().toString(), + field.getModifiers().getFlags().contains(Modifier.FINAL) + ? receiverType(field, owner, visibleFinalTypes) : ""); + } + } + Map receivers = new HashMap<>(fieldReceivers); for (VariableTree parameter : method.getParameters()) { receivers.put(parameter.getName().toString(), typedReceivers - ? receiverType(parameter, caller, finalTypes) : ""); + ? receiverType(parameter, caller.owner(), visibleFinalTypes) : ""); } EventScanner scanner = new EventScanner(new ArrayList<>(), caller, methods, new HashMap<>(constants), importedTypes, - finalTypes, receivers, typedReceivers, ambiguous); + visibleFinalTypes, receivers, fieldReceivers, typedReceivers, ambiguous); if (method.getBody() != null) { scanner.scan(method.getBody(), null); } @@ -2180,10 +2656,10 @@ class SessionRowLockOrderTest { private record MemberScan(List events, List> heldLambdas) { } - // A final same-file class cannot dispatch to a hidden subtype. Only - // explicit parameter/local types and direct `var = new Type(...)` - // inference qualify; fields, casts and other inference stay unknown. - private static String receiverType(VariableTree variable, Key caller, + // A parsed final class cannot dispatch to a hidden subtype. Only + // explicit types and direct `var = new Type(...)` inference qualify; + // casts and other inference stay unknown. + private static String receiverType(VariableTree variable, String declaringOwner, Set finalTypes) { String type = String.valueOf(variable.getType()); if (variable.getType() == null || type.equals("var")) { @@ -2193,7 +2669,7 @@ class SessionRowLockOrderTest { } type = creation.getIdentifier().toString(); } - for (String owner = caller.owner(); owner != null; + for (String owner = declaringOwner; owner != null; owner = owner.lastIndexOf('.') < 0 ? null : owner.substring(0, owner.lastIndexOf('.'))) { String candidate = owner + "." + type; @@ -2221,6 +2697,7 @@ class SessionRowLockOrderTest { private final Set importedTypes; private final Set finalTypes; private final Map receivers; + private final Map fieldReceivers; private final boolean typedReceivers; private final List ambiguous; // The lambda nodes passed straight into a call, marked at the call @@ -2231,7 +2708,7 @@ class SessionRowLockOrderTest { EventScanner(List events, Key caller, Map methods, Map constants, Set importedTypes, Set finalTypes, - Map receivers, boolean typedReceivers, + Map receivers, Map fieldReceivers, boolean typedReceivers, List ambiguous) { this.events = events; this.caller = caller; @@ -2240,6 +2717,7 @@ class SessionRowLockOrderTest { this.importedTypes = importedTypes; this.finalTypes = finalTypes; this.receivers = new HashMap<>(receivers); + this.fieldReceivers = fieldReceivers; this.typedReceivers = typedReceivers; this.ambiguous = ambiguous; } @@ -2253,7 +2731,7 @@ class SessionRowLockOrderTest { if (statement instanceof VariableTree variable) { receivers.put(variable.getName().toString(), typedReceivers ? receiverType(variable, - caller, finalTypes) : ""); + caller.owner(), finalTypes) : ""); } } return null; @@ -2386,7 +2864,7 @@ class SessionRowLockOrderTest { // any that reach a side. EventScanner held = new EventScanner(new ArrayList<>(), caller, methods, constants, importedTypes, finalTypes, - receivers, typedReceivers, ambiguous); + receivers, fieldReceivers, typedReceivers, ambiguous); held.scan(node.getBody(), null); heldLambdas.add(held.events); heldLambdas.addAll(held.heldLambdas); @@ -2407,20 +2885,20 @@ class SessionRowLockOrderTest { scan(node.getIdentifier(), null); scan(node.getTypeArguments(), null); scan(node.getArguments(), null); - EventScanner held = new EventScanner(new ArrayList<>(), caller, - methods, constants, importedTypes, finalTypes, - receivers, typedReceivers, ambiguous); + EventScanner held = classBodyScanner(node.getClassBody(), new ArrayList<>()); + EventScanner immediate = classBodyScanner(node.getClassBody(), events); for (Tree member : node.getClassBody().getMembers()) { if (member instanceof MethodTree method) { if (method.getBody() != null) { held.scan(method.getBody(), null); } } else { - scan(member, null); + immediate.scan(member, null); } } heldLambdas.add(held.events); heldLambdas.addAll(held.heldLambdas); + heldLambdas.addAll(immediate.heldLambdas); return null; } @@ -2431,9 +2909,7 @@ class SessionRowLockOrderTest { // declaration — so its bodies must be held aside exactly like // an anonymous-class body; folding them inline would score the // session lock the body takes where it never runs. - EventScanner held = new EventScanner(new ArrayList<>(), caller, - methods, constants, importedTypes, finalTypes, - receivers, typedReceivers, ambiguous); + EventScanner held = classBodyScanner(node, new ArrayList<>()); for (Tree member : node.getMembers()) { if (member instanceof MethodTree method && method.getBody() != null @@ -2446,6 +2922,22 @@ class SessionRowLockOrderTest { return null; } + private EventScanner classBodyScanner(ClassTree type, List targetEvents) { + // A local or anonymous class changes `this` and can shadow outer fields. + Map unknown = new HashMap<>(receivers); + fieldReceivers.keySet().forEach(name -> unknown.put(name, "")); + unknown.replaceAll((name, owner) -> ""); + for (Tree member : type.getMembers()) { + if (member instanceof VariableTree field) { + unknown.put(field.getName().toString(), ""); + } else if (member instanceof MethodTree method) { + method.getParameters().forEach(parameter -> unknown.put(parameter.getName().toString(), "")); + } + } + return new EventScanner(targetEvents, caller, methods, constants, importedTypes, finalTypes, + unknown, unknown, false, ambiguous); + } + @Override public Void visitLiteral(LiteralTree node, Void unused) { if (node.getValue() instanceof String text) { @@ -2463,6 +2955,16 @@ class SessionRowLockOrderTest { return null; } + @Override + public Void visitMemberSelect(MemberSelectTree node, Void unused) { + String text = constants.get(node.toString()); + if (text != null) { + addSpelling(text, events); + return null; + } + return super.visitMemberSelect(node, unused); + } + // The calls and references inside an expression whose spelling was // already scored: the SQL text is flattened once, but the member // calls it embeds keep their own audit edges. @@ -2486,7 +2988,7 @@ class SessionRowLockOrderTest { private void addCall(MethodInvocationTree node) { resolve(node.getMethodSelect(), node.getArguments().size(), - caller, methods, importedTypes, receivers, ambiguous).forEach(key -> + caller, methods, importedTypes, receivers, fieldReceivers, ambiguous).forEach(key -> events.add(new Event(false, false, key))); } @@ -2508,15 +3010,15 @@ class SessionRowLockOrderTest { } } - // The same-file member an invocation resolves to, or the reference + // The parsed member an invocation resolves to, or the reference // recorded as ambiguous. A bare or this. call searches the caller's // enclosing type chain innermost first; a Type. call searches the - // named type. An exact same-file final receiver uses its full owner + // named type. An exact parsed final receiver uses its full owner // path. Other receivers remain ambiguous and fail closed when any // same-named candidate reaches either audited side. private static List resolve(Tree select, int argCount, Key caller, Map methods, Set importedTypes, - Map receivers, + Map receivers, Map fieldReceivers, List ambiguous) { if (select instanceof IdentifierTree identifier) { return resolveInOwnerChain(identifier.getName().toString(), @@ -2524,6 +3026,21 @@ class SessionRowLockOrderTest { } if (select instanceof MemberSelectTree memberSelect) { String name = memberSelect.getIdentifier().toString(); + if (memberSelect.getExpression() instanceof MemberSelectTree field + && field.getExpression() instanceof IdentifierTree base + && base.getName().contentEquals("this")) { + String owner = fieldReceivers.getOrDefault(field.getIdentifier().toString(), ""); + List exact = methods.keySet().stream() + .filter(key -> key.owner().equals(owner) && key.name().equals(name) + && key.arity() == argCount).toList(); + if (exact.size() == 1) { + return exact; + } + if (!exact.isEmpty()) { + ambiguous.add(new Ambiguous(caller, handle(exact), exact)); + return List.of(); + } + } if (memberSelect.getExpression() instanceof IdentifierTree base) { if (base.getName().contentEquals("this")) { return resolveInOwnerChain(name, argCount, caller, @@ -2543,6 +3060,9 @@ class SessionRowLockOrderTest { return List.of(); } } else { + if (importedTypes.contains(receiver)) { + return List.of(); + } List inType = methods.keySet().stream() .filter(key -> key.ownerSimpleName() .contentEquals(base.getName().toString()) @@ -2557,9 +3077,6 @@ class SessionRowLockOrderTest { inType.getFirst().handle(), inType)); return List.of(); } - if (importedTypes.contains(base.getName().toString())) { - return List.of(); - } } } List any = methods.keySet().stream() @@ -2666,8 +3183,8 @@ class SessionRowLockOrderTest { ""); } if (node instanceof MemberSelectTree select) { - return constants.getOrDefault(select.getIdentifier().toString(), - ""); + return constants.getOrDefault(select.getExpression().toString().equals("this") + ? select.getIdentifier().toString() : select.toString(), ""); } // An expression the walk cannot resolve still carries its string // literals — a table name handed to a helper inside the SQL's own @@ -2694,10 +3211,19 @@ class SessionRowLockOrderTest { // component accessors and toString/equals/hashCode — are absent from // the parse tree unless the record declares them, so only the // undeclared ones are filtered. - private static List compiledMembers(Class type, + private static List compiledMembers(List sources, List parsed) { List compiled = new ArrayList<>(); - collectCompiled(type, parsed, compiled); + for (Path source : sources) { + String name = source.getFileName().toString().replace(".java", ""); + try { + Class type = Class.forName(ManagedAgentStore.class.getPackageName() + "." + name, + false, ManagedAgentStore.class.getClassLoader()); + collectCompiled(type, parsed, compiled); + } catch (ClassNotFoundException error) { + throw new IllegalStateException("uncompiled audit source " + source, error); + } + } compiled.sort(null); return compiled; } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAcknowledgementTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAcknowledgementTest.java index 22f089895f7..75ce98785e1 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAcknowledgementTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAcknowledgementTest.java @@ -96,7 +96,23 @@ class ToolPublicationAcknowledgementTest { ManagedSchema.initialize(source); jdbc = new ManagedSchemaJdbcTemplate(source); manager = new DataSourceTransactionManager(source); - sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var objects = new HashMap(); + ToolPublicationObjectStore bucket = new ToolPublicationObjectStore() { + public void putIfAbsent(String key, byte[] value) { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + objects.putIfAbsent(key, value.clone()); + } + public InputStream open(String key) { + assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); + objectReads++; + duringObjectRead.run(); + return new ByteArrayInputStream(objects.get(key)); + } + public void validateConfiguration() { } + }; + sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, + new ManagedExtensionRecordStore(jdbc), null, null, null, bucket, null, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), null); bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("fixture", new byte[32])); runtimeSessions = new JdbcRuntimeSessionRepository(source); executions = new JdbcToolExecutionRepository(source); @@ -119,21 +135,6 @@ class ToolPublicationAcknowledgementTest { runtimeSession = runtimeSessions.compareAndSet(acquiring, acquiring.withState(RuntimeSessionRecord.State.READY, Instant.now())); grants = new ToolPublicationStore(jdbc, manager, sessions, executions, bindings, new ToolPublicationStore.Capacity(2048, 16 * 1024 * 1024, 16 * 1024 * 1024, 10)); - var objects = new HashMap(); - ToolPublicationObjectStore bucket = new ToolPublicationObjectStore() { - public void putIfAbsent(String key, byte[] value) { - assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); - objects.putIfAbsent(key, value.clone()); - } - public InputStream open(String key) { - assertThat(TransactionSynchronizationManager.isActualTransactionActive()).isFalse(); - objectReads++; - duringObjectRead.run(); - return new ByteArrayInputStream(objects.get(key)); - } - public void validateConfiguration() { } - }; - sessions.setPublicationObjects(bucket); data = new ToolPublicationDataStore(jdbc, manager, grants, sessions, bucket, Duration.ofMinutes(2), Duration.ofSeconds(30), new ToolPublicationDataStore.VerificationBudget(16 * 1024 * 1024, Duration.ofMinutes(25))); @@ -371,7 +372,7 @@ class ToolPublicationAcknowledgementTest { void mismatchedSessionAuthorityAtEveryInjectionSiteRefusesBeforeRpcAndObjectReads(String component) { var other = new JdbcDataSource(); other.setURL("jdbc:h2:mem:foreign-session-" + UUID.randomUUID()); - var foreignSessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(new ManagedSchemaJdbcTemplate(other))); + var foreignSessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(new ManagedSchemaJdbcTemplate(other)); var selectedGrants = new ToolPublicationStore(jdbc, manager, "grants".equals(component) ? foreignSessions : sessions, executions, bindings, new ToolPublicationStore.Capacity(2048, 16 * 1024 * 1024, 16 * 1024 * 1024, 10)); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAsyncVerificationTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAsyncVerificationTest.java index e7521b186b2..be9f854c0ff 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAsyncVerificationTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationAsyncVerificationTest.java @@ -68,10 +68,10 @@ public class ToolPublicationAsyncVerificationTest { void setup() { DataSource source = dataSource(); com.alibaba.qwen.code.runtimebroker.managedschema.ManagedSchema.initialize(source); - fixture = PublicationJournalFixture.create(source); + bucket = new Bucket(); + fixture = PublicationJournalFixture.create(source, null, bucket, null); fixture.reserve(); key = fixture.binding.path("sessionKey"); - bucket = new Bucket(); data = replacement(); } @@ -314,7 +314,9 @@ public class ToolPublicationAsyncVerificationTest { .withBean("managedJdbcTemplate", org.springframework.jdbc.core.JdbcTemplate.class, () -> fixture.jdbc) .withBean("managedAgentTransactionManager", PlatformTransactionManager.class, () -> fixture.manager) .withBean(WriterCredentialPolicy.class, WriterCredentialPolicy::unbound) - .withBean(ManagedSessionStore.class, () -> fixture.sessions) + .withBean(ManagedSessionStore.class, () -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore( + fixture.jdbc, new ManagedExtensionRecordStore(fixture.jdbc), null, null, null, bucket, null, null, + WriterCredentialPolicy.unbound(), fixture.bindings)) .withBean(HarnessRules.class, SdkHarnessRules::new) .withBean(StorageReadPolicy.class, ManagedSessionReaderVersion::new) .withBean(com.alibaba.qwen.code.runtimebroker.ToolExecutionRepository.class, () -> fixture.executions) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionClockFixtureMain.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionClockFixtureMain.java index 28ed4b45f7d..173f34742cf 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionClockFixtureMain.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionClockFixtureMain.java @@ -26,7 +26,7 @@ public final class ToolPublicationRetentionClockFixtureMain { var manager = new DataSourceTransactionManager(source); var tx = new TransactionTemplate(manager); var retention = new ToolPublicationRetentionStore(jdbc, manager); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String tenant = "clock-" + UUID.randomUUID(); String session = "session-clock"; var key = new ObjectMapper().createObjectNode().put("tenantId", tenant) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionStoreTest.java index a62da7e6201..083522a7545 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/ToolPublicationRetentionStoreTest.java @@ -97,7 +97,7 @@ public class ToolPublicationRetentionStoreTest { Long.class, tenant)).isEqualTo(first); assertThatThrownBy(() -> retention.read(key)).isInstanceOfSatisfying(ManagedAgentException.class, error -> assertThat(error.getCode()).isEqualTo("tool_output_session_retired")); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); assertThatThrownBy(() -> tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L)))) .isInstanceOf(ManagedAgentException.class); @@ -317,7 +317,7 @@ public class ToolPublicationRetentionStoreTest { "READY", "BLOCKED_RESOURCE", "BLOCKED_WORKSPACE", "BLOCKED_EXECUTION" }) void retirementDerivesRecoveryProtectionFromThePrivateHead(String recovery) { - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); tx.executeWithoutResult(status -> sessions.sealWriter(tenant, session, "a".repeat(32), @@ -332,7 +332,7 @@ public class ToolPublicationRetentionStoreTest { @Test void writerAcquisitionAndRetirementSerializeEvenWithoutAnExistingHead() throws Exception { - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var locked = new CountDownLatch(1); var release = new CountDownLatch(1); try (var executor = java.util.concurrent.Executors.newFixedThreadPool(2)) { @@ -366,7 +366,7 @@ public class ToolPublicationRetentionStoreTest { key.put("sessionId", session); jdbc.update("UPDATE ma_output_capture SET session_id = ? WHERE tenant_id = ? AND session_id = ?", session, tenant, original); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); var operation = tx.execute(status -> publicStore.beginOperation(tenant, session, StoreModels.OperationKind.DELETE, @@ -395,7 +395,7 @@ public class ToolPublicationRetentionStoreTest { @Test void liveWriterPreventsRetirementAndDefaultsDisablePhysicalDeletion() { - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); assertThatThrownBy(this::retire).isInstanceOf(ManagedAgentException.class); @@ -464,7 +464,7 @@ public class ToolPublicationRetentionStoreTest { return super.update(sql, arguments); } }; - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(observed)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(observed); tx.executeWithoutResult(status -> sessions.acquireWriter(tenant, session, "a".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace-1", "writer", 60000L))); assertThatThrownBy(() -> sessions.readResource(tenant, "workspace-1", session, "resource-1", "b".repeat(32))) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStoreTest.java index 08755dcf5e1..f607af4e78c 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WakeRecoveryStoreTest.java @@ -143,7 +143,7 @@ public class WakeRecoveryStoreTest { append("input.accepted", "turn", "monitor"); var pending = candidate(); assertThat(store.claim(pending)).isTrue(); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var transactions = new TransactionTemplate(new DataSourceTransactionManager(jdbc.getDataSource())); for (String status : List.of("CLOSING", "CLOSED")) { jdbc.update("UPDATE ma_session SET status = ?", status); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationHistoryTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationHistoryTest.java index 133fab54192..a1e4aa78422 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationHistoryTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationHistoryTest.java @@ -31,10 +31,10 @@ class WorkspaceCsiApplicationHistoryTest { var manager = mock(PlatformTransactionManager.class); var bindings = new JdbcRuntimeBindingRepository(first, new AesGcmSecretProtector("test", new byte[32])); assertThatThrownBy(() -> new WorkspaceCsiApplicationHistory(jdbc, manager, bindings, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(new JdbcTemplate(second))), new ObjectMapper())).isInstanceOf(IllegalArgumentException.class); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(new JdbcTemplate(second)), new ObjectMapper())).isInstanceOf(IllegalArgumentException.class); var foreign = new JdbcRuntimeBindingRepository(second, new AesGcmSecretProtector("test", new byte[32])); assertThatThrownBy(() -> new WorkspaceCsiApplicationHistory(jdbc, manager, foreign, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), new ObjectMapper())).isInstanceOf(IllegalArgumentException.class); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), new ObjectMapper())).isInstanceOf(IllegalArgumentException.class); verifyNoInteractions(manager); } @@ -45,7 +45,7 @@ class WorkspaceCsiApplicationHistoryTest { var manager = mock(PlatformTransactionManager.class); var reader = new WorkspaceCsiApplicationHistory(jdbc, manager, new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])), - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), new ObjectMapper()); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), new ObjectMapper()); boolean previous = TransactionSynchronizationManager.isActualTransactionActive(); TransactionSynchronizationManager.setActualTransactionActive(true); try { @@ -103,7 +103,7 @@ class WorkspaceCsiApplicationHistoryTest { var manager = new DataSourceTransactionManager(source); var reader = new WorkspaceCsiApplicationHistory(jdbc, manager, new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])), - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), new ObjectMapper()); + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), new ObjectMapper()); // H2 ignores setReadOnly; the production guard must reject that actual Connection before SQL. assertThatThrownBy(() -> read(reader)) .isInstanceOf(IllegalArgumentException.class); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java index 3659e417713..341c9be9c95 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiApplicationInventoryTest.java @@ -1540,7 +1540,7 @@ class WorkspaceCsiApplicationInventoryTest { catalog.add(Map.of("name", "imported-" + i, "description", "Imported Skill", "body", i == 0 ? "x".repeat(404465) : "Instruction", "model_invocable", false)); } - var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); resources.publishAgentSkills("foreign", "foreign-workspace", "foreign-session", catalog); assertThat(readHistory(expected, new java.util.ArrayList<>(), true).blockers()).isEmpty(); resources.publishAgentSkills("foreign", "foreign-workspace", "foreign-session", List.of( diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java index 04b9dff5bdc..fa28dbfeadb 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiNativeActivationGateTest.java @@ -107,7 +107,7 @@ class WorkspaceCsiNativeActivationGateTest { bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("test", new byte[32])); sessions = new JdbcRuntimeSessionRepository(source); executions = new JdbcToolExecutionRepository(source, bindings); - journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); binding = bindings.findOrCreate(request); binding = bindings.claimOperation(binding.getBindingId(), "operator", Duration.ofSeconds(120)); reservation = reservations.reserve(registration, bindings, binding, UUID.randomUUID().toString()); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiRetainedArtifactsTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiRetainedArtifactsTest.java index 7533c0ed323..3d4bbe4148a 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiRetainedArtifactsTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiRetainedArtifactsTest.java @@ -83,7 +83,7 @@ class WorkspaceCsiRetainedArtifactsTest { void acceptsEmptyMessagesAndActualAgentResourcesIncludingEmptyAndLiteralText() throws Exception { materialize(); assertThat(tx.execute(status -> store.findMessagePage("foreign", session, null, 100, true)).items()).isEmpty(); - var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String literal = "Unicode 指令 {\"sessionId\":\"" + expected.table("ma_session").rows().getFirst().text("session_id") + "\"}"; for (String text : List.of("", literal)) { tx.execute(status -> resources.publishAgentInstructions("foreign", "workspace", session, text)); @@ -233,7 +233,7 @@ class WorkspaceCsiRetainedArtifactsTest { @ParameterizedTest @ValueSource(strings = {"instructions-utf8", "skills-shape", "skills-path", "skills-field", "publish-command", "agent-scope"}) void refusesMalformedAgentArtifactsWithOtherwiseCorrectDigests(String fault) throws Exception { - var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var resources = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); tx.execute(status -> resources.publishAgentInstructions("foreign", "workspace", session, "valid")); tx.execute(status -> resources.publishAgentSkills("foreign", "workspace", session, List.of(Map.of("name", "review", "description", "description", "body", "body")))); accepted(); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionGuardTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionGuardTest.java index 14ee8e58f3e..ec6dbcd3e5f 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionGuardTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSessionGuardTest.java @@ -94,7 +94,7 @@ class WorkspaceCsiSessionGuardTest { new AesGcmSecretProtector("test", new byte[32])); sessions = new JdbcRuntimeSessionRepository(source); executions = new JdbcToolExecutionRepository(source); - journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); } @ParameterizedTest diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java index 43a9890d0c4..218a1334a33 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiSqlWorkerMySqlIT.java @@ -173,8 +173,6 @@ class WorkspaceCsiSqlWorkerMySqlIT { var binding = bindings.claimOperation(initial.getBindingId(), "owned-test", Duration.ofMinutes(5)); var reserved = storage.reserve(registration, bindings, binding, UUID.randomUUID().toString()); bindings.releaseOperation(binding.getBindingId(), "owned-test", binding.getOperationGeneration()); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); - journal.setCsiBindings(bindings); var properties = new ManagedAgentProperties(); properties.setAgentRevision("construction-fixture/1"); // Wired as in production, so the receipt commits below reach the capture guard. @@ -182,7 +180,9 @@ class WorkspaceCsiSqlWorkerMySqlIT { com.alibaba.qwen.code.managedagent.VerificationFixtures.managedAgentStore(jdbc, JSON, java.time.Clock.systemUTC(), ignored -> {}, new ManagedWorkspaceRegistry(jdbc), properties, new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules())); - journal.setToolResults(toolResults); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc, + new ManagedExtensionRecordStore(jdbc), null, null, null, null, toolResults, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), bindings); var endpoint = new AtomicReference(); var actualClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build(); var mappedClient = mock(HttpClient.class); @@ -434,9 +434,9 @@ class WorkspaceCsiSqlWorkerMySqlIT { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_tool_execution WHERE native_authorization_json IS NOT NULL" + " AND execution_state = 'SETTLED'", Integer.class)).isEqualTo(4); // The receipts of those executions were committed through the private CSI - // lane, which records none for projection. The journal still holds the + // lane, which records none for projection. The journal commit component still holds the // result store, so the zero below is the capture guard's, not a missing store's. - assertThat(journal).extracting("toolResults").isSameAs(toolResults); + assertThat(journal).extracting("commits.toolResults").isSameAs(toolResults); assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ma_operation" + " WHERE operation_kind = 'TOOL_RESULT_PROJECTION'", Integer.class)).isZero(); assertThat(jdbc.queryForObject("SELECT writer_generation FROM ma_session_head", Long.class)) diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiWorkerAckStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiWorkerAckStoreTest.java index b06b8c89553..c27c49115cd 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiWorkerAckStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceCsiWorkerAckStoreTest.java @@ -243,7 +243,7 @@ public class WorkspaceCsiWorkerAckStoreTest { var bindings = new JdbcRuntimeBindingRepository(source, new AesGcmSecretProtector("fixture", new byte[32])); var sessions = new JdbcRuntimeSessionRepository(source); var executions = new JdbcToolExecutionRepository(source); - var managed = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var managed = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); var grants = new ToolPublicationStore(jdbc, manager, managed, executions, bindings, new ToolPublicationStore.Capacity(1024, 16 * 1024 * 1024, 16 * 1024 * 1024, 10)); var objects = mock(ToolPublicationObjectStore.class); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleMySqlIT.java index b8765f73305..7af01d82876 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleMySqlIT.java @@ -132,7 +132,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { + "connectionTimeZone=" + URLEncoder.encode(connectionZone, StandardCharsets.UTF_8) + "&sessionVariables=" + URLEncoder.encode("time_zone='" + databaseZone + "'", StandardCharsets.UTF_8)); var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new AcquireWriterRequest("workspace", "original", 60_000L))); String id = fixture.transactions.execute(ignored -> fixture.store.beginWorkspaceLifecycle("tenant", fixture.session, @@ -173,7 +173,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @Test void admissionSerializesAnOrdinaryWriterOnThePlacementFence() throws Exception { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); try (var pool = Executors.newSingleThreadExecutor()) { var entered = new CountDownLatch(1); var queued = new java.util.concurrent.atomic.AtomicReference>(); @@ -206,7 +206,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @Test void ordinaryAuthorizationFencesOnlyWithinOneSession() throws Exception { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); String token = "w".repeat(32); String otherTenant = "other-tenant"; String otherSession = "other-session"; @@ -293,7 +293,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @ValueSource(booleans = {false, true}) void idleJournalCheckReadsARevisionItsSnapshotCannotSee(boolean throughAdmission) throws Exception { var fixture = fixture(); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var journal = fixture.transactions.execute(ignored -> new ExtensionRecordJournal(store, "tenant", "workspace", fixture.session).open()); var txManager = new DataSourceTransactionManager(source); @@ -330,7 +330,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @Test void idleJournalCheckFailsClosedOnAJournalShortOfItsHead() throws Exception { var fixture = fixture(); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var journal = fixture.transactions.execute(ignored -> new ExtensionRecordJournal(store, "tenant", "workspace", fixture.session).open()); commitPendingTurn(fixture, journal); @@ -347,7 +347,7 @@ class WorkspaceLifecycleMySqlIT extends WorkspaceLifecycleStoreTest { @ValueSource(strings = {"accepted-settled", "other-settled", "accepted-pending"}) void unadmittedCancellationUsesTheFencedJournalSnapshot(String cut) throws Exception { var fixture = fixture(); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); fixture.transactions.executeWithoutResult(ignored -> new ExtensionRecordJournal(store, "tenant", "workspace", fixture.session).open()); String turn = fixture.transactions.execute(ignored -> fixture.store.insertTurnCommand("tenant", "SUBMIT_TURN", diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleStoreTest.java index f0f5e906a42..5dee4a4d816 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceLifecycleStoreTest.java @@ -62,9 +62,11 @@ class WorkspaceLifecycleStoreTest { void lifecycleHookTransitionsRecheckNewEffectsAndKeepOriginalSettlement(boolean authority, boolean granted, String before, String after, boolean replacement, boolean accepted, boolean batch, boolean draining) throws Exception { var fixture = fixture(); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); - store.setLifecycleExecution(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new WorkspaceExecutionStore(fixture.jdbc, - new DataSourceTransactionManager(fixture.jdbc.getDataSource()))), fixture.store); + var executionStore = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new WorkspaceExecutionStore(fixture.jdbc, + new DataSourceTransactionManager(fixture.jdbc.getDataSource()))); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc, + new ManagedExtensionRecordStore(fixture.jdbc), null, executionStore, fixture.store, null, null, null, + com.alibaba.qwen.code.managedagent.contracts.security.WriterCredentialPolicy.unbound(), null); var journal = fixture.transactions.execute(ignored -> new ExtensionRecordJournal(store, "tenant", "workspace", fixture.session).open()); var templates = ManagedHookRecordContractTest.fixtures().get("templates"); var data = new ManagedSessionStoreModels.CommitResource("hook-data", "hook-data", 1, 2, @@ -249,7 +251,7 @@ class WorkspaceLifecycleStoreTest { @MethodSource("invalidJournalRecords") void lifecycleJournalValidationRejectsInvalidRecordsAndRollsBack(String line, String mode) throws Exception { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); var operation = "ordinary".equals(mode) ? null : fixture.admit(OperationKind.DELETE); @@ -282,7 +284,7 @@ class WorkspaceLifecycleStoreTest { void hookReceiptsUseCompactProtocolIdentitiesWithAnIndentedApplicationMapper(OperationKind kind, boolean recover) throws Exception { var fixture = fixture(); fixture.json.enable(SerializationFeature.INDENT_OUTPUT); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); byte[] definition = "{\"toolProfile\":\"hosted-workspace-files/1\",\"hookCatalog\":{}}".getBytes(StandardCharsets.UTF_8); @@ -365,7 +367,7 @@ class WorkspaceLifecycleStoreTest { @Test void aResumedCloseVerifiesTheCarriedReceiptAgainstTheFailedOperationsHooks() throws Exception { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); byte[] definition = "{\"toolProfile\":\"hosted-workspace-files/1\",\"hookCatalog\":{}}".getBytes(StandardCharsets.UTF_8); @@ -444,7 +446,7 @@ class WorkspaceLifecycleStoreTest { @Test void detachOnlyAuthorizationAcceptsOriginalExpiredOrSealedWriterAfterEffects() throws Exception { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); byte[] definition = "{\"toolProfile\":\"hosted-workspace-files/1\"}".getBytes(StandardCharsets.UTF_8); @@ -493,7 +495,7 @@ class WorkspaceLifecycleStoreTest { @Test void legacyCloseKeepsItsOriginalHookPathButCannotAdmitOrdinaryWork() { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); String id = fixture.transactions.execute(ignored -> fixture.store.beginWorkspaceLifecycle("tenant", fixture.session, @@ -525,7 +527,7 @@ class WorkspaceLifecycleStoreTest { @Test void l3FenceCannotUseTheLegacyCloseException() { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); fixture.admit(OperationKind.CLOSE); @@ -539,7 +541,7 @@ class WorkspaceLifecycleStoreTest { @Test void lifecycleAttachmentCannotDetachBeforeEffectsAreSaved() { var fixture = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc); var writer = fixture.transactions.execute(ignored -> journal.acquireWriter("tenant", fixture.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); var operation = fixture.admit(OperationKind.DELETE); @@ -671,13 +673,13 @@ class WorkspaceLifecycleStoreTest { assertThat(fixture.jdbc.queryForObject("SELECT COUNT(*) FROM ma_session_record WHERE record_kind = 'ACTION'" + " AND state = 'requested'", Integer.class)).isZero(); - var journal = new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)), "tenant", "workspace", fixture.session); + var journal = new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc), "tenant", "workspace", fixture.session); fixture.transactions.executeWithoutResult(ignored -> journal.open()); byte[] genesis = fixture.jdbc.queryForObject("SELECT record_bytes FROM ma_session_journal_tx" + " WHERE journal_revision = 1", byte[].class); String prompt = fixture.store.findTurn("tenant", fixture.session, turn).orElseThrow().promptId(); var request = fixture.promptCommit(prompt, false); - fixture.transactions.executeWithoutResult(ignored -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)).commit( + fixture.transactions.executeWithoutResult(ignored -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc).commit( "tenant", fixture.session, "extension-writer-token-0123456789", request)); assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM ma_session_head", Long.class)).isEqualTo(2L); assertThat(fixture.jdbc.queryForObject("SELECT record_bytes FROM ma_session_journal_tx" @@ -708,7 +710,7 @@ class WorkspaceLifecycleStoreTest { "expired-monitor-v1", "expired-monitor-v2"}) void unacknowledgedCancellationRequiresFencedWriterAndIdleJournal(String cut) throws Exception { var fixture = fixture(); - var journal = new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)), "tenant", "workspace", fixture.session); + var journal = new ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc), "tenant", "workspace", fixture.session); fixture.transactions.executeWithoutResult(ignored -> journal.open()); byte[] genesis = fixture.jdbc.queryForObject("SELECT record_bytes FROM ma_session_journal_tx" + " WHERE journal_revision = 1", byte[].class); @@ -731,7 +733,7 @@ class WorkspaceLifecycleStoreTest { : fixture.store.findTurn("tenant", fixture.session, turn).orElseThrow().promptId(); var request = fixture.promptCommit(prompt, !"expired-pending".equals(cut) && !monitor, monitor ? "monitor" : "user"); - fixture.transactions.executeWithoutResult(ignored -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(fixture.jdbc)).commit( + fixture.transactions.executeWithoutResult(ignored -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(fixture.jdbc).commit( "tenant", fixture.session, "extension-writer-token-0123456789", request)); assertThat(fixture.jdbc.queryForObject("SELECT journal_revision FROM ma_session_head", Long.class)).isEqualTo(2L); } diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java index 1843e3e855e..4c6e22cdc9e 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceMigrationMySqlIT.java @@ -255,7 +255,7 @@ class WorkspaceMigrationMySqlIT { var manager = new DataSourceTransactionManager(data); var transaction = new TransactionTemplate(manager); transaction.setIsolationLevel(Connection.TRANSACTION_REPEATABLE_READ); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "a".repeat(64); transaction.execute(status -> sessions.acquireWriter("tenant-b", "m-successor", token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "writer", 30000L))); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleMySqlIT.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleMySqlIT.java index 0354b57ac57..776db5e4979 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleMySqlIT.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleMySqlIT.java @@ -68,7 +68,7 @@ class WorkspaceProfileLifecycleMySqlIT extends WorkspaceProfileLifecycleTest { @org.junit.jupiter.api.Test void expiredWriterRenewalAndColdAcquireCannotBothOwnTheHead() throws Exception { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); var writer = f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "resident", 60_000L))); var operation = admit(f, WorkspaceToolProfiles.SHELL, StoreModels.OperationKind.DELETE); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleTest.java index 28e529ff400..63475a5cb9e 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceProfileLifecycleTest.java @@ -73,7 +73,7 @@ class WorkspaceProfileLifecycleTest { assertThatThrownBy(() -> f.bindings.requireHarnessRecovery(f.scope(), f.session, null)).hasMessageContaining("closed"); assertThatThrownBy(() -> f.bindings.requireHarnessRecovery(f.scope(), f.session, com.alibaba.qwen.code.managedagent.repository.WorkspaceLifecycleStore.authority(first))).hasMessageContaining("closed"); assertThatThrownBy(() -> f.bindings.requireHookAdmission(f.scope(), f.session, current)).hasMessageContaining("closed"); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); var request = new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "cleanup", 60_000L); assertThatThrownBy(() -> f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, "w".repeat(32), request))) .isInstanceOf(ManagedAgentException.class); @@ -91,7 +91,7 @@ class WorkspaceProfileLifecycleTest { "hosted-workspace-mcp/1,CLOSE", "hosted-workspace-mcp/1,DELETE"}) void initialAdmissionPreservesTheLiveWriterForOneHandoff(String profile, OperationKind kind) { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); String token = "w".repeat(32); var writer = f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "resident", 1000L))); @@ -122,7 +122,7 @@ class WorkspaceProfileLifecycleTest { @ValueSource(ints = {1, 2}) void admissionNeverRevivesAnExpiredWriter(int protocol) { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "expired", 60_000L))); f.jdbc.update("UPDATE ma_session_head SET writer_lease_until = 0"); @@ -136,7 +136,7 @@ class WorkspaceProfileLifecycleTest { @ValueSource(strings = {WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP}) void newClaimsNeverRefreshTheAdmissionHandoff(String profile) { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "resident", 1000L))); var first = admit(f, profile, OperationKind.CLOSE); @@ -153,7 +153,7 @@ class WorkspaceProfileLifecycleTest { @ValueSource(strings = {WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP}) void expiredResidentWriterRenewsOnlyUnderTheCurrentResourceClaim(String profile) { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); String token = "w".repeat(32); var writer = f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "resident", 60_000L))); @@ -191,7 +191,7 @@ class WorkspaceProfileLifecycleTest { @Test void filesV1DrainingStillRejectsAnExpiredWriter() { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); var writer = f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, "w".repeat(32), new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "resident", 60_000L))); String id = f.transactions.execute(ignored -> f.store.beginWorkspaceLifecycle("tenant", f.session, OperationKind.CLOSE, @@ -211,7 +211,7 @@ class WorkspaceProfileLifecycleTest { @ValueSource(strings = {WorkspaceToolProfiles.SHELL, WorkspaceToolProfiles.MCP}) void sealedCleanupWriterCanBeReacquiredOnlyWithTheCurrentResourceClaim(String profile) { var f = fixture(); - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); String token = "w".repeat(32); var original = f.transactions.execute(ignored -> journal.acquireWriter("tenant", f.session, token, new ManagedSessionStoreModels.AcquireWriterRequest("workspace", "original", 60_000L))); @@ -620,7 +620,7 @@ class WorkspaceProfileLifecycleTest { @ValueSource(booleans = {false, true}) void queuedNotificationIsCleanupWorkButAnActivatedNotificationIsATurn(boolean activated) throws Exception { var f = fixture(); - var journal = new com.alibaba.qwen.code.managedagent.ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)), "tenant", "workspace", f.session); + var journal = new com.alibaba.qwen.code.managedagent.ExtensionRecordJournal(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc), "tenant", "workspace", f.session); f.transactions.executeWithoutResult(ignored -> journal.open()); var input = f.json.createObjectNode(); var event = input.putObject("managedSession").put("kind", "input.accepted"); @@ -688,7 +688,7 @@ class WorkspaceProfileLifecycleTest { @Test void duplicateOrdinaryCommitIsFencedByAVersionTwoClaim() throws Exception { var f = fixture(); - var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(f.jdbc)); + var sessions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(f.jdbc); var journal = f.transactions.execute(ignored -> new ExtensionRecordJournal(sessions, "tenant", "workspace", f.session).open()); var templates = ManagedHookRecordContractTest.fixtures().get("templates"); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStoreTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStoreTest.java index cf560481526..a348ee948c4 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStoreTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/persistence/WorkspaceRecoveryStoreTest.java @@ -229,7 +229,7 @@ class WorkspaceRecoveryStoreTest { request.put("mountRevision", revision); } long committedRevision = revision; - var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var journal = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String token = "a".repeat(64); String records = "{\"subtype\":\"session_execution_engine\"}\n{\"subtype\":\"managed_session_header_v1\"}\n"; byte[] bytes = records.getBytes(StandardCharsets.UTF_8); @@ -446,11 +446,11 @@ class WorkspaceRecoveryStoreTest { head(pinned); jdbc.update("UPDATE ma_session_head SET workspace_id = X'776f726b73706163652d62'" + " WHERE session_id = ?", pinned); - var instructions = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).publishAgentInstructions( + var instructions = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).publishAgentInstructions( "tenant", "workspace-b", pinned, "Keep the agent instructions. 保留指令。"); var skillCatalog = List.>of(Map.of( "name", "review", "description", "Review", "body", "Check the diff. 检查差异。")); - var skills = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).publishAgentSkills( + var skills = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).publishAgentSkills( "tenant", "workspace-b", pinned, skillCatalog); JsonNode sources = call(capture(), "sessions").path("sessions"); assertThat(sources).hasSize(3); diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionLifecycleCoordinatorTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionLifecycleCoordinatorTest.java index 2d1e24167ef..d8752be63e6 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionLifecycleCoordinatorTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionLifecycleCoordinatorTest.java @@ -267,7 +267,7 @@ class SessionLifecycleCoordinatorTest { public CompletionStage drain(String id) { throw new AssertionError("Accepted bound close must keep its original scope"); } }; try (var executor = Executors.newSingleThreadExecutor()) { - var coordinator = new SessionLifecycleCoordinator(store, com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)), + var coordinator = new SessionLifecycleCoordinator(store, com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc), new UnavailableHarnessConnector(), unsupported, new ChildResultRelayStore(jdbc), new ObjectMapper(), admissions(store, unsupported), noTurnCancels(), brokerProvider(null), executor, Clock.systemUTC(), properties); @@ -674,7 +674,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(false, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(false, false)), @@ -712,7 +712,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, true); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -772,7 +772,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -843,7 +843,7 @@ class SessionLifecycleCoordinatorTest { }; try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -908,7 +908,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(false, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, true), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, true)), noTurnCancels(), @@ -957,7 +957,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -996,7 +996,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1064,7 +1064,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1107,7 +1107,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1151,7 +1151,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1193,7 +1193,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, true); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1246,7 +1246,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1291,7 +1291,7 @@ class SessionLifecycleCoordinatorTest { try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator( lifecycleWorld.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(lifecycleWorld.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(lifecycleWorld.jdbc), harness, warmer(true, false), lifecycleWorld.relayStore, new ObjectMapper(), admissions(lifecycleWorld.store, warmer(true, false)), @@ -1340,7 +1340,7 @@ class SessionLifecycleCoordinatorTest { var harness = new CascadingHarness(true, false); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), noTurnCancels(), @@ -1406,7 +1406,7 @@ class SessionLifecycleCoordinatorTest { world.child, "run-1", "turn-1"); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, @@ -1494,7 +1494,7 @@ class SessionLifecycleCoordinatorTest { world.child, "run-1", "turn-1"); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, @@ -1566,7 +1566,7 @@ class SessionLifecycleCoordinatorTest { world.child, "run-1", "turn-1"); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, @@ -1619,7 +1619,7 @@ class SessionLifecycleCoordinatorTest { ManagedAgentService sessions = Mockito.mock(ManagedAgentService.class); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, @@ -1680,7 +1680,7 @@ class SessionLifecycleCoordinatorTest { world.child, "run-1", "turn-1"); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, @@ -1746,7 +1746,7 @@ class SessionLifecycleCoordinatorTest { world.child, "run-1", "turn-1"); try (var executor = Executors.newSingleThreadExecutor()) { var coordinator = new SessionLifecycleCoordinator(world.store, - com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(world.jdbc)), harness, + com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(world.jdbc), harness, warmer(true, false), world.relayStore, new ObjectMapper(), admissions(world.store, warmer(true, false)), sessions, diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionRecoveryInitialStateTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionRecoveryInitialStateTest.java index be4267cc7c2..6ebb112b544 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionRecoveryInitialStateTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/managedagent/session/SessionRecoveryInitialStateTest.java @@ -82,7 +82,7 @@ class SessionRecoveryInitialStateTest { var registry = mock(ManagedWorkspaceRegistry.class); store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedAgentStore(jdbc, json, Clock.systemUTC(), events -> {}, registry, new ManagedAgentProperties(), new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); - journals = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + journals = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); when(harness.isAvailable()).thenReturn(true); when(harness.supportsFencedTitles()).thenReturn(true); sessions = new ManagedAgentService(store, digests, coordinator, harness, registry, new com.alibaba.qwen.code.managedagent.harness.SdkHarnessRules()); @@ -318,7 +318,7 @@ class SessionRecoveryInitialStateTest { return result; } }; - assertThat(recovery(com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(moving))).query(TENANT, "actor", sessionId).nextTurnAllowed()).isFalse(); + assertThat(recovery(com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(moving)).query(TENANT, "actor", sessionId).nextTurnAllowed()).isFalse(); } private SessionRecoveryService recovery(ManagedSessionStore nativeStore) { diff --git a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/persistence/ManagedCheckpointHistorySqlTest.java b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/persistence/ManagedCheckpointHistorySqlTest.java index 482d9072ef4..0095bee94d5 100644 --- a/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/persistence/ManagedCheckpointHistorySqlTest.java +++ b/packages/managed-agent-server/verification/src/test/java/com/alibaba/qwen/code/runtimebroker/persistence/ManagedCheckpointHistorySqlTest.java @@ -2,7 +2,6 @@ package com.alibaba.qwen.code.runtimebroker.persistence; import com.alibaba.qwen.code.managedagent.contracts.persistence.ManagedSessionStoreModels; import com.alibaba.qwen.code.managedagent.persistence.ManagedSchemaJdbcTemplate; -import com.alibaba.qwen.code.managedagent.persistence.ManagedSessionStore; import com.alibaba.qwen.code.runtimebroker.CsiFilesRetirementProfile; import com.alibaba.qwen.code.runtimebroker.CsiNativeActivationProof; import com.alibaba.qwen.code.runtimebroker.ManagedCheckpointHistory; @@ -50,7 +49,7 @@ public class ManagedCheckpointHistorySqlTest { ManagedSchema.initialize(source); jdbc = new ManagedSchemaJdbcTemplate(source); transaction = new TransactionTemplate(new DataSourceTransactionManager(source)); - var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)); + var store = com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc); String session = UUID.randomUUID().toString(); original = new JdbcCsiFilesRetirementGuard.Original(CsiFilesRetirementProfile.request(new ContextBinding( "tenant", "workspace", 1, "storage", ".", CsiFilesRetirementProfile.CONTEXT_CONFIG_REF, 1), @@ -175,7 +174,7 @@ public class ManagedCheckpointHistorySqlTest { .put("recordBytesBase64", Base64.getEncoder().encodeToString(records.getBytes(StandardCharsets.UTF_8))); commit.putArray("resources").add(active).add(resource("root", "managed-checkpoint", root.toString())).add(pageRef); var request = JSON.treeToValue(commit, ManagedSessionStoreModels.CommitTransactionRequest.class); - transaction.execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.configure(new ManagedSessionStore(jdbc)).commit("tenant", original.request().getIsolationKey(), + transaction.execute(status -> com.alibaba.qwen.code.managedagent.VerificationFixtures.managedSessionStore(jdbc).commit("tenant", original.request().getIsolationKey(), "a".repeat(32), request)); return root; } diff --git a/scripts/tests/managed-agent-server-e2e.test.js b/scripts/tests/managed-agent-server-e2e.test.js index e5b82a55e85..8edee19aeed 100644 --- a/scripts/tests/managed-agent-server-e2e.test.js +++ b/scripts/tests/managed-agent-server-e2e.test.js @@ -1905,7 +1905,7 @@ describe('managed-agent-server e2e runner', () => { expect(source).toContain(`'${refusal}'`); expect( read( - 'packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/ManagedSessionStore.java', + 'packages/managed-agent-server/persistence/src/main/java/com/alibaba/qwen/code/managedagent/persistence/JournalFencing.java', ), ).toContain(`"${refusal}."`); const writerBefore = source.indexOf(