From 57f7b2b47f9886db8697c0aa00058eeae0e8ada7 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sun, 25 Oct 2026 05:56:25 +0000 Subject: [PATCH] Emit Environment pending and ready events --- contracts/agents-api/environments.md | 1 + contracts/agents-api/index.md | 4 +++- contracts/agents-api/zh/environments.md | 3 ++- contracts/agents-api/zh/index.md | 6 ++++-- .../sessions/execution_environment.go | 20 +++++++++++++++++-- .../sessions/execution_environment_test.go | 10 +++++++--- ...sted_initialization_failure_public_test.go | 6 +++--- .../runtime_initialization_test.go | 13 ++++++++++++ 8 files changed, 51 insertions(+), 12 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 623ab7bb6..e99afc829 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -255,6 +255,7 @@ stateDiagram-v2 - Each file, Skill or Plugin transfer has a two-minute staging budget. Configure, package installation, setup commands and snapshot finalization share the whole initialization’s 30-minute budget, whose remaining duration Core carries on each operation and the Runtime enforces. Initial input keeps its five-minute admission deadline, so large installations should start from an idle Session. - A running initialization whose owner is lost, including across a Core restart, fails as unconfirmed; nothing is replayed. A completed Environment never reinstalls on reconnect or native recovery, so later user changes survive. - Failure is terminal for the Session but destroys neither compute nor files. +- An `openai_hosted` Environment journals `agent.session.environment.pending` in the transaction that starts its initialization and `agent.session.environment.ready` in the one that completes it, each exactly once. A `self_hosted` Environment emits neither. A failed initialization records the events and Session state that [Environment initialization failure](./sessions-events.md#environment-initialization-failure) defines. The reason names only the step and its exit status: diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index c938e5458..4cb29a291 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -110,6 +110,7 @@ Each item is Core's deliberate or native behavior where the official service beh - `openai_hosted` and Template `capability_directories` must lie inside `/workspace`; the official service also accepts system paths such as `/etc` ([capability directories](./environments.md#plugins-and-capability-directories)). - `env` cannot set `OPENAI_API_KEY`, which Core reserves ([env](./environments.md#env-and-setup-commands)); the official service accepts it. - A `self_hosted` Session without pending input requests no connection ([activity](./environments.md#activity-and-required-actions)); one unused official Session switched to `requires_action` with `environment_connection` about five minutes after creation, and its deletion then returned 409. +- A hosted Environment emits `ready` once, when its initialization completes, and not again before each later Turn as one official Session did. It also emits `connected` and `disconnected`, which that Session did not. **Files, Skills, Environment files and Artifacts** ([Files and Skills](./source-files.md), [Environment files and Artifacts](./environment-files.md)) @@ -141,7 +142,7 @@ Each item is Core's deliberate or native behavior where the official service beh **Execution and history** -- The stream does not emit Environment `pending` or `ready` events, or every pinned interim tool-output variant. +- The stream does not emit every pinned interim tool-output variant. - Native Item variants beyond those listed under [Turns and Items](./sessions-events.md#turns-and-items) are not projected, and Items cannot be modified. - A function result that cancellation prevents from being applied never appears as an Item. - Pinned Codex can lose command output emitted before its stream subscription. @@ -183,6 +184,7 @@ Each item is Core's deliberate or native behavior where the official service beh - The order of errors when one request has several faults, and error, default and payload-limit parity in general. - A Subagent's function call: Core makes it a required action of the root Turn that runs the child, with `turn_id` set to that Turn, and publishes the child's `function_call` Item once as `agent.session.turn.item.added` with the child Turn ID and a null `output_index`; a result may name either Turn. The Turn is `waiting` and the Session `requires_action` while root output may continue, and the `call_id` equals the `call_id` of the child's `function_call` Item. Claude Code publishes the child's Items when the root Turn settles, after the required action. +- Whether the official service emits Environment `pending` and when: Core emits it when a hosted Environment's initialization starts, and the one official sample showed only `ready`, about 29 s after creation. - Whether Subagent child Turns and pending Environment file writes block Session deletion. - The official order between the pending-input error and an unknown result target. - Whether a Session whose latest Turn failed accepts new input: Core starts a new Turn, and one official sample rejected the input. diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index d5a0d4d64..933cffb75 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 15550304b85596e09cbeb4cc6d7253a4bdd64ffd6adc58a539511e0c939f7ad6 +source_hash: fa17eba20ecb905da36dfb1c0ffb8d9a86f49560f0688ddc6b9a3b59009fc4b4 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及已完成准备的能力。Session 通过其 `environment` 配置创建 Environment;不存在独立的 create 调用。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -257,6 +257,7 @@ stateDiagram-v2 - 每个文件、Skill 或 Plugin 传输的暂存预算为两分钟。configure、软件包安装、设置命令和快照最终确定共享整个初始化过程的 30 分钟预算,Core 在每项操作中携带剩余时长,由 Runtime 执行。初始输入仍保留其五分钟接纳期限,因此大型安装应从空闲 Session 开始。 - 正在运行且所有权丧失的初始化,包括跨 Core 重启丧失所有权,会作为未确认而失败;不会重播任何内容。已完成的 Environment 在重连或原生恢复时绝不会重新安装,因此用户后续更改会保留下来。 - 失败对 Session 而言是终止状态,但不会销毁计算资源或文件。 +- `openai_hosted` Environment 在开始初始化的事务中记录 `agent.session.environment.pending`,在完成初始化的事务中记录 `agent.session.environment.ready`,各恰好一次。`self_hosted` Environment 两者都不发出。 初始化失败时记录的事件和 Session 状态由 [Environment 初始化失败](sessions-events.md#environment-initialization-failure)定义。原因只指明步骤及其退出状态: diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index f545637d7..43691403b 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 09fcf64102a376e53b2881621c21647afb7187e3899ef6c4b9f350fa7a2d95fe +source_hash: b6322e1f2733cd1e5b1486488c4341190ce72b3cd29bb4547ef3fc244893ff7c --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -113,6 +113,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - `openai_hosted` 和模板的 `capability_directories` 必须位于 `/workspace` 内;官方服务也接受 `/etc` 等系统路径([能力目录](environments.md#plugins-and-capability-directories))。 - `env` 不能设置 Core 保留的 `OPENAI_API_KEY`([env](environments.md#env-and-setup-commands));官方服务接受该变量。 - 没有待处理输入的 `self_hosted` Session 不会请求连接([活动状态](environments.md#activity-and-required-actions));一个未使用的官方 Session 在创建约五分钟后转为 `requires_action`,原因为 `environment_connection`,之后删除它返回 409。 +- hosted Environment 只在初始化完成时发出一次 `ready`,不会像一个官方 Session 那样在之后每个 Turn 之前再次发出;它还会发出 `connected` 和 `disconnected`,而该官方 Session 没有。 **Files、Skills、Environment files 和 Artifacts**([Files and Skills](source-files.md)、[Environment files and Artifacts](environment-files.md)) @@ -144,7 +145,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh **执行和历史** -- 流不会发出 Environment 的 `pending` 或 `ready` 事件,也不会覆盖固定版本中的所有临时 tool-output 变体。 +- 流不会覆盖固定版本中的所有临时 tool-output 变体。 - 除 [Turns and Items](sessions-events.md#turns-and-items) 中列出的变体外,其他原生 Item 变体不会被投影,而且 Items 无法修改。 - 如果取消导致函数结果无法应用,该结果将永远不会作为 Item 出现。 - 固定版本的 Codex 可能会丢失在订阅其流之前发出的命令输出。 @@ -186,6 +187,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - 当一个请求存在多项故障时的错误顺序,以及错误处理、默认值和载荷限制的总体一致性。 - Subagent 的函数调用:Core 将其作为运行该子级的根 Turn 的待执行操作,`turn_id` 为该 Turn,并把子级的 `function_call` Item 以 `agent.session.turn.item.added` 发布一次,`turn_id` 为子级 Turn ID,`output_index` 为 null;结果可以指定其中任一 Turn。该 Turn 为 `waiting`、Session 为 `requires_action`,根输出可继续,`call_id` 等于该子级 `function_call` Item 的 `call_id`。Claude Code 在根 Turn 收敛时才发布子级的 Item,晚于该待执行操作。 +- 官方服务是否发出 Environment `pending` 以及何时发出:Core 在 hosted Environment 初始化开始时发出,而仅有的一个官方样本只显示了创建约 29 秒后的 `ready`。 - Subagent 子级 Turns 和待处理的 Environment file 写入是否会阻止 Session 删除。 - 官方服务在待处理输入错误与未知结果目标之间的先后顺序。 - 最新 Turn 失败的 Session 是否接受新输入:Core 会开始新的 Turn,而一个官方样本拒绝了该输入。 diff --git a/services/core/internal/sessions/execution_environment.go b/services/core/internal/sessions/execution_environment.go index 8974f4955..942c08b60 100644 --- a/services/core/internal/sessions/execution_environment.go +++ b/services/core/internal/sessions/execution_environment.go @@ -175,17 +175,33 @@ func (o *ExecutionOperations) advanceInitialization(ctx context.Context, owner E // device. func (o *ExecutionOperations) ClaimEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization) error { return o.advanceInitialization(ctx, owner, func(ctx context.Context, tx InitializationTx, environment Environment) (bool, error) { - return tx.ClaimInitialization(ctx, environment.ID) + return journalInitialization(ctx, tx, environment, "pending", tx.ClaimInitialization) }) } // CompleteEnvironmentInitialization completes the claimed preparation. func (o *ExecutionOperations) CompleteEnvironmentInitialization(ctx context.Context, owner EnvironmentInitialization) error { return o.advanceInitialization(ctx, owner, func(ctx context.Context, tx InitializationTx, environment Environment) (bool, error) { - return tx.CompleteInitialization(ctx, environment.ID) + return journalInitialization(ctx, tx, environment, "ready", tx.CompleteInitialization) }) } +// journalInitialization applies one initialization transition and, when it +// applied to a hosted Environment, journals the matching +// agent.session.environment. change in the same transaction. Self-hosted +// Environments are prepared by the user and emit neither event. +func journalInitialization(ctx context.Context, tx InitializationTx, environment Environment, status string, transition func(context.Context, string) (bool, error)) (bool, error) { + kind, err := EnvironmentType(environment.Configuration) + if err != nil { + return false, err + } + applied, err := transition(ctx, environment.ID) + if err != nil || !applied || kind != "openai_hosted" { + return applied, err + } + return true, tx.AppendChanges(ctx, EnvironmentStateChange(environment.ID, kind, status)) +} + // FailEnvironmentInitialization records the failure of a preparation that did // not complete and fails the Environment, as FailEnvironment reports it. It // records state and settles work; it never destroys compute or a workspace. diff --git a/services/core/internal/sessions/execution_environment_test.go b/services/core/internal/sessions/execution_environment_test.go index 73461e26b..286673b55 100644 --- a/services/core/internal/sessions/execution_environment_test.go +++ b/services/core/internal/sessions/execution_environment_test.go @@ -163,6 +163,7 @@ func TestInitializationOwnerAndDeviceRules(t *testing.T) { } func TestInitializationTransitionsRunUnderTheListedDevice(t *testing.T) { + var journaled []SessionChange owner := EnvironmentInitialization{TenantID: "tenant", SessionID: "session", EnvironmentID: "environment", DeviceID: "device"} listed := func() (ExecutionDevice, bool, error) { return ExecutionDevice{ID: "device", EnvironmentID: "environment"}, true, nil @@ -176,15 +177,18 @@ func TestInitializationTransitionsRunUnderTheListedDevice(t *testing.T) { want error calls []string }{ - {"claim", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, claimInitialization: returns(true)}, + {"claim", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, claimInitialization: returns(true), appendChanges: collect(&journaled)}, + LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, + nil, append(checked, "ClaimInitialization environment", "AppendChanges agent.session.environment.pending")}, + {"claim of a self-hosted Environment", fakeTx{loadEnvironment: returns(Environment{ID: "environment", Status: "pending", Configuration: []byte(`{"type":"self_hosted"}`)}), loadSessionDevice: listed, claimInitialization: returns(true)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, nil, append(checked, "ClaimInitialization environment")}, {"claim that did not apply", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, claimInitialization: returns(false)}, LockedSession{}, func(o *ExecutionOperations) error { return o.ClaimEnvironmentInitialization(t.Context(), owner) }, ErrTurnConflict, append(checked, "ClaimInitialization environment")}, - {"complete", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, completeInitialization: returns(true)}, + {"complete", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: listed, completeInitialization: returns(true), appendChanges: collect(&journaled)}, LockedSession{}, func(o *ExecutionOperations) error { return o.CompleteEnvironmentInitialization(t.Context(), owner) }, - nil, append(checked, "CompleteInitialization environment")}, + nil, append(checked, "CompleteInitialization environment", "AppendChanges agent.session.environment.ready")}, {"device moved", fakeTx{loadEnvironment: returns(hostedEnvironment), loadSessionDevice: loads(ExecutionDevice{ID: "other", EnvironmentID: "environment"}, true)}, LockedSession{}, func(o *ExecutionOperations) error { return o.CompleteEnvironmentInitialization(t.Context(), owner) }, ErrTurnConflict, checked}, diff --git a/services/core/tests/integration/hosted_initialization_failure_public_test.go b/services/core/tests/integration/hosted_initialization_failure_public_test.go index c5805c4ed..06956d80d 100644 --- a/services/core/tests/integration/hosted_initialization_failure_public_test.go +++ b/services/core/tests/integration/hosted_initialization_failure_public_test.go @@ -187,8 +187,8 @@ func TestHostedInitializationFailureRecordsSafeSessionFailure(t *testing.T) { t.Fatal("Session list", page, err) } events, err := sessionAdapter(s).ListSessionEvents(t.Context(), tenant, session.ID, 0) - // Transport may connect while initialization is still running. - if len(events) > 0 && events[0].Event.Type == "agent.session.environment.connected" { + // Initialization starts and transport may connect before the failure. + for len(events) > 0 && (events[0].Event.Type == "agent.session.environment.pending" || events[0].Event.Type == "agent.session.environment.connected") { events = events[1:] } @@ -352,7 +352,7 @@ func TestHostedInitializationFailurePublicHTTP(t *testing.T) { t.Fatal("invalid frame", line) } bodies = append(bodies, data) - if frame["type"] == "agent.session.environment.connected" && len(frames) == 0 && len(names) == 1 { + if (frame["type"] == "agent.session.environment.pending" || frame["type"] == "agent.session.environment.connected") && len(frames) == 0 && len(names) == 1 { names = nil continue } diff --git a/services/core/tests/integration/runtime_initialization_test.go b/services/core/tests/integration/runtime_initialization_test.go index 3095a02c5..3fed7317b 100644 --- a/services/core/tests/integration/runtime_initialization_test.go +++ b/services/core/tests/integration/runtime_initialization_test.go @@ -202,4 +202,17 @@ func TestManagedRuntimePreparationAllOperationsUsePeer(t *testing.T) { if err != nil || initializationState(t, s, allocation.TenantID, allocation.EnvironmentID) != "complete" { t.Fatal("initialization incomplete", allocation, err) } + changes, err := sessionAdapter(s).ListSessionEvents(t.Context(), tenant, session.ID, 0) + if err != nil { + t.Fatal(err) + } + var lifecycle []string + for _, change := range changes { + if change.Event.Type == "agent.session.environment.pending" || change.Event.Type == "agent.session.environment.ready" { + lifecycle = append(lifecycle, change.Event.Type) + } + } + if !reflect.DeepEqual(lifecycle, []string{"agent.session.environment.pending", "agent.session.environment.ready"}) { + t.Fatal("initialization events", lifecycle) + } }