diff --git a/contracts/agents-api/admin-api.md b/contracts/agents-api/admin-api.md index 6dea7b673..fac3f8931 100644 --- a/contracts/agents-api/admin-api.md +++ b/contracts/agents-api/admin-api.md @@ -125,7 +125,7 @@ Paths are relative to `/core/v1/projects/{project_id}`. Each read returns the sa | Environment type other than `openai_hosted` | 400 | | Session missing or in another Project | 404 | -One transaction marks the Environment expired (a failed Environment stays failed), requests cancellation of running work, revokes the Runtime's authority and writes the audit entry. Cleanup of the sandbox and its snapshot follows through the normal lifecycle; a resource whose release is uncertain stays owned until the provider confirms it. The Session is not deleted: its history and persisted Files and Artifacts stay readable, unpersisted workspace contents are lost and the Session cannot resume. +One transaction marks the Environment expired (a failed Environment stays failed), fails input that has not started, so the Session reads `failed` with "The environment is no longer available for this input.", requests cancellation of running work, revokes the Runtime's authority and writes the audit entry. Cleanup of the sandbox and its snapshot follows through the normal lifecycle; a resource whose release is uncertain stays owned until the provider confirms it. The Session is not deleted: its history and persisted Files and Artifacts stay readable, unpersisted workspace contents are lost and the Session cannot resume. `POST` and `GET /projects/{project_id}/sessions/{session_id}/archive` return `{session_id, environment_id, state}`. `GET` is read-only and needs no generation. `state` is the resource's current disposition: `active`, `cleanup_pending` or `released`, whatever released it. `released` does not mean an active Turn has finished cancelling; read the Turn for that. diff --git a/contracts/agents-api/sessions-events.md b/contracts/agents-api/sessions-events.md index 2d4d4323f..2f30a502c 100644 --- a/contracts/agents-api/sessions-events.md +++ b/contracts/agents-api/sessions-events.md @@ -87,7 +87,7 @@ An empty `turn_id` or blank `call_id` is the generic 400 `invalid_request`. Apar - Initial input is required on `none` (400 `invalid_request_error`, "conversation-only sessions currently require initial input") and for `stream: true` on every placement except `self_hosted` (400, "streaming session creation requires initial input"). These checks run before the creation retry lookup. - The Session and its initial work commit in one transaction. On `none` that includes the first Turn and the input Items. On `openai_hosted` the input is reserved while the Environment provisions. On `self_hosted` it is reserved with an `environment_connection` action, and creation returns while the machine is offline. -- Reserved initial input has the same five-minute deadline as later input. When it passes before a Turn starts, the Session reads `failed` without a Turn. +- Reserved initial input has the same five-minute deadline as later input. When the deadline passes, or the Environment fails or expires, before a Turn starts, the Session reads `failed` without a Turn and `error` states the reason. - A creation retry returns the original Session and never admits its input again, including after later Turns ([creation retries](./wire-semantics.md)). ## Creation streaming diff --git a/contracts/agents-api/zh/admin-api.md b/contracts/agents-api/zh/admin-api.md index bba37ce0f..0577c0be2 100644 --- a/contracts/agents-api/zh/admin-api.md +++ b/contracts/agents-api/zh/admin-api.md @@ -1,7 +1,7 @@ --- title: "Core 管理 API" source: contracts/agents-api/admin-api.md -source_hash: 7fdb71b28ca0132d219056f9ddea1d3c1500f5e833b4ef788cbb930c9c40d4e0 +source_hash: d6301f4facacd059a48fc79afdcc922c4882692ce7368018e5c3c8763423e91f --- Core 管理 API(`/core/v1`)用于管理安装实例:项目及其 API 密钥、项目资源的读取和删除、执行器凭据、部署默认模型、沙箱部署及其节点、监控和审计。Web 的[控制台服务器](../../../docs/zh/web/console-server.md#forwarding-to-core)会为已登录的管理员调用它;运维人员则从 Core 主机上的脚本调用它([编写 Core API 脚本](../../../docs/zh/getting-started/operations.md#script-the-core-api))。生成的架构是 [core.openapi.yaml](../core.openapi.yaml),所有错误都使用 [Core 错误封装](core-errors.md)。 @@ -127,7 +127,7 @@ GET 返回所选配置和租户容量策略;尚未配置时返回 404 `workspa | Environment 类型不是 `openai_hosted` | 400 | | Session 不存在或属于另一个项目 | 404 | -一个事务会将 Environment 标记为过期(已失败的 Environment 仍保持失败状态)、请求取消正在运行的工作、吊销 Runtime 的权限并写入审计条目。沙箱及其快照随后通过正常生命周期完成清理;如果资源是否已释放尚不确定,在提供商确认之前,该资源仍归 Core 所有。Session 不会被删除:其历史以及已持久化的 Files 和 Artifacts 仍可读取,未持久化的工作区内容会丢失,并且该 Session 无法恢复运行。 +一个事务会将 Environment 标记为过期(已失败的 Environment 仍保持失败状态)、将尚未开始的输入标记为失败(Session 因此显示为 `failed`,错误为 "The environment is no longer available for this input.")、请求取消正在运行的工作、吊销 Runtime 的权限并写入审计条目。沙箱及其快照随后通过正常生命周期完成清理;如果资源是否已释放尚不确定,在提供商确认之前,该资源仍归 Core 所有。Session 不会被删除:其历史以及已持久化的 Files 和 Artifacts 仍可读取,未持久化的工作区内容会丢失,并且该 Session 无法恢复运行。 `POST` 和 `GET /projects/{project_id}/sessions/{session_id}/archive` 返回 `{session_id, environment_id, state}`。`GET` 是只读操作,不需要代次。`state` 表示资源的当前处置状态:`active`、`cleanup_pending` 或 `released`,无论该资源由什么操作释放。`released` 不表示活动的 Turn 已完成取消;请读取相应的 Turn。 diff --git a/contracts/agents-api/zh/sessions-events.md b/contracts/agents-api/zh/sessions-events.md index 00d3637cc..f0f0d756a 100644 --- a/contracts/agents-api/zh/sessions-events.md +++ b/contracts/agents-api/zh/sessions-events.md @@ -1,7 +1,7 @@ --- title: "Session、事件和历史" source: contracts/agents-api/sessions-events.md -source_hash: 1c6e57bbd212f7155906d68bbbcb2e976170512a2aca907e024104667419d720 +source_hash: dcd0eac2866f22d4bc050f56e2cc81b3feb70186a0eb971831a99fe0c4852509 --- 本契约涵盖 Session 内部发生的事情:发送输入、实时事件流,以及读取 Turn、Item 和使用量的持久化历史。Session 资源本身(创建配置、重试标识、更新、列出和删除)见 [API 行为](wire-semantics.md)。消息和函数结果内容见[消息内容](message-content.md)。[Agents API 指南](../../../docs/zh/api/public-agent-api.md)展示了使用 SDK 和 HTTP 的调用方式。 @@ -89,7 +89,7 @@ Turn 失败后 Session 仍可使用:新输入会启动一个新 Turn。后来 - `none` 必须提供初始输入(400 `invalid_request_error`,"conversation-only sessions currently require initial input");使用 `stream: true` 时,除 `self_hosted` 外的所有部署方式也必须提供初始输入(400,"streaming session creation requires initial input")。这些检查在创建重试查找之前执行。 - Session 及其初始工作在一个事务中提交。对于 `none`,这包括首个 Turn 和输入 Item。对于 `openai_hosted`,输入会在 Environment 预配期间保留。对于 `self_hosted`,输入会保留并带有 `environment_connection` 操作,即使机器仍处于离线状态,创建操作也会返回。 -- 预留的初始输入与后续输入具有相同的五分钟截止时间。如果截止时间在 Turn 启动前过去,Session 将显示为 `failed`,且不会创建 Turn。 +- 预留的初始输入与后续输入具有相同的五分钟截止时间。如果在 Turn 启动前截止时间已过,或 Environment 失败或过期,Session 将显示为 `failed`,不会创建 Turn,`error` 说明原因。 - 创建重试会返回原始 Session,绝不会再次接纳其输入,即使之后已经产生新的 Turn([创建重试](wire-semantics.md))。 ## 创建时流式传输 {#creation-streaming} diff --git a/services/core/internal/deployment/session_archive.go b/services/core/internal/deployment/session_archive.go index 025fa98f0..9135ddb6c 100644 --- a/services/core/internal/deployment/session_archive.go +++ b/services/core/internal/deployment/session_archive.go @@ -10,8 +10,9 @@ import ( ) // ArchiveSession ends the lifetime of a hosted Session's Environment while -// keeping the public Session, its history and persisted files. It cancels the -// Session's work, revokes the allocation's device and requests its cleanup; +// keeping the public Session, its history and persisted files. It expires the +// Environment as compute expiry does, so input that never ran fails and work is +// cancelled, then revokes the allocation's device and requests its cleanup; // the lifecycle performs the provider work, and only its confirmation releases // the allocation. expectedGeneration must be the current generation of the Web // managed deployment. @@ -87,14 +88,7 @@ func (e *ExecutionOperations) archiveSession(ctx context.Context, tenantID, sess if live && current.ProviderKey != d.InstallationID { return ErrConflict } - if err := sessions.TrackInputActivity(ctx, tx, func(ctx context.Context) error { - if !ended { - if err := tx.ExpireEnvironment(ctx, environment.ID); err != nil { - return err - } - } - return sessions.CancelWork(ctx, tx) - }); err != nil { + if err := sessions.ExpireEnvironment(ctx, tx, environment); err != nil { return err } if live { diff --git a/services/core/internal/deployment/session_archive_test.go b/services/core/internal/deployment/session_archive_test.go index 1731fa128..7b555b8fb 100644 --- a/services/core/internal/deployment/session_archive_test.go +++ b/services/core/internal/deployment/session_archive_test.go @@ -74,6 +74,7 @@ type fakeArchiveTx struct { busy bool allocation *Allocation loadActiveTurn func() (sessions.Turn, bool, error) + failPendingInput func() error cancelPendingInput func() error loadEnvironmentInput func() (*sessions.EnvironmentInputState, error) // audited is the administrator source the audit was recorded with. @@ -84,6 +85,7 @@ type fakeArchiveTx struct { // activity change. func (f *fakeArchiveTx) idle() { f.loadActiveTurn = func() (sessions.Turn, bool, error) { return sessions.Turn{}, false, nil } + f.failPendingInput = func() error { return nil } f.cancelPendingInput = func() error { return nil } f.loadEnvironmentInput = func() (*sessions.EnvironmentInputState, error) { return nil, nil } } @@ -157,6 +159,14 @@ func (f *fakeArchiveTx) LoadActiveTurn(context.Context) (sessions.Turn, bool, er return f.loadActiveTurn() } +func (f *fakeArchiveTx) FailPendingInput(context.Context) error { + f.record("FailPendingInput") + if f.failPendingInput == nil { + unexpected(f.t, "FailPendingInput") + } + return f.failPendingInput() +} + func (f *fakeArchiveTx) CancelPendingInput(context.Context) error { f.record("CancelPendingInput") if f.cancelPendingInput == nil { @@ -225,7 +235,7 @@ func TestArchiveSession(t *testing.T) { hosted := &sessions.Environment{ID: "environment", Status: "connected", Configuration: json.RawMessage(`{"type":"openai_hosted"}`)} expired := &sessions.Environment{ID: "environment", Status: "expired", Configuration: hosted.Configuration} live := &Allocation{ID: "allocation", DeviceID: "device", ProviderKey: "installation", State: "running"} - settle := []string{"LoadEnvironmentInput", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput"} + settle := []string{"LoadEnvironmentInput", "FailPendingInput", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput"} expire := append([]string{"LoadEnvironmentInput", "ExpireEnvironment environment"}, settle[1:]...) head := []string{"LoadDeployment", "LoadEnvironment", "FindAllocation environment"} join := func(parts ...[]string) []string { @@ -291,10 +301,10 @@ func TestArchiveResetSession(t *testing.T) { calls []string }{ {"idle Session", resetting(ResetAuto), hosted, false, requested, nil, - []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, + []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "FailPendingInput", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, {"busy Session", resetting(ResetAuto), hosted, true, requested, ErrSandboxResetSessionBusy, []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy"}}, {"forced busy Session", resetting(ResetForce), hosted, true, requested, nil, - []string{"LoadDeployment", "LoadEnvironment", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, + []string{"LoadDeployment", "LoadEnvironment", "LoadResetSource", "LoadProject", "FindAllocation environment", "LoadEnvironmentInput", "ExpireEnvironment environment", "FailPendingInput", "LoadActiveTurn", "CancelPendingInput", "LoadEnvironmentInput", "RecordArchiveAudit", "LoadArchive"}}, {"ended Environment", resetting(ResetAuto), failed, false, requested, nil, []string{"LoadDeployment", "LoadEnvironment", "LoadResetBusy", "LoadArchive"}}, {"another reset", resetting(ResetAuto), hosted, false, requested.Add(time.Second), ErrConflict, []string{"LoadDeployment", "LoadEnvironment"}}, } { diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index fc956bb22..d85067937 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -176,16 +176,12 @@ type AllocationCleanupTx interface { // SessionArchiveTx is one Session-locked archive of the Session's hosted // Environment, with the Session's cancellation bound to the same transaction. type SessionArchiveTx interface { - sessions.CancellationTx - sessions.InputActivityTx + sessions.EnvironmentExpiryTx // LoadDeployment locks the deployment and returns it. The Session lock // precedes it, as in Turn, allocation and input admission. LoadDeployment() (Record, error) // LoadEnvironment reads the Session's Environment. LoadEnvironment(ctx context.Context) (sessions.Environment, error) - // ExpireEnvironment sets the status of the Session's Environment to - // expired. - ExpireEnvironment(ctx context.Context, environment string) error // LoadResetBusy reports whether the Session has an in-progress or waiting // Turn or subagent Turn, or a pending Environment file write, which an // automatic reset does not archive. diff --git a/services/core/internal/sessions/environment_termination.go b/services/core/internal/sessions/environment_termination.go index 25978da74..fcf1d1a16 100644 --- a/services/core/internal/sessions/environment_termination.go +++ b/services/core/internal/sessions/environment_termination.go @@ -100,33 +100,34 @@ func FailEnvironment(ctx context.Context, tx EnvironmentFailureTx, environment E return tx.AppendChanges(ctx, environmentFailedChanges(EnvironmentFailure{Reason: reason, FailedAt: failedAt}, activity, pending, usage)...) } -// environmentTermination is how the end of a Session's managed compute settles -// its Environment. -type environmentTermination int - -const ( - // terminationFails records the provisioning failure of a live Environment - // whose compute did not expire. - terminationFails environmentTermination = iota - // terminationExpires expires a live Environment whose compute expired and - // settles its input. Public expiry neither asserts compute removal nor - // invents an expired event. - terminationExpires - // terminationSettles only settles the remaining input of an Environment - // that already failed or expired, without repeating its events. - terminationSettles -) +// EnvironmentExpiryTx is the Session transaction ExpireEnvironment runs in. +type EnvironmentExpiryTx interface { + CancellationTx + InputActivityTx + // FailPendingInput settles the Session's pending Environment input + // reservation as failed. + FailPendingInput(ctx context.Context) error + // ExpireEnvironment sets the status of the Session's Environment to + // expired. + ExpireEnvironment(ctx context.Context, environment string) error +} -// decideEnvironmentTermination decides how the end of managed compute settles -// an Environment of status whose compute did or did not expire. -func decideEnvironmentTermination(status string, computeExpired bool) environmentTermination { - switch { - case status == "failed" || status == "expired": - return terminationSettles - case computeExpired: - return terminationExpires - } - return terminationFails +// ExpireEnvironment ends the Session's Environment without a failure of its +// own: a live Environment expires, input that never ran fails, so the Session +// reads failed with its reason instead of idle, and active work is cancelled. +// It reports the input activity this changes. +func ExpireEnvironment(ctx context.Context, tx EnvironmentExpiryTx, environment Environment) error { + return TrackInputActivity(ctx, tx, func(ctx context.Context) error { + if environment.Status != "failed" && environment.Status != "expired" { + if err := tx.ExpireEnvironment(ctx, environment.ID); err != nil { + return err + } + } + if err := tx.FailPendingInput(ctx); err != nil { + return err + } + return CancelWork(ctx, tx) + }) } // EnvironmentTerminationTx is the Session transaction TerminateEnvironment @@ -142,31 +143,19 @@ type EnvironmentTerminationTx interface { // TerminateEnvironment settles the Session's Environment when its managed // compute ends. A live Environment whose compute did not expire fails with the -// reason and detail, as FailEnvironment reports it. Otherwise the Environment -// expires if it is still live, its pending input fails and its work is -// cancelled, and the input activity this changes is reported. +// reason and detail, as FailEnvironment reports it; any other ends as +// ExpireEnvironment reports it. func TerminateEnvironment(ctx context.Context, tx EnvironmentTerminationTx, computeExpired bool, reason string, detail *ProvisioningFailureDetail) error { environment, err := tx.LoadEnvironment(ctx) if err != nil { return err } - termination := decideEnvironmentTermination(environment.Status, computeExpired) - if termination == terminationFails { + if environment.Status != "failed" && environment.Status != "expired" && !computeExpired { if environment.Initialization == "complete" { // The category describes a failed initialization, which this was not. detail = nil } return FailEnvironment(ctx, tx, environment, reason, detail) } - return TrackInputActivity(ctx, tx, func(ctx context.Context) error { - if termination == terminationExpires { - if err := tx.ExpireEnvironment(ctx, environment.ID); err != nil { - return err - } - } - if err := tx.FailPendingInput(ctx); err != nil { - return err - } - return CancelWork(ctx, tx) - }) + return ExpireEnvironment(ctx, tx, environment) } diff --git a/services/core/internal/sessions/environment_termination_test.go b/services/core/internal/sessions/environment_termination_test.go index 3304f2f99..54d5ff612 100644 --- a/services/core/internal/sessions/environment_termination_test.go +++ b/services/core/internal/sessions/environment_termination_test.go @@ -35,24 +35,6 @@ func TestEnvironmentStateChange(t *testing.T) { } } -func TestDecideEnvironmentTermination(t *testing.T) { - for _, test := range []struct { - status string - expired bool - want environmentTermination - }{ - {"connected", false, terminationFails}, - {"disconnected", true, terminationExpires}, - {"failed", false, terminationSettles}, - {"failed", true, terminationSettles}, - {"expired", true, terminationSettles}, - } { - if got := decideEnvironmentTermination(test.status, test.expired); got != test.want { - t.Errorf("%s expired=%v: %v", test.status, test.expired, got) - } - } -} - // FailEnvironment journals the Environment failure, then cancels the Session's // work, then reports the error and the failed Session with the recorded // failure time and the settled input activity. diff --git a/services/core/tests/integration/admin_session_archive_test.go b/services/core/tests/integration/admin_session_archive_test.go index 4cab6715f..30ee03688 100644 --- a/services/core/tests/integration/admin_session_archive_test.go +++ b/services/core/tests/integration/admin_session_archive_test.go @@ -119,14 +119,20 @@ func TestManagedSessionArchiveUnallocatedAndGuards(t *testing.T) { if err != nil || result.State != sessions.ManagedArchiveReleased { t.Fatal("unallocated archive", result, err) } + // The initial input never ran, so the Session fails with the reason + // instead of reading idle; the Environment expired rather than failed. row, err := sessionAdapter(s).GetSession(t.Context(), tenant, session.ID) - if err != nil || row.Environment.Status != "expired" || row.EnvironmentFailure != nil || row.PendingInput || row.EnvironmentInputActivity != nil || row.LastTurn != nil { - t.Fatal("archive fabricated failed execution", row, err) + if err != nil || row.Environment.Status != "expired" || row.EnvironmentFailure != nil || row.PendingInput || row.LastTurn != nil || + row.EnvironmentInputActivity == nil || row.EnvironmentInputActivity.Status != "failed" || row.EnvironmentInputActivity.Failure != "environment_unavailable" { + t.Fatal("archive left the unrun initial input without a failure", row, err) } var reservationState string - if err := s.pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE session_id=$1", session.ID).Scan(&reservationState); err != nil || reservationState != "cancelled" { + if err := s.pool.QueryRow(t.Context(), "SELECT state FROM environment_input_reservations WHERE session_id=$1", session.ID).Scan(&reservationState); err != nil || reservationState != "failed" { t.Fatal("archive left pending initial input", reservationState, err) } + if events, err := sessionAdapter(s).ListSessionEvents(t.Context(), tenant, session.ID, 0); err != nil || len(events) == 0 || events[len(events)-1].Event.Type != "agent.session.failed" { + t.Fatal("archive did not report the failed input", events, err) + } before := adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events") retry, err := deploymentExecution(t, w).ArchiveSession(adminDeleteContext(t.Context(), tenant, uuid.NewString()), tenant, session.ID, 1) if err != nil || retry != result || !reflect.DeepEqual(before, adminMutationSnapshot(t, s, "sessions", "environments", "environment_input_reservations", "session_events")) {