From 4e5480945cd9b65ed04868da5aebf11ae40d7a8d Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Sun, 25 Oct 2026 06:01:51 +0000
Subject: [PATCH 1/2] Report initialization step timing
---
.../internal/dispatch/runtime_preparation.go | 2 +
.../dispatch/runtime_preparation_test.go | 22 ++++++
apps/web/e2e/fixture-console.mjs | 2 +-
.../web/src/features/sessions/SessionPage.tsx | 3 +-
.../sessions/session-diagnostics.test.tsx | 17 ++++-
.../features/sessions/session-diagnostics.tsx | 12 ++++
apps/web/src/i18n/locales/en/diagnostics.ts | 13 +++-
.../web/src/i18n/locales/zh-CN/diagnostics.ts | 13 +++-
apps/web/src/lib/diagnostic-failure.ts | 3 +
contracts/agents-api/core-errors.md | 2 +-
contracts/agents-api/core.openapi.yaml | 29 ++++++++
contracts/agents-api/session-diagnostics.md | 17 ++++-
contracts/agents-api/zh/core-errors.md | 4 +-
.../agents-api/zh/session-diagnostics.md | 19 ++++-
docs/runtime-protocol.md | 2 +-
docs/zh/runtime-protocol.md | 4 +-
internal/agentdaemon/proto/runtime_prepare.go | 6 ++
.../agentdaemon/proto/runtime_prepare_test.go | 16 +++++
.../agents-client/src/generated/core-api.ts | 11 ++-
.../src/session-diagnostics.test.ts | 26 ++++---
.../agents-client/src/session-diagnostics.ts | 22 ++++--
.../core/internal/api/session_diagnostics.go | 19 ++++-
.../internal/api/session_diagnostics_test.go | 15 ++--
.../db/queries/environment_initialization.sql | 4 ++
.../db/sqlc/environment_initialization.sql.go | 15 ++++
.../core/internal/db/sqlc/environments.sql.go | 6 +-
services/core/internal/db/sqlc/models.go | 17 ++---
.../core/internal/deployment/allocations.go | 2 +-
.../execution/runtime_capabilities_test.go | 6 +-
.../execution/runtime_initialization.go | 36 +++++++---
.../core/internal/execution/runtime_setup.go | 70 ++++++++++++++++---
.../internal/execution/runtime_setup_test.go | 37 ++++++++--
.../sessionpg/execution_environment.go | 16 +++++
.../postgres/sessionpg/session_reads.go | 3 +
.../sessions/execution_environment.go | 11 +++
.../sessions/execution_environment_test.go | 4 ++
.../core/internal/sessions/provisioning.go | 54 +++++++++++++-
services/core/internal/sessions/session.go | 3 +
...00009_environment_initialization_steps.sql | 4 ++
.../integration/session_diagnostics_test.go | 33 +++++++++
40 files changed, 520 insertions(+), 80 deletions(-)
create mode 100644 services/core/migrations/000009_environment_initialization_steps.sql
diff --git a/apps/daemon/internal/dispatch/runtime_preparation.go b/apps/daemon/internal/dispatch/runtime_preparation.go
index 926ddc5f9..a50d1d3b5 100644
--- a/apps/daemon/internal/dispatch/runtime_preparation.go
+++ b/apps/daemon/internal/dispatch/runtime_preparation.go
@@ -151,7 +151,9 @@ func (r *Router) runRuntimePreparationTransfer(ctx context.Context, u *runtimePr
r.mu.Unlock()
result := rejectedRuntimePreparation("invalid_request")
if admitted {
+ started := time.Now()
result = runtimePreparationResult(apply(ctx, u.request, data), u.request.SizeBytes)
+ result.DurationMS = time.Since(started).Milliseconds()
}
// Release the potentially large body before waiting on transport delivery.
data = nil
diff --git a/apps/daemon/internal/dispatch/runtime_preparation_test.go b/apps/daemon/internal/dispatch/runtime_preparation_test.go
index cdd948ae6..6a6a8069a 100644
--- a/apps/daemon/internal/dispatch/runtime_preparation_test.go
+++ b/apps/daemon/internal/dispatch/runtime_preparation_test.go
@@ -441,3 +441,25 @@ func TestRuntimePreparationHonorsBeginBudgetDuringStaging(t *testing.T) {
shutdownCapabilitiesRouter(t, r)
})
}
+
+func TestRuntimePreparationReportsApplyDuration(t *testing.T) {
+ synctest.Test(t, func(t *testing.T) {
+ r, sender, environment, session := capabilitiesTestRouter(t)
+ _, cancel := context.WithCancel(t.Context())
+ id := uuid.NewString()
+ request := proto.RuntimePreparePayload{BudgetMS: 300000, Step: "begin", Action: "finalize", EnvironmentID: environment, SessionID: session, Sources: &proto.CapabilitySources{}}
+ owner := &runtimePreparationTransfer{envelope: capabilityEnvelope(t, id, request), request: request, ready: make(chan struct{}), cancel: cancel, finished: true, apply: true}
+ close(owner.ready)
+ r.runtimePreparation = owner
+ r.shutdownWG.Add(1)
+ go r.runRuntimePreparationTransfer(t.Context(), owner, func(context.Context, proto.RuntimePreparePayload, []byte) error {
+ time.Sleep(1500 * time.Millisecond)
+ return nil
+ })
+ synctest.Wait()
+ got := capabilitiesReceipt(t, sender, id, "completed")
+ if got.DurationMS != 1500 || !proto.ValidRuntimePrepareResult(got, "completed", 0, 0) {
+ t.Fatalf("apply duration not reported: %+v", got)
+ }
+ })
+}
diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs
index a6c92d720..79f5c5d46 100644
--- a/apps/web/e2e/fixture-console.mjs
+++ b/apps/web/e2e/fixture-console.mjs
@@ -293,7 +293,7 @@ function adminRead(response, path, url) {
if (m[2] === "diagnostics") {
const failedTurn = (state.turns.get(session.id) ?? []).findLast((turn) => turn.status === "failed");
return send(response, 200, { object: "core.session_diagnostics", session_id: session.id, status: session.status,
- failure: session.status === "failed" ? { source: failedTurn ? "turn" : "environment_input", ...(failedTurn ? { turn_id: failedTurn.id } : {}), code: "internal_error", params: {}, failed_at: null } : null });
+ failure: session.status === "failed" ? { source: failedTurn ? "turn" : "environment_input", ...(failedTurn ? { turn_id: failedTurn.id } : {}), code: "internal_error", params: {}, failed_at: null } : null, initialization_steps: [] });
}
const diagnostics = m[2].match(/^turns\/([^/]+)\/diagnostics$/);
if (diagnostics) {
diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx
index 4187e3ddb..c72c20f71 100644
--- a/apps/web/src/features/sessions/SessionPage.tsx
+++ b/apps/web/src/features/sessions/SessionPage.tsx
@@ -16,7 +16,7 @@ import { sessionTitle } from "../../lib/session-title";
import { forgetDeleted } from "../resources/detail-queries";
import { ExecutorCredentialsSection } from "./ExecutorCredentialsSection";
import { SessionDeleteDialog, type SessionDeleteTarget } from "./SessionDeleteDialog";
-import { DiagnosticScope, SessionFailureDetails } from "./session-diagnostics";
+import { DiagnosticScope, InitializationSteps, SessionFailureDetails } from "./session-diagnostics";
import { sessionKey } from "./session-queries";
import { isSessionActive, turnAnchorId } from "./session-history";
import { environmentKind, isDeletable } from "./session-log";
@@ -150,6 +150,7 @@ export function SessionPage() {
{session.vault_ids.length ? session.vault_ids.map((id) => ) : MISSING}
+
{waiting.length ?
diff --git a/apps/web/src/features/sessions/session-diagnostics.test.tsx b/apps/web/src/features/sessions/session-diagnostics.test.tsx
index 1f179df34..dfcd7140e 100644
--- a/apps/web/src/features/sessions/session-diagnostics.test.tsx
+++ b/apps/web/src/features/sessions/session-diagnostics.test.tsx
@@ -5,14 +5,14 @@ import { afterEach, describe, expect, it } from "vitest";
import i18n from "../../i18n";
import { diagnosticFailure } from "../../lib/diagnostic-failure";
-import { DiagnosticScope, SessionFailure, SessionFailureDetails, TurnFailure, sessionDiagnosticsQuery, turnDiagnosticsQuery } from "./session-diagnostics";
+import { DiagnosticScope, InitializationSteps, SessionFailure, SessionFailureDetails, TurnFailure, sessionDiagnosticsQuery, turnDiagnosticsQuery } from "./session-diagnostics";
import { ItemReceiptTiming } from "./trace/ItemReceiptTiming";
const scope = { projectId: "project", sessionId: "session" };
const session = { id: "session", status: "failed", last_active_at: 20 } as AgentSession;
const turn = { id: "turn", session_id: "session", status: "failed", completed_at: 20, error: { code: "internal_error", message: "PRIVATE_NATIVE_SENTINEL" } } as AgentTurn;
const failure: DiagnosticFailure = { code: "authentication_error", params: {}, failed_at: null };
-const sessionData: SessionDiagnostics = { object: "core.session_diagnostics", session_id: "session", status: "failed", failure: { ...failure, source: "turn", turn_id: "turn" } };
+const sessionData: SessionDiagnostics = { object: "core.session_diagnostics", session_id: "session", status: "failed", failure: { ...failure, source: "turn", turn_id: "turn" }, initialization_steps: [{ step: "npm", index: null, duration_ms: 4100 }, { step: "setup", index: 1, duration_ms: 5200 }] };
const turnData: TurnDiagnostics = { object: "core.turn_diagnostics", session_id: "session", turn_id: "turn", status: "failed", failure, items: [], items_truncated: false };
afterEach(() => { void i18n.changeLanguage("en"); });
@@ -38,6 +38,17 @@ describe("Core diagnostics in the console", () => {
expect(render("turn", turnData)).not.toContain("PRIVATE_NATIVE_SENTINEL");
});
+ it("lists the initialization operations that ran with their durations and the failure category", () => {
+ const client = new QueryClient();
+ client.setQueryData(sessionDiagnosticsQuery(scope.projectId, session).queryKey, sessionData);
+ const html = renderToStaticMarkup(
);
+ client.clear();
+ expect(html).toContain("npm dependencies: 4.1 s");
+ expect(html).toContain("setup 2: 5.2 s");
+ const t = i18n.getFixedT("en", "diagnostics");
+ expect(diagnosticFailure({ code: "environment_provisioning_failed", params: { step: null, index: null, exit_code: null, category: "timeout" }, failed_at: null }, t)).toContain("The preparation timed out.");
+ });
+
it("gives the Session page the reason, then its next step, in one block", () => {
expect(render("session", sessionData)).toContain('
The model provider rejected authentication.
Check the API key');
});
@@ -58,7 +69,7 @@ describe("Core diagnostics in the console", () => {
it("renders only typed provisioning metadata and does not guess a timeout", () => {
const t = i18n.getFixedT("en", "diagnostics");
- const text = diagnosticFailure({ code: "environment_provisioning_failed", params: { step: "setup", index: 0, exit_code: 7 }, failed_at: null }, t);
+ const text = diagnosticFailure({ code: "environment_provisioning_failed", params: { step: "setup", index: 0, exit_code: 7, category: null }, failed_at: null }, t);
expect(text).toContain("Setup command 1");
expect(text).toContain("Exit code 7");
expect(diagnosticFailure({ code: "harness_error", params: {}, failed_at: null }, t)).not.toContain("timed out");
diff --git a/apps/web/src/features/sessions/session-diagnostics.tsx b/apps/web/src/features/sessions/session-diagnostics.tsx
index c2bc01e63..ebea896f7 100644
--- a/apps/web/src/features/sessions/session-diagnostics.tsx
+++ b/apps/web/src/features/sessions/session-diagnostics.tsx
@@ -4,6 +4,7 @@ import { createContext, useContext } from "react";
import { useTranslation } from "react-i18next";
import { HelpTip } from "../../components/console-ui";
+import { formatDuration } from "../../lib/format";
import { diagnosticFailure, diagnosticNext, diagnosticReason } from "../../lib/diagnostic-failure";
import { admin } from "../../lib/projects";
import { sessionKey } from "./session-queries";
@@ -39,6 +40,17 @@ export function DiagnosticsUnavailable({ pending, retry }: { pending: boolean; r
;
}
+/** The facts row of the operations the Environment's initialization ran, in order. */
+export function InitializationSteps({ projectId, session }: { projectId: string; session: AgentSession }) {
+ const { t } = useTranslation("diagnostics");
+ const query = useQuery({ ...sessionDiagnosticsQuery(projectId, session), enabled: true });
+ const steps = query.isError ? [] : query.data?.initialization_steps ?? [];
+ if (!steps.length) return null;
+ return
{t("initializationTitle")}
+ {steps.map((step, index) => {t(`step.${step.step}`)}{step.index === null ? "" : ` ${step.index + 1}`}: {formatDuration(step.duration_ms / 1000)})}
+ ;
+}
+
function useSessionFailure(projectId: string, session: AgentSession) {
const query = useQuery(sessionDiagnosticsQuery(projectId, session));
return { query, failure: !query.isError && query.data?.status === session.status ? query.data.failure : null };
diff --git a/apps/web/src/i18n/locales/en/diagnostics.ts b/apps/web/src/i18n/locales/en/diagnostics.ts
index 32cdfe5b5..ece268160 100644
--- a/apps/web/src/i18n/locales/en/diagnostics.ts
+++ b/apps/web/src/i18n/locales/en/diagnostics.ts
@@ -61,7 +61,18 @@ export const diagnostics = {
exitCode: "Exit code {{code}}.",
httpStatus: "HTTP {{status}}.",
unreachableHost: "Then check that the URL's host resolves and is reachable from the Session's sandbox, or from the host of a self-hosted Session.",
- step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation", harness: "Harness preparation" },
+ step: { setup: "setup", python: "Python dependencies", npm: "npm dependencies", file: "file preparation", skill: "Skill preparation", harness: "Harness preparation", plugin: "Plugin preparation", configure: "environment configuration", finalize: "finalization" },
+ category: {
+ timeout: "The preparation timed out.",
+ runtime_disconnected: "The Runtime disconnected.",
+ unconfirmed: "The Runtime did not confirm the result.",
+ rejected: "The Runtime rejected the operation.",
+ operation_failed: "The Runtime reported a failed operation.",
+ core_error: "Core could not read the stored configuration.",
+ core_restart: "Core restarted during preparation.",
+ compute_released: "The sandbox ended before preparation completed.",
+ },
+ initializationTitle: "Initialization",
unavailable: "Failure details unavailable",
unavailableHelp: "The diagnostics read failed or no longer matches this status. Refresh to check again.",
checking: "Checking failure details",
diff --git a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
index 8df62ea55..68cc7f01c 100644
--- a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
+++ b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
@@ -61,7 +61,18 @@ export const diagnostics = {
exitCode: "退出码 {{code}}。",
httpStatus: "HTTP {{status}}。",
unreachableHost: "再确认 Session 的沙箱(自托管 Session 则为其主机)能解析并访问该 URL 的主机。",
- step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备", harness: "Harness 准备" },
+ step: { setup: "设置", python: "Python 依赖", npm: "npm 依赖", file: "文件准备", skill: "Skill 准备", harness: "Harness 准备", plugin: "Plugin 准备", configure: "环境配置", finalize: "收尾" },
+ category: {
+ timeout: "准备超时。",
+ runtime_disconnected: "Runtime 已断开连接。",
+ unconfirmed: "Runtime 未确认结果。",
+ rejected: "Runtime 拒绝了该操作。",
+ operation_failed: "Runtime 报告操作失败。",
+ core_error: "Core 无法读取已存储的配置。",
+ core_restart: "准备期间 Core 重启。",
+ compute_released: "准备完成前沙箱已结束。",
+ },
+ initializationTitle: "初始化",
unavailable: "失败详情不可用",
unavailableHelp: "诊断读取失败或与当前状态不一致,请刷新后重试。",
checking: "正在读取失败详情",
diff --git a/apps/web/src/lib/diagnostic-failure.ts b/apps/web/src/lib/diagnostic-failure.ts
index 3df058ab7..336c164e6 100644
--- a/apps/web/src/lib/diagnostic-failure.ts
+++ b/apps/web/src/lib/diagnostic-failure.ts
@@ -15,6 +15,9 @@ export function diagnosticReason(failure: DiagnosticFailure, t: TFunction<"diagn
if (failure.params.index !== null) message += gap + t("setupIndex", { number: failure.params.index + 1 });
if (failure.params.exit_code !== null) message += gap + t("exitCode", { code: failure.params.exit_code });
}
+ if (failure.code === "environment_provisioning_failed" && "category" in failure.params && failure.params.category) {
+ message += gap + t(`category.${failure.params.category}`);
+ }
const status = connectionStatus(failure);
if (status !== null) message += gap + t("httpStatus", { status });
return message;
diff --git a/contracts/agents-api/core-errors.md b/contracts/agents-api/core-errors.md
index 430cefc5a..297a2a711 100644
--- a/contracts/agents-api/core-errors.md
+++ b/contracts/agents-api/core-errors.md
@@ -134,7 +134,7 @@ The [Session diagnostics](./session-diagnostics.md) routes return these categori
| `internal_error` | Unknown or malformed outcome; no raw value is returned |
| `environment_connection_timeout` | Initial input connection deadline expired |
| `environment_unavailable` | Environment unavailable for initial input |
-| `environment_provisioning_failed` | Hosted provisioning failure; params contain nullable `step`, `index`, `exit_code` from a sanitized receipt |
+| `environment_provisioning_failed` | Hosted provisioning failure; params contain nullable `step`, `index`, `exit_code` from a sanitized receipt, and `category` for a failure without a confirmed step |
A database failure is an error, never an empty or healthy snapshot. Provisioning reasons and native messages are never parsed for categories or parameters.
diff --git a/contracts/agents-api/core.openapi.yaml b/contracts/agents-api/core.openapi.yaml
index a0448560b..7c1457196 100644
--- a/contracts/agents-api/core.openapi.yaml
+++ b/contracts/agents-api/core.openapi.yaml
@@ -490,6 +490,29 @@ definitions:
- object
- updated_at
type: object
+ api.InitializationStepTiming:
+ properties:
+ duration_ms:
+ type: integer
+ index:
+ type: integer
+ x-nullable: true
+ step:
+ enum:
+ - file
+ - skill
+ - plugin
+ - configure
+ - npm
+ - python
+ - setup
+ - finalize
+ type: string
+ required:
+ - duration_ms
+ - index
+ - step
+ type: object
api.Installation:
properties:
address_bindings:
@@ -855,6 +878,11 @@ definitions:
allOf:
- $ref: '#/definitions/api.SessionDiagnosticFailure'
x-nullable: true
+ initialization_steps:
+ description: InitializationSteps lists the operations the Environment's initialization ran, in order.
+ items:
+ $ref: '#/definitions/api.InitializationStepTiming'
+ type: array
object:
enum:
- core.session_diagnostics
@@ -870,6 +898,7 @@ definitions:
type: string
required:
- failure
+ - initialization_steps
- object
- session_id
- status
diff --git a/contracts/agents-api/session-diagnostics.md b/contracts/agents-api/session-diagnostics.md
index a8b06aa21..4b8102edd 100644
--- a/contracts/agents-api/session-diagnostics.md
+++ b/contracts/agents-api/session-diagnostics.md
@@ -13,7 +13,7 @@ Missing, malformed, foreign and deleted resources follow the Session and Turn no
## Session snapshot
-The object is `core.session_diagnostics`, with `session_id`, the official Session `status`, and nullable `failure`. Each Session or Turn read uses one repeatable-read database snapshot and the public status projection, within a five-second budget that an earlier caller deadline shortens. The transaction is closed after the read, including on cancellation. `failure` is null unless the projection says `failed`. Its fields are:
+The object is `core.session_diagnostics`, with `session_id`, the official Session `status`, nullable `failure` and `initialization_steps`. Each Session or Turn read uses one repeatable-read database snapshot and the public status projection, within a five-second budget that an earlier caller deadline shortens. The transaction is closed after the read, including on cancellation. `failure` is null unless the projection says `failed`. Its fields are:
| Field | Value |
| --- | --- |
@@ -27,6 +27,21 @@ A hosted provisioning failure takes precedence over input activity, and input ac
Provisioning parameters come from the confirmed structured receipt, persisted in the failure transaction that settles the Environment and its input and records the events. `step` is `setup`, `python`, `npm`, `file`, `skill`, `harness` or null. `index` is a nonnegative JSON-safe integer for setup only, otherwise null. `exit_code` is 1 to 255 for script steps, otherwise null. Details that were not recorded remain null. These private fields do not change the public failure reason or SSE.
+`category` is set only when `step` is null, for a failure whose public reason is the generic `initialization did not complete`, and is otherwise null:
+
+| `category` | Cause |
+| --- | --- |
+| `timeout` | The initialization budget or a transfer budget expired |
+| `runtime_disconnected` | The Runtime connection closed, changed or was not registered |
+| `unconfirmed` | An operation's effect is unknown, or its receipt is missing or invalid |
+| `rejected` | The Runtime refused an operation before applying it |
+| `operation_failed` | The Runtime confirmed a failed operation that has no public step label, such as a Plugin or snapshot finalization |
+| `core_error` | Core could not read or validate the stored configuration |
+| `core_restart` | The initialization owner was lost, including across a Core restart |
+| `compute_released` | The Environment's compute ended before initialization completed |
+
+`initialization_steps` lists the operations the Environment's initialization ran, in order, from the same snapshot and for any Session status. Each entry has `step` (`file`, `skill`, `plugin`, `configure`, `npm`, `python`, `setup` or `finalize`), `index` (the setup command position for `setup`, otherwise null) and `duration_ms`, the Runtime-measured application time from the [Core–Runtime protocol](../../docs/runtime-protocol.md). Only operations whose result the Runtime reported with a `completed` or `failed` outcome appear, so a preparation-cache hit lists only `finalize`, and an unconfirmed operation is absent. Core also writes one `Environment initialization step` log line per entry with the Session, Environment, `step`, `index` and `duration_ms`. The list is empty before any operation ran.
+
## Turn snapshot and Item receipt timing
The object is `core.turn_diagnostics`, with `session_id`, `turn_id`, the official root Turn `status`, nullable `failure`, `items` and `items_truncated`. A Turn failure has `code`, `params` and nullable `failed_at`, without a source or Turn ID. Only failed Turns have failure details; cancelled and completed Turns never inherit a classification from a private outcome.
diff --git a/contracts/agents-api/zh/core-errors.md b/contracts/agents-api/zh/core-errors.md
index a09140a3e..63260327f 100644
--- a/contracts/agents-api/zh/core-errors.md
+++ b/contracts/agents-api/zh/core-errors.md
@@ -1,7 +1,7 @@
---
title: "Core 管理错误"
source: contracts/agents-api/core-errors.md
-source_hash: b2a6cd40a67bbe58fc78efa068a8ad0cf23cf62ad02e3c1456323fa66e9bb6ef
+source_hash: f2c2a927b8faad9c2834ae4d1195940195df5fc617c8395a027f0d21dfb5ea1e
---
`/core/v1` 上的错误使用此封装结构。`message` 是安全的英文文本;`code` 和 `param` 可以为 null。客户端依据稳定的 `code` 和可选的 `param` 进行处理,对未知代码显示 `message`,绝不解析消息,也绝不自动重试被拒绝的写操作。
@@ -136,7 +136,7 @@ Web 的控制台服务器在 `/core` 路径上发生自身故障时使用此封
| `internal_error` | 结果未知或格式错误;不返回原始值 |
| `environment_connection_timeout` | 初始输入连接截止时间已过 |
| `environment_unavailable` | 初始输入所需 Environment 不可用 |
-| `environment_provisioning_failed` | 托管预置失败;params 包含来自已清理回执的可空 `step`、`index`、`exit_code` |
+| `environment_provisioning_failed` | 托管预置失败;params 包含来自已清理回执的可空 `step`、`index`、`exit_code`,以及用于没有已确认步骤的失败的 `category` |
数据库故障属于错误,绝不会产生空快照或健康快照。绝不会解析预置原因或原生消息以确定类别或参数。
diff --git a/contracts/agents-api/zh/session-diagnostics.md b/contracts/agents-api/zh/session-diagnostics.md
index d81427fcc..c3a3e6760 100644
--- a/contracts/agents-api/zh/session-diagnostics.md
+++ b/contracts/agents-api/zh/session-diagnostics.md
@@ -1,7 +1,7 @@
---
title: "Session 诊断"
source: contracts/agents-api/session-diagnostics.md
-source_hash: fe7192fda8191ceca39aa19bed83f37f79b381638a84ca931b51e3d671e4251b
+source_hash: 2cb616097b200b1c2ed46c20fb28ed1b0e1cff4677e505ee418a2922042c8a03
---
管理员通过两个需要 Core 密钥的路由读取 Session 或 Turn 失败的原因,它们报告失败类别和 Item 接收时间。
@@ -15,7 +15,7 @@ source_hash: fe7192fda8191ceca39aa19bed83f37f79b381638a84ca931b51e3d671e4251b
## Session 快照 {#session-snapshot}
-对象为 `core.session_diagnostics`,包含 `session_id`、官方 Session `status` 和可空的 `failure`。每次 Session 或 Turn 读取使用一个可重复读数据库快照和公开状态投影,时间预算为五秒,调用方更早的截止时间会缩短该预算。读取后关闭事务,取消时也一样。除非投影为 `failed`,否则 `failure` 为 null。其字段为:
+对象为 `core.session_diagnostics`,包含 `session_id`、官方 Session `status`、可空的 `failure` 和 `initialization_steps`。每次 Session 或 Turn 读取使用一个可重复读数据库快照和公开状态投影,时间预算为五秒,调用方更早的截止时间会缩短该预算。读取后关闭事务,取消时也一样。除非投影为 `failed`,否则 `failure` 为 null。其字段为:
| 字段 | 值 |
| --- | --- |
@@ -29,6 +29,21 @@ source_hash: fe7192fda8191ceca39aa19bed83f37f79b381638a84ca931b51e3d671e4251b
配置参数来自已确认的结构化回执,在结算 Environment 及其输入并记录事件的失败事务中持久化。`step` 为 `setup`、`python`、`npm`、`file`、`skill`、`harness` 或 null。`index` 仅对 setup 为 JSON 安全的非负整数,其他情况为 null。`exit_code` 对脚本步骤为 1 至 255,其他情况为 null。未记录的详情保持 null。这些私有字段不会改变公开失败原因或 SSE。
+`category` 仅在 `step` 为 null 时设置,对应公开原因为通用的 `initialization did not complete` 的失败,其他情况为 null:
+
+| `category` | 原因 |
+| --- | --- |
+| `timeout` | 初始化预算或传输预算到期 |
+| `runtime_disconnected` | Runtime 连接关闭、变更或未注册 |
+| `unconfirmed` | 某个操作的效果未知,或其回执缺失或无效 |
+| `rejected` | Runtime 在应用前拒绝了某个操作 |
+| `operation_failed` | Runtime 确认某个没有公开步骤标签的操作失败,例如 Plugin 或快照终结 |
+| `core_error` | Core 无法读取或校验已存储的配置 |
+| `core_restart` | 初始化所有者丢失,包括跨 Core 重启 |
+| `compute_released` | Environment 的计算资源在初始化完成前已结束 |
+
+`initialization_steps` 按顺序列出该 Environment 的初始化运行过的操作,来自同一快照,适用于任何 Session 状态。每项包含 `step`(`file`、`skill`、`plugin`、`configure`、`npm`、`python`、`setup` 或 `finalize`)、`index`(`setup` 为设置命令位置,其他为 null)和 `duration_ms`,即 [Core–Runtime 协议](../../../docs/zh/runtime-protocol.md)中 Runtime 测得的应用时间。仅 Runtime 以 `completed` 或 `failed` 结果报告的操作会出现,因此命中准备缓存时只列出 `finalize`,未确认的操作不会出现。Core 还会为每项写一条 `Environment initialization step` 日志,包含 Session、Environment、`step`、`index` 和 `duration_ms`。在任何操作运行之前列表为空。
+
## Turn 快照与 Item 回执时间 {#turn-snapshot-and-item-receipt-timing}
对象为 `core.turn_diagnostics`,包含 `session_id`、`turn_id`、官方根 Turn `status`、可空的 `failure`、`items` 和 `items_truncated`。Turn 失败包含 `code`、`params` 和可空的 `failed_at`,不包含来源或 Turn ID。只有失败的 Turn 才有失败详情;已取消或已完成的 Turn 不会继承私有结果中的分类。
diff --git a/docs/runtime-protocol.md b/docs/runtime-protocol.md
index 387dcd763..435ee21a3 100644
--- a/docs/runtime-protocol.md
+++ b/docs/runtime-protocol.md
@@ -145,7 +145,7 @@ sequenceDiagram
R-->>C: runtime_prepare_result completed
```
-Initialization and finalization have typed headers without file data. Every `begin` carries `budget_ms`, the remaining Core-owned initialization budget; chunks and commits omit it. The Runtime enforces that relative budget from receipt of `begin`, and Core waits no longer than its original operation deadline. The `begin`–`commit` staging phase has its own limit; applying a committed operation and waiting for `completed` use the remaining initialization budget, not the staging limit. Expiry before application rejects the transfer; expiry during application reports `unknown` after local mutations stop and never authorizes replay. Validate the expected outcome, offset, size and finite error code with the shared validator. One transfer is allowed per connection. A chunk receipt confirms staged bytes, not installation; a completed commit confirms that operation, not that a later Turn ran.
+Initialization and finalization have typed headers without file data. Every `begin` carries `budget_ms`, the remaining Core-owned initialization budget; chunks and commits omit it. The Runtime enforces that relative budget from receipt of `begin`, and Core waits no longer than its original operation deadline. The `begin`–`commit` staging phase has its own limit; applying a committed operation and waiting for `completed` use the remaining initialization budget, not the staging limit. Expiry before application rejects the transfer; expiry during application reports `unknown` after local mutations stop and never authorizes replay. A terminal `completed`, `failed` or `unknown` result of an operation the Runtime applied carries `duration_ms`, the Runtime-measured time from the start of application to the outcome; no other result carries it, and it adds no command text, path or output. Validate the expected outcome, offset, size, duration and finite error code with the shared validator. One transfer is allowed per connection. A chunk receipt confirms staged bytes, not installation; a completed commit confirms that operation, not that a later Turn ran.
An execution Turn runs as follows. Core subscribes to preparation status before it sends `execution_prepare` and to the Run before it sends `execution_start`, because output can race the status.
diff --git a/docs/zh/runtime-protocol.md b/docs/zh/runtime-protocol.md
index 5cbe4e530..2f32ea541 100644
--- a/docs/zh/runtime-protocol.md
+++ b/docs/zh/runtime-protocol.md
@@ -1,7 +1,7 @@
---
title: "Core–Runtime 协议"
source: docs/runtime-protocol.md
-source_hash: 986ab6e6ef3979a672f2e49f7c75346e7bfff327d9de1a6138e7f5c75b55d720
+source_hash: d0180316782a49812b3922ed544e2338d66cb735e496c5d08ccac47e1e2b4024
---
Core–Runtime 协议连接 Core 与 Runtime daemon,定义 daemon 连接上消息的含义和顺序。托管和自托管 Runtime 使用同一协议。
@@ -147,7 +147,7 @@ sequenceDiagram
R-->>C: runtime_prepare_result completed
```
-初始化和终结阶段使用不含文件数据的类型化 header。每个 `begin` 携带 `budget_ms`,表示 Core 所拥有的初始化预算剩余毫秒数;chunk 和 commit 不携带该字段。Runtime 从收到 `begin` 起执行此相对预算,Core 的等待不超过原操作期限。`begin`–`commit` 暂存阶段另有独立限制;应用已提交操作及等待 `completed` 使用剩余初始化预算,不受暂存限制约束。应用前到期会拒绝传输;应用中到期则在本地变更停止后报告 `unknown`,绝不授权重放。使用共享 validator 验证预期结果、offset、size 和有限错误 code。每条连接允许一个 transfer。chunk 回执确认暂存字节,不确认安装;完成的 commit 确认该操作,不证明后续 Turn 已运行。
+初始化和终结阶段使用不含文件数据的类型化 header。每个 `begin` 携带 `budget_ms`,表示 Core 所拥有的初始化预算剩余毫秒数;chunk 和 commit 不携带该字段。Runtime 从收到 `begin` 起执行此相对预算,Core 的等待不超过原操作期限。`begin`–`commit` 暂存阶段另有独立限制;应用已提交操作及等待 `completed` 使用剩余初始化预算,不受暂存限制约束。应用前到期会拒绝传输;应用中到期则在本地变更停止后报告 `unknown`,绝不授权重放。Runtime 已应用的操作,其终态 `completed`、`failed` 或 `unknown` 结果携带 `duration_ms`,即 Runtime 测得的从开始应用到得出结果的时间;其他结果不携带,它也不增加命令文本、路径或输出。使用共享 validator 验证预期结果、offset、size、duration 和有限错误 code。每条连接允许一个 transfer。chunk 回执确认暂存字节,不确认安装;完成的 commit 确认该操作,不证明后续 Turn 已运行。
执行 Turn 流程如下。Core 在发送 `execution_prepare` 之前订阅 preparation status,在发送 `execution_start` 之前订阅 Run,因为输出可能与 status 竞争。
diff --git a/internal/agentdaemon/proto/runtime_prepare.go b/internal/agentdaemon/proto/runtime_prepare.go
index ea388f47a..ad710190f 100644
--- a/internal/agentdaemon/proto/runtime_prepare.go
+++ b/internal/agentdaemon/proto/runtime_prepare.go
@@ -61,6 +61,9 @@ type RuntimePrepareResultPayload struct {
Offset int `json:"offset,omitempty"`
SizeBytes int `json:"size_bytes,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
+ // DurationMS is the Runtime-measured time spent applying the operation. It is
+ // reported only by a terminal result of an operation that was applied.
+ DurationMS int64 `json:"duration_ms,omitempty"`
}
func ValidRuntimePrepareRequest(p RuntimePreparePayload) bool {
@@ -144,6 +147,9 @@ func ValidRuntimePrepareResult(r RuntimePrepareResultPayload, expected string, o
if r.ExitCode < 0 || r.ExitCode > 255 || (r.Outcome != "failed" && r.ExitCode != 0) {
return false
}
+ if r.DurationMS < 0 || r.DurationMS > RuntimePrepareMaxBudgetMS || (r.DurationMS != 0 && r.Outcome != "completed" && r.Outcome != "failed" && r.Outcome != "unknown") {
+ return false
+ }
switch r.Outcome {
case "rejected":
if r.Offset != 0 || r.SizeBytes != 0 {
diff --git a/internal/agentdaemon/proto/runtime_prepare_test.go b/internal/agentdaemon/proto/runtime_prepare_test.go
index aa0f37b84..1e43d6ebd 100644
--- a/internal/agentdaemon/proto/runtime_prepare_test.go
+++ b/internal/agentdaemon/proto/runtime_prepare_test.go
@@ -283,3 +283,19 @@ func TestRuntimePreparationBudgetOnlyOnBegin(t *testing.T) {
}
}
}
+
+func TestRuntimePrepareResultDuration(t *testing.T) {
+ for _, tc := range []struct {
+ result RuntimePrepareResultPayload
+ valid bool
+ }{
+ {RuntimePrepareResultPayload{Outcome: "completed", DurationMS: 1500}, true},
+ {RuntimePrepareResultPayload{Outcome: "completed", DurationMS: -1}, false},
+ {RuntimePrepareResultPayload{Outcome: "completed", DurationMS: RuntimePrepareMaxBudgetMS + 1}, false},
+ {RuntimePrepareResultPayload{Outcome: "rejected", ErrorCode: "invalid_request", DurationMS: 1}, false},
+ } {
+ if got := ValidRuntimePrepareResult(tc.result, "completed", 0, 0); got != tc.valid {
+ t.Fatalf("%+v: valid=%v", tc.result, got)
+ }
+ }
+}
diff --git a/packages/agents-client/src/generated/core-api.ts b/packages/agents-client/src/generated/core-api.ts
index be0433809..4dae6fc7c 100644
--- a/packages/agents-client/src/generated/core-api.ts
+++ b/packages/agents-client/src/generated/core-api.ts
@@ -339,6 +339,14 @@ export interface HostHistoryPoint {
start: string;
}
export const hostHistoryPointFields = ["available_disk_bytes_min", "cpu_utilization_max", "memory_used_bytes_max", "start"] as const;
+export interface InitializationStepTiming {
+ duration_ms: number;
+ index: number | null;
+ step: InitializationStepTimingStep;
+}
+export const initializationStepTimingFields = ["duration_ms", "index", "step"] as const;
+export const initializationStepTimingStepValues = ["file", "skill", "plugin", "configure", "npm", "python", "setup", "finalize"] as const;
+export type InitializationStepTimingStep = (typeof initializationStepTimingStepValues)[number];
export interface Installation {
address_bindings: AddressBindings;
api_base_url: string;
@@ -885,11 +893,12 @@ export const sessionDiagnosticFailureCodeValues = ["harness_error", "model_provi
export type SessionDiagnosticFailureCode = (typeof sessionDiagnosticFailureCodeValues)[number];
export interface SessionDiagnostics {
failure: SessionDiagnosticFailure | null;
+ initialization_steps: InitializationStepTiming[];
object: "core.session_diagnostics";
session_id: string;
status: SessionDiagnosticsStatus;
}
-export const sessionDiagnosticsFields = ["failure", "object", "session_id", "status"] as const;
+export const sessionDiagnosticsFields = ["failure", "initialization_steps", "object", "session_id", "status"] as const;
export const sessionDiagnosticsStatusValues = ["idle", "in_progress", "requires_action", "failed"] as const;
export type SessionDiagnosticsStatus = (typeof sessionDiagnosticsStatusValues)[number];
export interface SessionExecutionConfiguration {
diff --git a/packages/agents-client/src/session-diagnostics.test.ts b/packages/agents-client/src/session-diagnostics.test.ts
index 1f6d32f6f..497368bf0 100644
--- a/packages/agents-client/src/session-diagnostics.test.ts
+++ b/packages/agents-client/src/session-diagnostics.test.ts
@@ -7,7 +7,7 @@ const turn = "729dab72-a9ea-4d0a-a38c-1b45e46233a9";
const item = "bf8d4f3a-d8bd-4d59-ad37-15f186f90832";
const time = "2026-09-28T03:00:00.123456Z";
const failure = { code: "runtime_disconnected", params: {}, failed_at: time };
-const session = { object: "core.session_diagnostics", session_id: id, status: "failed", failure: { ...failure, source: "turn", turn_id: turn } };
+const session = { object: "core.session_diagnostics", session_id: id, status: "failed", failure: { ...failure, source: "turn", turn_id: turn }, initialization_steps: [{ step: "npm", index: null, duration_ms: 4100 }, { step: "setup", index: 1, duration_ms: 5200 }] };
const snapshot = { object: "core.turn_diagnostics", session_id: id, turn_id: turn, status: "failed", failure, items: [{ item_id: item, started_at: time, completed_at: null, observed_duration_ms: null }], items_truncated: false };
it("retrieves Core diagnostics with credential, scope and cancellation", async () => {
@@ -25,13 +25,13 @@ it("retrieves Core diagnostics with credential, scope and cancellation", async (
});
it("preserves historical nulls and finite safe provisioning parameters", () => {
- const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "setup", index: 2, exit_code: 7 }, failed_at: null } };
+ const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "setup", index: 2, exit_code: 7, category: null }, failed_at: null } };
expect(projectSessionDiagnostics(value, id)).toEqual(value);
expect(projectTurnDiagnostics(snapshot, id.toUpperCase(), turn.toUpperCase())).toEqual(snapshot);
});
it("accepts the Harness step Core records when the selected Harness is unavailable", () => {
- const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "harness", index: null, exit_code: null }, failed_at: null } };
+ const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: "harness", index: null, exit_code: null, category: null }, failed_at: null } };
expect(projectSessionDiagnostics(value, id)).toEqual(value);
});
@@ -53,12 +53,20 @@ it.each([
expect(() => projectTurnDiagnostics(value, id, turn)).not.toThrow(/secret-canary/u);
});
+it("preserves a failure category and rejects malformed initialization steps", () => {
+ const categorized = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params: { step: null, index: null, exit_code: null, category: "timeout" }, failed_at: null } };
+ expect(projectSessionDiagnostics(categorized, id)).toEqual(categorized);
+ expect(() => projectSessionDiagnostics({ ...categorized, failure: { ...categorized.failure, params: { ...categorized.failure.params, category: "secret-canary" } } }, id)).toThrow();
+ expect(() => projectSessionDiagnostics({ ...session, initialization_steps: [{ step: "setup", index: null, duration_ms: 1 }] }, id)).toThrow();
+ expect(() => projectSessionDiagnostics({ ...session, initialization_steps: [{ step: "npm", index: null, duration_ms: -1 }] }, id)).toThrow();
+});
+
it.each([
- { step: "secret-canary", index: null, exit_code: null },
- { step: "python", index: 3, exit_code: 2 },
- { step: "setup", index: Number.MAX_SAFE_INTEGER + 1, exit_code: 1 },
- { step: "file", index: null, exit_code: 1 },
- { step: "setup", index: 0, exit_code: 256 },
+ { step: "secret-canary", index: null, exit_code: null, category: null },
+ { step: "python", index: 3, exit_code: 2, category: null },
+ { step: "setup", index: Number.MAX_SAFE_INTEGER + 1, exit_code: 1, category: null },
+ { step: "file", index: null, exit_code: 1, category: null },
+ { step: "setup", index: 0, exit_code: 256, category: null },
])("rejects unsafe provisioning parameters", (params) => {
const value = { ...session, failure: { source: "environment", code: "environment_provisioning_failed", params, failed_at: time } };
expect(() => projectSessionDiagnostics(value, id)).toThrow();
@@ -82,7 +90,7 @@ it("rejects HTTP status parameters on nonconnection categories", () => {
expect(() => projectTurnDiagnostics({ ...snapshot, failure: { ...failure, code: "authentication_error", params: { http_status: 401 } } }, id, turn)).toThrow();
});
-const provisioning = { source: "environment", code: "environment_provisioning_failed", params: { step: "setup", index: null, exit_code: null }, failed_at: null };
+const provisioning = { source: "environment", code: "environment_provisioning_failed", params: { step: "setup", index: null, exit_code: null, category: null }, failed_at: null };
it.each([
["Session status", () => projectSessionDiagnostics({ ...session, status: ["failed"], failure: null }, id)],
["Turn status", () => projectTurnDiagnostics({ ...snapshot, status: ["failed"], failure: null }, id, turn)],
diff --git a/packages/agents-client/src/session-diagnostics.ts b/packages/agents-client/src/session-diagnostics.ts
index a3c470b7b..e413e0ee2 100644
--- a/packages/agents-client/src/session-diagnostics.ts
+++ b/packages/agents-client/src/session-diagnostics.ts
@@ -2,17 +2,18 @@ import { canonicalUuid, exactFields, isOneOf, isRecord, sameResourceId } from ".
import { invalidAdminResponse } from "./admin-projection";
import { turnStatusResourceValues } from "./generated/public-api";
import {
- diagnosticFailureFields, itemDiagnosticTimingFields, sessionDiagnosticFailureFields, sessionDiagnosticFailureRequired,
+ diagnosticFailureFields, initializationStepTimingFields, initializationStepTimingStepValues, itemDiagnosticTimingFields, sessionDiagnosticFailureFields, sessionDiagnosticFailureRequired,
diagnosticSourceValues, sessionDiagnosticsFields, sessionDiagnosticsStatusValues, turnDiagnosticsFields,
- type DiagnosticFailure as DiagnosticFailureResource, type DiagnosticFailureCode, type ItemDiagnosticTiming,
+ type DiagnosticFailure as DiagnosticFailureResource, type DiagnosticFailureCode, type InitializationStepTiming, type ItemDiagnosticTiming,
type SessionDiagnosticFailure as SessionDiagnosticFailureResource, type SessionDiagnostics as SessionDiagnosticsResource,
type TurnDiagnostics as TurnDiagnosticsResource,
} from "./generated/core-api";
// ItemDiagnosticTiming holds Core receipt intervals, distinct from the public tool-reported duration_ms.
-export type { DiagnosticFailureCode, ItemDiagnosticTiming };
+export type { DiagnosticFailureCode, InitializationStepTiming, ItemDiagnosticTiming };
// The schema types params as a free-form object; each code that carries params has one of these shapes.
-export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null };
+export const provisioningCategoryValues = ["timeout", "runtime_disconnected", "unconfirmed", "rejected", "operation_failed", "core_error", "core_restart", "compute_released"] as const;
+export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null; category: (typeof provisioningCategoryValues)[number] | null };
export type ConnectionFailureParams = { http_status: number | null };
export type DiagnosticFailure = Omit
& { params: Record | ProvisioningFailureParams | ConnectionFailureParams };
export type SessionDiagnosticFailure = DiagnosticFailure & Pick;
@@ -33,10 +34,10 @@ function failure(value: unknown, codes: readonly DiagnosticFailureCode[], sessio
let params: DiagnosticFailure["params"] = {};
if (value.code === "environment_provisioning_failed") {
const p = value.params;
- if (!exactFields(p, fields("step", "index", "exit_code")) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill", "harness"].includes(p.step))) ||
+ if (!exactFields(p, fields("step", "index", "exit_code", "category")) || (p.category !== null && (!isOneOf(provisioningCategoryValues, p.category) || p.step !== null)) || (p.step !== null && (typeof p.step !== "string" || !["setup", "python", "npm", "file", "skill", "harness"].includes(p.step))) ||
(p.index !== null && (!Number.isSafeInteger(p.index) || (p.index as number) < 0 || p.step !== "setup")) ||
(p.exit_code !== null && (!Number.isSafeInteger(p.exit_code) || (p.exit_code as number) < 1 || (p.exit_code as number) > 255 || !["setup", "python", "npm"].includes(p.step ?? "")))) return invalidAdminResponse();
- params = { step: p.step as ProvisioningFailureParams["step"], index: p.index as number | null, exit_code: p.exit_code as number | null };
+ params = { step: p.step as ProvisioningFailureParams["step"], index: p.index as number | null, exit_code: p.exit_code as number | null, category: p.category as ProvisioningFailureParams["category"] };
} else if (value.code === "connection_failed") {
const p = value.params;
if (!exactFields(p, fields("http_status")) || (p.http_status !== null && (!Number.isSafeInteger(p.http_status) || (p.http_status as number) < 100 || (p.http_status as number) > 599))) return invalidAdminResponse();
@@ -59,7 +60,14 @@ export function projectSessionDiagnostics(value: unknown, sessionId: string): Se
projected.turn_id = f.turn_id as string;
}
} else if (value.failure !== null) return invalidAdminResponse();
- return { object: "core.session_diagnostics", session_id: value.session_id as string, status: value.status, failure: projected };
+ if (!Array.isArray(value.initialization_steps) || value.initialization_steps.length > 200) return invalidAdminResponse();
+ const initialization_steps: InitializationStepTiming[] = value.initialization_steps.map((step: unknown) => {
+ if (!isRecord(step) || !exactFields(step, initializationStepTimingFields) || !isOneOf(initializationStepTimingStepValues, step.step) ||
+ !Number.isSafeInteger(step.duration_ms) || (step.duration_ms as number) < 0 ||
+ (step.step === "setup" ? !Number.isSafeInteger(step.index) || (step.index as number) < 0 : step.index !== null)) return invalidAdminResponse();
+ return { step: step.step, index: step.index as number | null, duration_ms: step.duration_ms as number };
+ });
+ return { object: "core.session_diagnostics", session_id: value.session_id as string, status: value.status, failure: projected, initialization_steps };
}
export function projectTurnDiagnostics(value: unknown, sessionId: string, turnId: string): TurnDiagnostics {
diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go
index dd5ad70d3..83936ef5f 100644
--- a/services/core/internal/api/session_diagnostics.go
+++ b/services/core/internal/api/session_diagnostics.go
@@ -30,11 +30,20 @@ type SessionDiagnosticFailure struct {
TurnID string `json:"turn_id,omitempty"`
}
+// InitializationStepTiming is the Runtime-measured duration of one Environment initialization operation that ran.
+type InitializationStepTiming struct {
+ Step string `json:"step" enums:"file,skill,plugin,configure,npm,python,setup,finalize" binding:"required"`
+ Index *int `json:"index" extensions:"x-nullable" binding:"required"`
+ DurationMS int64 `json:"duration_ms" binding:"required"`
+}
+
type SessionDiagnostics struct {
Object string `json:"object" enums:"core.session_diagnostics" binding:"required"`
SessionID string `json:"session_id" binding:"required"`
Status string `json:"status" enums:"idle,in_progress,requires_action,failed" binding:"required"`
Failure *SessionDiagnosticFailure `json:"failure" extensions:"x-nullable" binding:"required"`
+ // InitializationSteps lists the operations the Environment's initialization ran, in order.
+ InitializationSteps []InitializationStepTiming `json:"initialization_steps" binding:"required"`
}
type ItemDiagnosticTiming struct {
@@ -85,7 +94,10 @@ func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request)
writeSessionsError(w, r, err)
return
}
- response := SessionDiagnostics{Object: "core.session_diagnostics", SessionID: public.ID, Status: public.Status}
+ response := SessionDiagnostics{Object: "core.session_diagnostics", SessionID: public.ID, Status: public.Status, InitializationSteps: []InitializationStepTiming{}}
+ for _, step := range session.EnvironmentInitializationSteps {
+ response.InitializationSteps = append(response.InitializationSteps, InitializationStepTiming{Step: step.Step, Index: step.Index, DurationMS: step.DurationMS})
+ }
if public.Status == "failed" {
failure := SessionDiagnosticFailure{DiagnosticFailure: DiagnosticFailure{Code: "internal_error", Params: CoreErrorDetails{}}}
switch {
@@ -162,7 +174,7 @@ func diagnosticTime(value time.Time) *time.Time {
}
func provisioningFailureParams(detail *sessions.ProvisioningFailureDetail) CoreErrorDetails {
- params := CoreErrorDetails{"step": CoreErrorNull(), "index": CoreErrorNull(), "exit_code": CoreErrorNull()}
+ params := CoreErrorDetails{"step": CoreErrorNull(), "index": CoreErrorNull(), "exit_code": CoreErrorNull(), "category": CoreErrorNull()}
if detail == nil {
return params
}
@@ -172,6 +184,9 @@ func provisioningFailureParams(detail *sessions.ProvisioningFailureDetail) CoreE
if detail.Index != nil {
params["index"] = CoreErrorNumber(float64(*detail.Index))
}
+ if detail.Category != nil {
+ params["category"] = CoreErrorString(*detail.Category)
+ }
if detail.ExitCode != nil {
params["exit_code"] = CoreErrorNumber(float64(*detail.ExitCode))
}
diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go
index becdf229e..5447fd04d 100644
--- a/services/core/internal/api/session_diagnostics_test.go
+++ b/services/core/internal/api/session_diagnostics_test.go
@@ -107,19 +107,22 @@ func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T
session := hostedFailureSession()
session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()}
session.LastTurn = &sessions.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)}
- step, index, exit := "setup", 2, 7
- for _, detail := range []*sessions.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}} {
+ step, index, exit, category := "setup", 2, 7, "timeout"
+ session.EnvironmentInitializationSteps = []sessions.InitializationStep{{Step: "npm", DurationMS: 4100}, {Step: "setup", Index: &index, DurationMS: 5200}}
+ for _, detail := range []*sessions.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}, {Category: &category}} {
session.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail}
h, _, _ := adminTestHandler(t, serveDiagnostics(diagnosticSnapshotStore{session: session}))
w := diagnosticRequest(h, adminSessionsPath+session.ID+"/diagnostics", "Bearer admin")
if w.Code != 200 || strings.Contains(w.Body.String(), "canary") || !strings.Contains(w.Body.String(), `"code":"environment_provisioning_failed"`) || !strings.Contains(w.Body.String(), `"source":"environment"`) {
t.Fatal(w.Code, w.Body)
}
- want := `"params":{"exit_code":null,"index":null,"step":null}`
- if detail != nil {
- want = `"params":{"exit_code":7,"index":2,"step":"setup"}`
+ want := `"params":{"category":null,"exit_code":null,"index":null,"step":null}`
+ if detail != nil && detail.Step != nil {
+ want = `"params":{"category":null,"exit_code":7,"index":2,"step":"setup"}`
+ } else if detail != nil {
+ want = `"params":{"category":"timeout","exit_code":null,"index":null,"step":null}`
}
- if !strings.Contains(w.Body.String(), want) || !strings.Contains(w.Body.String(), `"failed_at":null`) {
+ if !strings.Contains(w.Body.String(), `"initialization_steps":[{"step":"npm","index":null,"duration_ms":4100},{"step":"setup","index":2,"duration_ms":5200}]`) || !strings.Contains(w.Body.String(), want) || !strings.Contains(w.Body.String(), `"failed_at":null`) {
t.Fatal("historical reason parsed or time invented", w.Body)
}
}
diff --git a/services/core/internal/db/queries/environment_initialization.sql b/services/core/internal/db/queries/environment_initialization.sql
index d1452e781..f3d671337 100644
--- a/services/core/internal/db/queries/environment_initialization.sql
+++ b/services/core/internal/db/queries/environment_initialization.sql
@@ -14,5 +14,9 @@ WHERE id = $1 AND initialization = 'pending' AND status NOT IN ('failed', 'expir
UPDATE environments SET initialization = 'complete'
WHERE id = $1 AND initialization = 'running' AND status NOT IN ('failed', 'expired');
+-- name: RecordEnvironmentInitializationSteps :exec
+UPDATE environments SET initialization_steps = sqlc.arg(steps)
+WHERE id = sqlc.arg(id) AND initialization = 'running';
+
-- name: FailEnvironmentInitialization :exec
UPDATE environments SET initialization = 'failed' WHERE id = $1 AND initialization <> 'complete';
diff --git a/services/core/internal/db/sqlc/environment_initialization.sql.go b/services/core/internal/db/sqlc/environment_initialization.sql.go
index 102d4837d..a3c8c5bbf 100644
--- a/services/core/internal/db/sqlc/environment_initialization.sql.go
+++ b/services/core/internal/db/sqlc/environment_initialization.sql.go
@@ -90,3 +90,18 @@ func (q *Queries) ListEnvironmentInitializations(ctx context.Context, id pgtype.
}
return items, nil
}
+
+const recordEnvironmentInitializationSteps = `-- name: RecordEnvironmentInitializationSteps :exec
+UPDATE environments SET initialization_steps = $1
+WHERE id = $2 AND initialization = 'running'
+`
+
+type RecordEnvironmentInitializationStepsParams struct {
+ Steps []byte `json:"steps"`
+ ID pgtype.UUID `json:"id"`
+}
+
+func (q *Queries) RecordEnvironmentInitializationSteps(ctx context.Context, arg RecordEnvironmentInitializationStepsParams) error {
+ _, err := q.db.Exec(ctx, recordEnvironmentInitializationSteps, arg.Steps, arg.ID)
+ return err
+}
diff --git a/services/core/internal/db/sqlc/environments.sql.go b/services/core/internal/db/sqlc/environments.sql.go
index 6a8097c94..514d21724 100644
--- a/services/core/internal/db/sqlc/environments.sql.go
+++ b/services/core/internal/db/sqlc/environments.sql.go
@@ -28,7 +28,7 @@ func (q *Queries) CreateEnvironment(ctx context.Context, arg CreateEnvironmentPa
}
const getEnvironment = `-- name: GetEnvironment :one
-SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration,
+SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration,
EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace
FROM environments e JOIN sessions s ON s.id = e.session_id
WHERE s.tenant_id = $1 AND e.id = $2 AND s.deleted_at IS NULL
@@ -58,6 +58,7 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams)
&i.Environment.FailedAt,
&i.Environment.FailureDetail,
&i.Environment.Initialization,
+ &i.Environment.InitializationSteps,
&i.TenantID,
&i.Configuration,
&i.ExternalWorkspace,
@@ -66,7 +67,7 @@ func (q *Queries) GetEnvironment(ctx context.Context, arg GetEnvironmentParams)
}
const getSessionEnvironment = `-- name: GetSessionEnvironment :one
-SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration,
+SELECT e.id, e.session_id, e.status, e.created_at, e.failure_reason, e.failed_at, e.failure_detail, e.initialization, e.initialization_steps, s.tenant_id, (s.configuration->'environment')::jsonb AS configuration,
EXISTS (SELECT 1 FROM environment_workspaces w WHERE w.environment_id = e.id)::boolean AS external_workspace
FROM environments e JOIN sessions s ON s.id = e.session_id
WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL
@@ -96,6 +97,7 @@ func (q *Queries) GetSessionEnvironment(ctx context.Context, arg GetSessionEnvir
&i.Environment.FailedAt,
&i.Environment.FailureDetail,
&i.Environment.Initialization,
+ &i.Environment.InitializationSteps,
&i.TenantID,
&i.Configuration,
&i.ExternalWorkspace,
diff --git a/services/core/internal/db/sqlc/models.go b/services/core/internal/db/sqlc/models.go
index 035c6b639..4d11656ca 100644
--- a/services/core/internal/db/sqlc/models.go
+++ b/services/core/internal/db/sqlc/models.go
@@ -68,14 +68,15 @@ type Device struct {
}
type Environment struct {
- ID pgtype.UUID `json:"id"`
- SessionID pgtype.UUID `json:"session_id"`
- Status string `json:"status"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- FailureReason pgtype.Text `json:"failure_reason"`
- FailedAt pgtype.Timestamptz `json:"failed_at"`
- FailureDetail []byte `json:"failure_detail"`
- Initialization string `json:"initialization"`
+ ID pgtype.UUID `json:"id"`
+ SessionID pgtype.UUID `json:"session_id"`
+ Status string `json:"status"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ FailureReason pgtype.Text `json:"failure_reason"`
+ FailedAt pgtype.Timestamptz `json:"failed_at"`
+ FailureDetail []byte `json:"failure_detail"`
+ Initialization string `json:"initialization"`
+ InitializationSteps []byte `json:"initialization_steps"`
}
type EnvironmentConnection struct {
diff --git a/services/core/internal/deployment/allocations.go b/services/core/internal/deployment/allocations.go
index e0ebb2cd4..b1aa86d77 100644
--- a/services/core/internal/deployment/allocations.go
+++ b/services/core/internal/deployment/allocations.go
@@ -223,7 +223,7 @@ func (e *ExecutionOperations) cleanup(ctx context.Context, owner Allocation, evi
return ErrAllocationConflict
}
if !retain {
- err = sessions.TerminateEnvironment(ctx, tx, revoked.Expired, sessions.ProvisioningFailureReason, nil)
+ err = sessions.TerminateEnvironment(ctx, tx, revoked.Expired, sessions.ProvisioningFailureReason, sessions.ProvisioningFailure{Category: sessions.ProvisioningComputeReleased}.Detail())
}
}
if err != nil {
diff --git a/services/core/internal/execution/runtime_capabilities_test.go b/services/core/internal/execution/runtime_capabilities_test.go
index 52d30fbd6..0c2b411bd 100644
--- a/services/core/internal/execution/runtime_capabilities_test.go
+++ b/services/core/internal/execution/runtime_capabilities_test.go
@@ -39,7 +39,7 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) {
owner := agentcapabilities.Identity{EnvironmentID: uuid.NewString(), SessionID: uuid.NewString()}
for _, op := range operations {
peer := &capabilityFixture{outcome: "completed"}
- if err := runRuntimeSetup(initializationTestContext(t), peer, owner, op); err != nil {
+ if _, err := runRuntimeSetup(initializationTestContext(t), peer, owner, op); err != nil {
t.Fatal(err)
}
if _, err := uuid.Parse(peer.requestID); err != nil {
@@ -63,14 +63,14 @@ func TestRuntimeCapabilitiesPreserveRawBundlesAndSetupOrdering(t *testing.T) {
func TestRuntimeCapabilitiesConfirmedAndUnknownFailures(t *testing.T) {
for _, outcome := range []string{"failed", "rejected", "unknown", "unexpected"} {
peer := &capabilityFixture{outcome: outcome}
- err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "finalize"}})
+ _, err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "finalize"}})
var confirmed *runtimeStepFailure
if err == nil || errors.As(err, &confirmed) != (outcome == "failed" || outcome == "rejected") {
t.Fatal(outcome, err)
}
}
peer := &capabilityFixture{outcome: "completed", err: errors.New(setupCanary)}
- err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{}})
+ _, err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{}})
if err == nil || bytes.Contains([]byte(err.Error()), []byte(setupCanary)) {
t.Fatal("transport error leaked or succeeded", err)
}
diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go
index 3b01cde3e..d73bf5187 100644
--- a/services/core/internal/execution/runtime_initialization.go
+++ b/services/core/internal/execution/runtime_initialization.go
@@ -50,7 +50,7 @@ func (w *Worker) runEnvironmentInitializations(ctx context.Context) error {
}
if owner.State == "running" {
// Lost process-local progress cannot prove which side effects ran.
- if err := w.dispatcher.sessionExecution.FailEnvironmentInitialization(ctx, owner, sessions.ProvisioningFailure{}); err != nil && !errors.Is(err, sessions.ErrNotFound) {
+ if err := w.dispatcher.sessionExecution.FailEnvironmentInitialization(ctx, owner, sessions.ProvisioningFailure{Category: sessions.ProvisioningCoreRestart}); err != nil && !errors.Is(err, sessions.ErrNotFound) {
return err
}
continue
@@ -96,7 +96,14 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir
operation, cancel := context.WithTimeout(ctx, time.Duration(proto.RuntimePrepareMaxBudgetMS)*time.Millisecond)
defer cancel()
failure := sessions.ProvisioningFailure{}
- err := w.prepareEnvironment(operation, owner, &failure)
+ steps := []sessions.InitializationStep{}
+ err := w.prepareEnvironment(operation, owner, &failure, &steps)
+ // Steps that ran stay recorded for a failed initialization as well.
+ record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer stop()
+ if recordErr := w.dispatcher.sessionExecution.RecordEnvironmentInitializationSteps(record, owner, steps); recordErr != nil && err == nil {
+ err = recordErr
+ }
if err == nil {
err = w.dispatcher.sessionExecution.CompleteEnvironmentInitialization(operation, owner)
if err == nil {
@@ -106,13 +113,14 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir
if err != nil {
log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID)
// A later scan settles an unrecorded failure; it never retries the setup.
- record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
- defer stop()
+ if failure.Step == "" && failure.Category == "" {
+ failure.Category = provisioningCategory(err)
+ }
_ = w.dispatcher.sessionExecution.FailEnvironmentInitialization(record, owner, failure)
}
}
-func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.EnvironmentInitialization, failure *sessions.ProvisioningFailure) error {
+func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.EnvironmentInitialization, failure *sessions.ProvisioningFailure, steps *[]sessions.InitializationStep) error {
environment, err := w.dispatcher.SessionsReader.GetEnvironment(ctx, owner.TenantID, owner.EnvironmentID)
if err != nil {
return err
@@ -150,21 +158,33 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm
var currentPeer = peer
currentPeer, err = w.dispatcher.authorizedPeer(ctx, owner.DeviceID)
if err == nil && currentPeer != peer {
- err = errors.New("Runtime connection changed during initialization")
+ err = errRuntimeConnectionChanged
}
}
candidate := sessions.ProvisioningFailure{Step: sessions.ProvisioningInitialFile}
+ var result proto.RuntimePrepareResultPayload
+ var recorded sessions.InitializationStep
if err == nil && index < len(cfg.Files) {
var metadata environmentconfig.InitialFileMetadata
var body []byte
metadata, body, err = w.dispatcher.SessionsReader.ReadInitialEnvironmentFile(ctx, owner.TenantID, owner.SessionID, index)
if err == nil {
- err = installInitialFile(ctx, peer, identity, metadata, body)
+ result, err = installInitialFile(ctx, peer, identity, metadata, body)
+ recorded, _ = runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "file"}}.step(result)
}
} else if err == nil {
command := operations[index-len(cfg.Files)]
candidate = command.provisioningFailure(0)
- err = runRuntimeSetup(ctx, peer, identity, command)
+ result, err = runRuntimeSetup(ctx, peer, identity, command)
+ recorded, _ = command.step(result)
+ }
+ if recorded.Step != "" {
+ *steps = append(*steps, recorded)
+ attrs := []any{"environment_id", owner.EnvironmentID, "session_id", owner.SessionID, "step", recorded.Step, "duration_ms", recorded.DurationMS}
+ if recorded.Index != nil {
+ attrs = append(attrs, "index", *recorded.Index)
+ }
+ log.With(attrs...).InfoContext(ctx, "Environment initialization step")
}
var confirmed *runtimeStepFailure
if errors.As(err, &confirmed) {
diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go
index bff49c3f7..000cff4a2 100644
--- a/services/core/internal/execution/runtime_setup.go
+++ b/services/core/internal/execution/runtime_setup.go
@@ -8,6 +8,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
"github.com/google/uuid"
)
@@ -20,10 +21,61 @@ type runtimeSetupOperation struct {
Index int
}
-type runtimeStepFailure struct{ exitCode int }
+type runtimeStepFailure struct {
+ exitCode int
+ rejected bool
+}
func (*runtimeStepFailure) Error() string { return "environment initialization operation failed" }
+// unconfirmedError reports an operation whose effect or receipt is unknown. It
+// keeps the cause for classification and never renders it.
+type unconfirmedError struct{ cause error }
+
+func (*unconfirmedError) Error() string { return "environment initialization operation unconfirmed" }
+func (e *unconfirmedError) Unwrap() error { return e.cause }
+
+var errRuntimeConnectionChanged = errors.New("Runtime connection changed during initialization")
+
+// stepLabel is the fixed label of the operation in step timing.
+func (operation runtimeSetupOperation) stepLabel() string {
+ if operation.Request.Initialization != nil {
+ return operation.Request.Initialization.Action
+ }
+ return operation.Request.Action
+}
+
+// step reports the Runtime-measured timing of an operation that ran.
+func (operation runtimeSetupOperation) step(result proto.RuntimePrepareResultPayload) (sessions.InitializationStep, bool) {
+ if result.Outcome != "completed" && result.Outcome != "failed" {
+ return sessions.InitializationStep{}, false
+ }
+ step := sessions.InitializationStep{Step: operation.stepLabel(), DurationMS: result.DurationMS}
+ if step.Step == "setup" {
+ step.Index = &operation.Index
+ }
+ return step, true
+}
+
+// provisioningCategory classifies a failure without a confirmed failed step.
+func provisioningCategory(err error) string {
+ var confirmed *runtimeStepFailure
+ var unconfirmed *unconfirmedError
+ switch {
+ case errors.Is(err, context.DeadlineExceeded):
+ return sessions.ProvisioningTimeout
+ case errors.Is(err, runtimegateway.ErrSessionClosed), errors.Is(err, runtimegateway.ErrDeviceNotRegistered), errors.Is(err, errRuntimeConnectionChanged):
+ return sessions.ProvisioningRuntimeDisconnected
+ case errors.As(err, &confirmed) && confirmed.rejected:
+ return sessions.ProvisioningRejected
+ case errors.As(err, &confirmed):
+ return sessions.ProvisioningOperationFailed
+ case errors.As(err, &unconfirmed):
+ return sessions.ProvisioningUnconfirmed
+ }
+ return sessions.ProvisioningCoreError
+}
+
func (operation runtimeSetupOperation) provisioningFailure(exitCode int) sessions.ProvisioningFailure {
action := operation.Request.Action
if operation.Request.Initialization != nil {
@@ -82,31 +134,31 @@ type runtimePreparer interface {
PrepareRuntime(context.Context, string, proto.RuntimePreparePayload, []byte) (proto.RuntimePrepareResultPayload, error)
}
-func runRuntimeSetup(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, operation runtimeSetupOperation) error {
+func runRuntimeSetup(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, operation runtimeSetupOperation) (proto.RuntimePrepareResultPayload, error) {
if peer == nil {
- return errors.New("environment initialization request unavailable")
+ return proto.RuntimePrepareResultPayload{}, errors.New("environment initialization request unavailable")
}
deadline, bounded := ctx.Deadline()
budget := time.Until(deadline).Milliseconds()
if !bounded || budget <= 0 || budget > proto.RuntimePrepareMaxBudgetMS || ctx.Err() != nil {
- return errors.New("environment initialization budget unavailable")
+ return proto.RuntimePrepareResultPayload{}, errors.New("environment initialization budget unavailable")
}
request := operation.Request
request.BudgetMS = budget
request.EnvironmentID, request.SessionID = identity.EnvironmentID, identity.SessionID
result, err := peer.PrepareRuntime(ctx, uuid.NewString(), request, operation.Data)
if err == nil && result.Outcome == "completed" {
- return nil
+ return result, nil
}
if err == nil && (result.Outcome == "rejected" || result.Outcome == "failed") {
- return &runtimeStepFailure{exitCode: result.ExitCode}
+ return result, &runtimeStepFailure{exitCode: result.ExitCode, rejected: result.Outcome == "rejected"}
}
- return errors.New("environment initialization operation unconfirmed")
+ return result, &unconfirmedError{cause: err}
}
-func installInitialFile(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, file environmentconfig.InitialFileMetadata, body []byte) error {
+func installInitialFile(ctx context.Context, peer runtimePreparer, identity agentcapabilities.Identity, file environmentconfig.InitialFileMetadata, body []byte) (proto.RuntimePrepareResultPayload, error) {
if file.SizeBytes == nil || *file.SizeBytes != int64(len(body)) || len(body) > environmentconfig.MaxInitialFileBytes {
- return errors.New("environment initialization request unavailable")
+ return proto.RuntimePrepareResultPayload{}, errors.New("environment initialization request unavailable")
}
return runRuntimeSetup(ctx, peer, identity, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "file", File: &proto.RuntimeInitialFile{Path: file.Path}}, Data: body})
}
diff --git a/services/core/internal/execution/runtime_setup_test.go b/services/core/internal/execution/runtime_setup_test.go
index 2bd9be1bb..d92deb743 100644
--- a/services/core/internal/execution/runtime_setup_test.go
+++ b/services/core/internal/execution/runtime_setup_test.go
@@ -10,6 +10,7 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/internal/agentcapabilities"
"github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig"
+ "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions"
)
@@ -37,7 +38,7 @@ func TestRuntimeSetupReceiptOutcomes(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: test.outcome, ExitCode: test.code}, err: test.err}
- err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: setupCanary}}})
+ _, err := runRuntimeSetup(initializationTestContext(t), peer, agentcapabilities.Identity{}, runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup", Command: setupCanary}}})
if test.outcome == "completed" && test.err == nil {
if err != nil {
t.Fatal(err)
@@ -74,14 +75,14 @@ func TestInitialFileUsesTypedRuntimeBytes(t *testing.T) {
size := int64(len(body))
owner := agentcapabilities.Identity{EnvironmentID: "environment", SessionID: "session"}
peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: "completed"}}
- if err := installInitialFile(initializationTestContext(t), peer, owner, environmentconfig.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil {
+ if _, err := installInitialFile(initializationTestContext(t), peer, owner, environmentconfig.InitialFileMetadata{Path: "/workspace/a", SizeBytes: &size}, body); err != nil {
t.Fatal(err)
}
if peer.request.Action != "file" || peer.request.File.Path != "/workspace/a" || peer.request.EnvironmentID != owner.EnvironmentID || peer.request.SessionID != owner.SessionID || string(peer.data) != setupCanary {
t.Fatal("file transport changed")
}
size++
- if err := installInitialFile(initializationTestContext(t), peer, owner, environmentconfig.InitialFileMetadata{SizeBytes: &size}, body); err == nil {
+ if _, err := installInitialFile(initializationTestContext(t), peer, owner, environmentconfig.InitialFileMetadata{SizeBytes: &size}, body); err == nil {
t.Fatal("mismatched source size accepted")
}
}
@@ -97,7 +98,7 @@ func TestRuntimeSetupUsesRemainingInitializationBudget(t *testing.T) {
ctx := initializationTestContext(t)
deadline, _ := ctx.Deadline()
peer := &receiptRuntime{result: proto.RuntimePrepareResultPayload{Outcome: "completed"}}
- err := runRuntimeSetup(ctx, peer, agentcapabilities.Identity{}, runtimeSetupOperation{})
+ _, err := runRuntimeSetup(ctx, peer, agentcapabilities.Identity{}, runtimeSetupOperation{})
if err != nil {
t.Fatal(err)
}
@@ -112,9 +113,35 @@ func TestRuntimeSetupUsesRemainingInitializationBudget(t *testing.T) {
}()} {
t.Run(name, func(t *testing.T) {
peer := &receiptRuntime{}
- if runRuntimeSetup(c, peer, agentcapabilities.Identity{}, runtimeSetupOperation{}) == nil || peer.request.BudgetMS != 0 {
+ if _, err := runRuntimeSetup(c, peer, agentcapabilities.Identity{}, runtimeSetupOperation{}); err == nil || peer.request.BudgetMS != 0 {
t.Fatal("invalid budget sent")
}
})
}
}
+
+func TestRuntimeSetupReportsStepTimingAndCategories(t *testing.T) {
+ setup := runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "initialize", Initialization: &proto.RuntimeInitialization{Action: "setup"}}, Index: 3}
+ step, ok := setup.step(proto.RuntimePrepareResultPayload{Outcome: "completed", DurationMS: 5200})
+ if !ok || step.Step != "setup" || step.Index == nil || *step.Index != 3 || step.DurationMS != 5200 {
+ t.Fatal("setup timing", step)
+ }
+ if step, ok = (runtimeSetupOperation{Request: proto.RuntimePreparePayload{Action: "finalize"}}).step(proto.RuntimePrepareResultPayload{Outcome: "failed", DurationMS: 7}); !ok || step.Step != "finalize" || step.Index != nil {
+ t.Fatal("finalize timing", step)
+ }
+ if _, ok = setup.step(proto.RuntimePrepareResultPayload{Outcome: "unknown"}); ok {
+ t.Fatal("unconfirmed operation reported as run")
+ }
+ for want, err := range map[string]error{
+ sessions.ProvisioningTimeout: &unconfirmedError{cause: context.DeadlineExceeded},
+ sessions.ProvisioningRuntimeDisconnected: &unconfirmedError{cause: runtimegateway.ErrSessionClosed},
+ sessions.ProvisioningUnconfirmed: &unconfirmedError{},
+ sessions.ProvisioningRejected: &runtimeStepFailure{rejected: true},
+ sessions.ProvisioningOperationFailed: &runtimeStepFailure{},
+ sessions.ProvisioningCoreError: sessions.ErrInvalidInput,
+ } {
+ if got := provisioningCategory(err); got != want {
+ t.Fatalf("category %s, want %s", got, want)
+ }
+ }
+}
diff --git a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go
index de4bf3cd5..c99a98ab7 100644
--- a/services/core/internal/persistence/postgres/sessionpg/execution_environment.go
+++ b/services/core/internal/persistence/postgres/sessionpg/execution_environment.go
@@ -2,6 +2,7 @@ package sessionpg
import (
"context"
+ "encoding/json"
"errors"
"github.com/google/uuid"
@@ -104,6 +105,21 @@ func (t *environmentTx) CompleteInitialization(ctx context.Context, environment
return count == 1, err
}
+func (t *environmentTx) RecordInitializationSteps(ctx context.Context, environment string, steps []sessions.InitializationStep) error {
+ id, err := parseID(environment)
+ if err != nil {
+ return err
+ }
+ if steps == nil {
+ steps = []sessions.InitializationStep{}
+ }
+ raw, err := json.Marshal(steps)
+ if err != nil {
+ return err
+ }
+ return t.q.RecordEnvironmentInitializationSteps(ctx, sqlc.RecordEnvironmentInitializationStepsParams{ID: id, Steps: raw})
+}
+
func (t *environmentTx) FailInitialization(ctx context.Context, environment string) error {
id, err := parseID(environment)
if err != nil {
diff --git a/services/core/internal/persistence/postgres/sessionpg/session_reads.go b/services/core/internal/persistence/postgres/sessionpg/session_reads.go
index dc0109247..a3982164d 100644
--- a/services/core/internal/persistence/postgres/sessionpg/session_reads.go
+++ b/services/core/internal/persistence/postgres/sessionpg/session_reads.go
@@ -292,6 +292,9 @@ func loadSessionActivity(ctx context.Context, q *sqlc.Queries, session sessions.
}
session.Environment = &value
session.EnvironmentFailure = environmentFailure(environment.Environment)
+ if err := json.Unmarshal(environment.Environment.InitializationSteps, &session.EnvironmentInitializationSteps); err != nil {
+ return session, err
+ }
state, err := LoadEnvironmentInput(ctx, q, id)
if err != nil {
return session, err
diff --git a/services/core/internal/sessions/execution_environment.go b/services/core/internal/sessions/execution_environment.go
index 942c08b60..bee1eac57 100644
--- a/services/core/internal/sessions/execution_environment.go
+++ b/services/core/internal/sessions/execution_environment.go
@@ -64,6 +64,9 @@ type InitializationTx interface {
// CompleteInitialization completes the running preparation of the live
// Environment and reports whether it did.
CompleteInitialization(ctx context.Context, environment string) (bool, error)
+ // RecordInitializationSteps stores the operation durations of the
+ // Environment's running preparation.
+ RecordInitializationSteps(ctx context.Context, environment string, steps []InitializationStep) error
// FailInitialization marks the Environment's preparation failed unless it
// completed.
FailInitialization(ctx context.Context, environment string) error
@@ -202,6 +205,14 @@ func journalInitialization(ctx context.Context, tx InitializationTx, environment
return true, tx.AppendChanges(ctx, EnvironmentStateChange(environment.ID, kind, status))
}
+// RecordEnvironmentInitializationSteps stores the operation durations of the
+// claimed preparation, before it completes or fails.
+func (o *ExecutionOperations) RecordEnvironmentInitializationSteps(ctx context.Context, owner EnvironmentInitialization, steps []InitializationStep) error {
+ return o.withInitialization(ctx, owner, func(ctx context.Context, tx InitializationTx, environment Environment) error {
+ return tx.RecordInitializationSteps(ctx, environment.ID, steps)
+ })
+}
+
// 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 286673b55..f8af313ca 100644
--- a/services/core/internal/sessions/execution_environment_test.go
+++ b/services/core/internal/sessions/execution_environment_test.go
@@ -24,6 +24,10 @@ func (f *fakeTx) CompleteInitialization(_ context.Context, environment string) (
return f.completeInitialization()
}
+func (f *fakeTx) RecordInitializationSteps(context.Context, string, []InitializationStep) error {
+ return nil
+}
+
func (f *fakeTx) FailInitialization(_ context.Context, environment string) error {
f.record("FailInitialization", f.failInitialization != nil, environment)
return f.failInitialization()
diff --git a/services/core/internal/sessions/provisioning.go b/services/core/internal/sessions/provisioning.go
index da5882344..78f01e2a1 100644
--- a/services/core/internal/sessions/provisioning.go
+++ b/services/core/internal/sessions/provisioning.go
@@ -19,6 +19,7 @@ type ProvisioningFailureDetail struct {
Step *string `json:"step"`
Index *int `json:"index"`
ExitCode *int `json:"exit_code"`
+ Category *string `json:"category,omitempty"`
}
// ProvisioningFailureReason is the safe reason for an Environment that
@@ -26,6 +27,40 @@ type ProvisioningFailureDetail struct {
// old receipts, bootstrap rejection and Core restart during initialization.
const ProvisioningFailureReason = "Failed to provision environment: initialization did not complete"
+// Failure categories for an Environment that failed without a confirmed failed
+// step, recorded privately beside the generic public reason.
+const (
+ // ProvisioningTimeout: the initialization budget or a transfer budget expired.
+ ProvisioningTimeout = "timeout"
+ // ProvisioningRuntimeDisconnected: the Runtime connection closed or changed.
+ ProvisioningRuntimeDisconnected = "runtime_disconnected"
+ // ProvisioningUnconfirmed: the effect of an operation is unknown or its receipt is missing or invalid.
+ ProvisioningUnconfirmed = "unconfirmed"
+ // ProvisioningRejected: the Runtime refused an operation before applying it.
+ ProvisioningRejected = "rejected"
+ // ProvisioningOperationFailed: the Runtime confirmed failure of an operation with no public step label.
+ ProvisioningOperationFailed = "operation_failed"
+ // ProvisioningCoreError: Core could not read or validate the stored configuration.
+ ProvisioningCoreError = "core_error"
+ // ProvisioningCoreRestart: the initialization owner was lost, so progress is unknown.
+ ProvisioningCoreRestart = "core_restart"
+ // ProvisioningComputeReleased: the Environment's compute ended before initialization completed.
+ ProvisioningComputeReleased = "compute_released"
+)
+
+var provisioningCategories = map[string]bool{
+ ProvisioningTimeout: true, ProvisioningRuntimeDisconnected: true, ProvisioningUnconfirmed: true, ProvisioningRejected: true,
+ ProvisioningOperationFailed: true, ProvisioningCoreError: true, ProvisioningCoreRestart: true, ProvisioningComputeReleased: true,
+}
+
+// InitializationStep is the Runtime-reported duration of one initialization
+// operation that ran. Step is a fixed label and Index is set for setup commands.
+type InitializationStep struct {
+ Step string `json:"step"`
+ Index *int `json:"index"`
+ DurationMS int64 `json:"duration_ms"`
+}
+
// Provisioning step kinds for ProvisioningFailure.Step.
const (
ProvisioningSetupCommand = "setup"
@@ -39,10 +74,12 @@ const (
// ProvisioningFailure identifies a confirmed failed initialization step.
// It cannot carry Runtime output: Step selects a fixed label, Index is the setup
// command position and ExitCode is the Runtime-reported status (0 when absent).
+// Category classifies a failure without a confirmed step.
type ProvisioningFailure struct {
Step string
Index int
ExitCode int
+ Category string
}
// Reason renders the public Session error. The setup_commands and Python package
@@ -72,14 +109,25 @@ func (f ProvisioningFailure) Reason() string {
// Detail is the private evidence recorded with the failure.
func (f ProvisioningFailure) Detail() *ProvisioningFailureDetail {
- return SanitizedProvisioningDetail(ProvisioningFailureDetail{Step: &f.Step, Index: &f.Index, ExitCode: &f.ExitCode})
+ detail := ProvisioningFailureDetail{Step: &f.Step, Index: &f.Index, ExitCode: &f.ExitCode}
+ if f.Step == "" {
+ detail.Step = nil
+ if f.Category != "" {
+ detail.Category = &f.Category
+ }
+ }
+ return SanitizedProvisioningDetail(detail)
}
// SanitizedProvisioningDetail keeps only the fields the step's category allows
-// and returns nil for a missing or unknown step.
+// and returns nil for a missing or unknown step without a known category.
func SanitizedProvisioningDetail(f ProvisioningFailureDetail) *ProvisioningFailureDetail {
if f.Step == nil {
- return nil
+ if f.Category == nil || !provisioningCategories[*f.Category] {
+ return nil
+ }
+ value := *f.Category
+ return &ProvisioningFailureDetail{Category: &value}
}
result := &ProvisioningFailureDetail{}
switch *f.Step {
diff --git a/services/core/internal/sessions/session.go b/services/core/internal/sessions/session.go
index dddf2e0c0..e2cad8b4c 100644
--- a/services/core/internal/sessions/session.go
+++ b/services/core/internal/sessions/session.go
@@ -35,6 +35,9 @@ type Session struct {
// EnvironmentFailure is the recorded provisioning failure of a failed hosted
// Environment. It makes the Session failed and is terminal.
EnvironmentFailure *EnvironmentFailure
+ // EnvironmentInitializationSteps are the durations the Runtime reported for
+ // the operations of the Environment's initialization.
+ EnvironmentInitializationSteps []InitializationStep
// PendingInput reports that the latest input reservation, read once no Turn
// is active or newer, can still start a Turn. It only supports settlement
// checks and is never rendered.
diff --git a/services/core/migrations/000009_environment_initialization_steps.sql b/services/core/migrations/000009_environment_initialization_steps.sql
new file mode 100644
index 000000000..7ceecb17f
--- /dev/null
+++ b/services/core/migrations/000009_environment_initialization_steps.sql
@@ -0,0 +1,4 @@
+-- +goose Up
+ALTER TABLE environments
+ ADD COLUMN initialization_steps jsonb NOT NULL DEFAULT '[]'::jsonb,
+ ADD CONSTRAINT environments_initialization_steps_check CHECK (jsonb_typeof(initialization_steps) = 'array');
diff --git a/services/core/tests/integration/session_diagnostics_test.go b/services/core/tests/integration/session_diagnostics_test.go
index 7f097b231..8b185ffd2 100644
--- a/services/core/tests/integration/session_diagnostics_test.go
+++ b/services/core/tests/integration/session_diagnostics_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "reflect"
"strings"
"testing"
"time"
@@ -254,6 +255,38 @@ func TestDiagnosticProvisioningDetailAtomicAndPrivate(t *testing.T) {
}
}
+func TestDiagnosticInitializationStepsAndFailureCategory(t *testing.T) {
+ s, installation := configuredStore(t)
+ tenant := uuid.NewString()
+ session, err := s.CreateSession(t.Context(), tenant, environmentInput("step-timing", "openai_hosted", "/workspace"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ writer := executionWriter(t, s)
+ owner, err := deploymentExecution(t, writer).ReserveAllocation(t.Context(), deployment.AllocationKey{TenantID: tenant, EnvironmentID: session.Environment.ID}, installation, runtimedevice.HashCredential(uuid.NewString()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtimeSuspensionSQL(t, s.pool, "UPDATE environments SET initialization='running' WHERE id=$1", owner.EnvironmentID)
+ preparation := sessions.EnvironmentInitialization{EnvironmentID: owner.EnvironmentID, SessionID: owner.SessionID, TenantID: owner.TenantID, DeviceID: owner.DeviceID}
+ index := 1
+ steps := []sessions.InitializationStep{{Step: "npm", DurationMS: 4100}, {Step: "setup", Index: &index, DurationMS: 5200}}
+ execution := sessionExecution(t, writer.lease)
+ if err = execution.RecordEnvironmentInitializationSteps(t.Context(), preparation, steps); err != nil {
+ t.Fatal(err)
+ }
+ if err = execution.FailEnvironmentInitialization(t.Context(), preparation, sessions.ProvisioningFailure{Category: sessions.ProvisioningTimeout}); err != nil {
+ t.Fatal(err)
+ }
+ snap, err := sessionAdapter(s).GetSession(t.Context(), tenant, session.ID)
+ if err != nil || !reflect.DeepEqual(snap.EnvironmentInitializationSteps, steps) {
+ t.Fatal("steps not persisted", snap.EnvironmentInitializationSteps, err)
+ }
+ if failure := snap.EnvironmentFailure; failure == nil || failure.Reason != sessions.ProvisioningFailureReason || failure.Detail == nil || failure.Detail.Step != nil || *failure.Detail.Category != sessions.ProvisioningTimeout {
+ t.Fatal("category not persisted beside the generic reason", snap.EnvironmentFailure)
+ }
+}
+
func TestDiagnosticPutItemRollsBack(t *testing.T) {
s, pool := testStore(t)
tenant, session := newTurnSession(t, s)
From 9ab53832a7607bd6834d9c12ef4ea933d6298196 Mon Sep 17 00:00:00 2001
From: SaladDay <1203511142@qq.com>
Date: Sun, 25 Oct 2026 06:29:09 +0000
Subject: [PATCH 2/2] Address review of initialization step timing
---
.../features/sessions/session-diagnostics.tsx | 2 +-
apps/web/src/i18n/locales/en/diagnostics.ts | 10 ++-
.../web/src/i18n/locales/zh-CN/diagnostics.ts | 10 ++-
contracts/agents-api/session-diagnostics.md | 8 +-
.../agents-api/zh/session-diagnostics.md | 10 +--
internal/agentdaemon/proto/version.go | 2 +-
packages/agents-client/src/index.ts | 2 +-
.../src/session-diagnostics.test.ts | 7 +-
.../agents-client/src/session-diagnostics.ts | 6 +-
scripts/ci_plan.py | 1 +
.../core/internal/api/session_diagnostics.go | 4 +-
.../internal/api/session_diagnostics_test.go | 13 +++-
.../execution/runtime_initialization.go | 20 +++--
.../core/internal/execution/runtime_setup.go | 14 ++--
.../internal/execution/runtime_setup_test.go | 25 ++++++-
.../sessions/environment_termination.go | 4 +
.../core/internal/sessions/provisioning.go | 75 ++++++++++++-------
.../internal/sessions/provisioning_test.go | 25 ++++++-
.../testdata/provisioning-categories.json | 10 +++
19 files changed, 183 insertions(+), 65 deletions(-)
create mode 100644 services/core/internal/sessions/testdata/provisioning-categories.json
diff --git a/apps/web/src/features/sessions/session-diagnostics.tsx b/apps/web/src/features/sessions/session-diagnostics.tsx
index ebea896f7..d543ef3cb 100644
--- a/apps/web/src/features/sessions/session-diagnostics.tsx
+++ b/apps/web/src/features/sessions/session-diagnostics.tsx
@@ -14,7 +14,7 @@ export const DiagnosticScope = createContext(null);
export function sessionDiagnosticsQuery(projectId: string, session: AgentSession) {
return queryOptions({
- queryKey: [...sessionKey(projectId, session.id), "diagnostics", "session", session.status, session.last_active_at],
+ queryKey: [...sessionKey(projectId, session.id), "diagnostics", "session", session.status],
queryFn: ({ signal }) => admin.retrieveSessionDiagnostics(projectId, session.id, { signal }),
enabled: session.status === "failed",
retry: false,
diff --git a/apps/web/src/i18n/locales/en/diagnostics.ts b/apps/web/src/i18n/locales/en/diagnostics.ts
index ece268160..257fc576e 100644
--- a/apps/web/src/i18n/locales/en/diagnostics.ts
+++ b/apps/web/src/i18n/locales/en/diagnostics.ts
@@ -1,3 +1,5 @@
+import type { ProvisioningCategory } from "@oac/agents-client";
+
export const diagnostics = {
providerUnknown: "Not recorded yet",
providerStale: "Observation unconfirmed",
@@ -68,10 +70,10 @@ export const diagnostics = {
unconfirmed: "The Runtime did not confirm the result.",
rejected: "The Runtime rejected the operation.",
operation_failed: "The Runtime reported a failed operation.",
- core_error: "Core could not read the stored configuration.",
- core_restart: "Core restarted during preparation.",
- compute_released: "The sandbox ended before preparation completed.",
- },
+ core_error: "Core could not read, validate or record the initialization state.",
+ core_restart: "Core lost ownership of the initialization.",
+ compute_released: "The sandbox was released or never created.",
+ } satisfies Record,
initializationTitle: "Initialization",
unavailable: "Failure details unavailable",
unavailableHelp: "The diagnostics read failed or no longer matches this status. Refresh to check again.",
diff --git a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
index 68cc7f01c..8bf3309dc 100644
--- a/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
+++ b/apps/web/src/i18n/locales/zh-CN/diagnostics.ts
@@ -1,3 +1,5 @@
+import type { ProvisioningCategory } from "@oac/agents-client";
+
export const diagnostics = {
providerUnknown: "还没有记录",
providerStale: "记录尚未确认",
@@ -68,10 +70,10 @@ export const diagnostics = {
unconfirmed: "Runtime 未确认结果。",
rejected: "Runtime 拒绝了该操作。",
operation_failed: "Runtime 报告操作失败。",
- core_error: "Core 无法读取已存储的配置。",
- core_restart: "准备期间 Core 重启。",
- compute_released: "准备完成前沙箱已结束。",
- },
+ core_error: "Core 无法读取、校验或记录初始化状态。",
+ core_restart: "Core 失去了初始化的所有权。",
+ compute_released: "沙箱已被释放或从未创建。",
+ } satisfies Record,
initializationTitle: "初始化",
unavailable: "失败详情不可用",
unavailableHelp: "诊断读取失败或与当前状态不一致,请刷新后重试。",
diff --git a/contracts/agents-api/session-diagnostics.md b/contracts/agents-api/session-diagnostics.md
index 4b8102edd..44853d9ec 100644
--- a/contracts/agents-api/session-diagnostics.md
+++ b/contracts/agents-api/session-diagnostics.md
@@ -36,11 +36,11 @@ Provisioning parameters come from the confirmed structured receipt, persisted in
| `unconfirmed` | An operation's effect is unknown, or its receipt is missing or invalid |
| `rejected` | The Runtime refused an operation before applying it |
| `operation_failed` | The Runtime confirmed a failed operation that has no public step label, such as a Plugin or snapshot finalization |
-| `core_error` | Core could not read or validate the stored configuration |
-| `core_restart` | The initialization owner was lost, including across a Core restart |
-| `compute_released` | The Environment's compute ended before initialization completed |
+| `core_error` | Core failed to read, validate or record initialization state |
+| `core_restart` | Core lost ownership of the initialization, by restart or lease loss |
+| `compute_released` | The sandbox was released or never created before initialization completed; never set for an Environment whose initialization completed |
-`initialization_steps` lists the operations the Environment's initialization ran, in order, from the same snapshot and for any Session status. Each entry has `step` (`file`, `skill`, `plugin`, `configure`, `npm`, `python`, `setup` or `finalize`), `index` (the setup command position for `setup`, otherwise null) and `duration_ms`, the Runtime-measured application time from the [Core–Runtime protocol](../../docs/runtime-protocol.md). Only operations whose result the Runtime reported with a `completed` or `failed` outcome appear, so a preparation-cache hit lists only `finalize`, and an unconfirmed operation is absent. Core also writes one `Environment initialization step` log line per entry with the Session, Environment, `step`, `index` and `duration_ms`. The list is empty before any operation ran.
+`initialization_steps` lists the operations the Environment's initialization ran, in order, from the same snapshot and for any Session status. Each entry has `step` (`file`, `skill`, `plugin`, `configure`, `npm`, `python`, `setup` or `finalize`), `index` (the setup command position for `setup`, otherwise null) and `duration_ms`, the Runtime-measured application time from the [Core–Runtime protocol](../../docs/runtime-protocol.md). Only operations whose result the Runtime reported with a `completed` or `failed` outcome appear, so a preparation-cache hit lists only `finalize`, and an unconfirmed operation is absent. Core also writes one `Environment initialization step` log line per entry with the Session, Environment, `step`, `index` and `duration_ms`. Core stores the list when the initialization ends, successfully or not, and logs each step as it finishes. The list is empty before any operation ran.
## Turn snapshot and Item receipt timing
diff --git a/contracts/agents-api/zh/session-diagnostics.md b/contracts/agents-api/zh/session-diagnostics.md
index c3a3e6760..10f3a336b 100644
--- a/contracts/agents-api/zh/session-diagnostics.md
+++ b/contracts/agents-api/zh/session-diagnostics.md
@@ -1,7 +1,7 @@
---
title: "Session 诊断"
source: contracts/agents-api/session-diagnostics.md
-source_hash: 2cb616097b200b1c2ed46c20fb28ed1b0e1cff4677e505ee418a2922042c8a03
+source_hash: d2159eddd1995bac506cb753a243b9ac063aba0f87c1854e2f97e885f9bb624f
---
管理员通过两个需要 Core 密钥的路由读取 Session 或 Turn 失败的原因,它们报告失败类别和 Item 接收时间。
@@ -38,11 +38,11 @@ source_hash: 2cb616097b200b1c2ed46c20fb28ed1b0e1cff4677e505ee418a2922042c8a03
| `unconfirmed` | 某个操作的效果未知,或其回执缺失或无效 |
| `rejected` | Runtime 在应用前拒绝了某个操作 |
| `operation_failed` | Runtime 确认某个没有公开步骤标签的操作失败,例如 Plugin 或快照终结 |
-| `core_error` | Core 无法读取或校验已存储的配置 |
-| `core_restart` | 初始化所有者丢失,包括跨 Core 重启 |
-| `compute_released` | Environment 的计算资源在初始化完成前已结束 |
+| `core_error` | Core 无法读取、校验或记录初始化状态 |
+| `core_restart` | Core 失去了初始化的所有权,原因是重启或租约丢失 |
+| `compute_released` | 沙箱在初始化完成前已被释放或从未创建;初始化已完成的 Environment 不会设置此值 |
-`initialization_steps` 按顺序列出该 Environment 的初始化运行过的操作,来自同一快照,适用于任何 Session 状态。每项包含 `step`(`file`、`skill`、`plugin`、`configure`、`npm`、`python`、`setup` 或 `finalize`)、`index`(`setup` 为设置命令位置,其他为 null)和 `duration_ms`,即 [Core–Runtime 协议](../../../docs/zh/runtime-protocol.md)中 Runtime 测得的应用时间。仅 Runtime 以 `completed` 或 `failed` 结果报告的操作会出现,因此命中准备缓存时只列出 `finalize`,未确认的操作不会出现。Core 还会为每项写一条 `Environment initialization step` 日志,包含 Session、Environment、`step`、`index` 和 `duration_ms`。在任何操作运行之前列表为空。
+`initialization_steps` 按顺序列出该 Environment 的初始化运行过的操作,来自同一快照,适用于任何 Session 状态。每项包含 `step`(`file`、`skill`、`plugin`、`configure`、`npm`、`python`、`setup` 或 `finalize`)、`index`(`setup` 为设置命令位置,其他为 null)和 `duration_ms`,即 [Core–Runtime 协议](../../../docs/zh/runtime-protocol.md)中 Runtime 测得的应用时间。仅 Runtime 以 `completed` 或 `failed` 结果报告的操作会出现,因此命中准备缓存时只列出 `finalize`,未确认的操作不会出现。Core 还会为每项写一条 `Environment initialization step` 日志,包含 Session、Environment、`step`、`index` 和 `duration_ms`。Core 在初始化结束时(无论成败)存储该列表,并在每一步完成时记录日志。在任何操作运行之前列表为空。
## Turn 快照与 Item 回执时间 {#turn-snapshot-and-item-receipt-timing}
diff --git a/internal/agentdaemon/proto/version.go b/internal/agentdaemon/proto/version.go
index d24c19dd7..9ae7aecb6 100644
--- a/internal/agentdaemon/proto/version.go
+++ b/internal/agentdaemon/proto/version.go
@@ -2,7 +2,7 @@ package proto
// Version identifies the complete Core–Runtime wire contract. Change it when
// removing or changing a payload or its semantics; deploy both endpoints together.
-const Version = "0.22.0"
+const Version = "0.23.0"
// VersionCompatible accepts only this contract. Patch drift, prerelease suffixes
// and malformed versions do not select an implicit compatibility path.
diff --git a/packages/agents-client/src/index.ts b/packages/agents-client/src/index.ts
index 2c450c799..6c81cc79c 100644
--- a/packages/agents-client/src/index.ts
+++ b/packages/agents-client/src/index.ts
@@ -12,7 +12,7 @@ export { AdminClient } from "./admin-client";
export { adminResourceTypes } from "./admin-types";
export type { AdminClientOptions, AdminProject, CreateAdminProjectInput, RenameAdminProjectInput, AdminAPIKey, AdminIssuedAPIKey, IssueAdminAPIKeyInput, AdminPage, AdminDeleted, AdminContent, AdminResourceType, AdminKeyProvenance, AdminResourceOwner, AdminWriteOperation, AdminWriteOperationOptions, AdminWriteOperationPage, AdminSummaryOptions, AdminSummaryEntry, AdminSummary, AdminRuntimeObservation, AdminAuditOptions, AdminAuditEntry, AdminAuditPage, ExecutorCredential, ExecutorConnection, ExecutorCredentialList, IssueExecutorCredentialInput, IssuedExecutorCredential, CoreInstallation, CoreInstallationConfiguration, CoreInstallationSetting, CoreAddressBindings } from "./admin-types";
-export type { DiagnosticFailureCode, ConnectionFailureParams, ProvisioningFailureParams, DiagnosticFailure, SessionDiagnosticFailure, SessionDiagnostics, ItemDiagnosticTiming, TurnDiagnostics } from "./session-diagnostics";
+export type { DiagnosticFailureCode, ConnectionFailureParams, ProvisioningFailureParams, ProvisioningCategory, InitializationStepTiming, DiagnosticFailure, SessionDiagnosticFailure, SessionDiagnostics, ItemDiagnosticTiming, TurnDiagnostics } from "./session-diagnostics";
export type { Selection as WorkspaceStorageSelection, WorkspaceStorageInput } from "./generated/core-api";
diff --git a/packages/agents-client/src/session-diagnostics.test.ts b/packages/agents-client/src/session-diagnostics.test.ts
index 497368bf0..e83a5ecdd 100644
--- a/packages/agents-client/src/session-diagnostics.test.ts
+++ b/packages/agents-client/src/session-diagnostics.test.ts
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { AdminClient } from "./admin-client";
-import { projectSessionDiagnostics, projectTurnDiagnostics } from "./session-diagnostics";
+import provisioningCategoryFixture from "../../../services/core/internal/sessions/testdata/provisioning-categories.json";
+import { projectSessionDiagnostics, projectTurnDiagnostics, provisioningCategoryValues } from "./session-diagnostics";
const id = "a113b7db-c689-4dfc-af9a-04bd0e466f11";
const turn = "729dab72-a9ea-4d0a-a38c-1b45e46233a9";
@@ -99,3 +100,7 @@ it.each([
] as const)("rejects arrays in %s", (_field, project) => {
expect(project).toThrow();
});
+
+it("keeps the provisioning categories equal to the Core set", () => {
+ expect([...provisioningCategoryValues]).toEqual(provisioningCategoryFixture);
+});
diff --git a/packages/agents-client/src/session-diagnostics.ts b/packages/agents-client/src/session-diagnostics.ts
index e413e0ee2..9e7aa39a5 100644
--- a/packages/agents-client/src/session-diagnostics.ts
+++ b/packages/agents-client/src/session-diagnostics.ts
@@ -9,11 +9,13 @@ import {
type TurnDiagnostics as TurnDiagnosticsResource,
} from "./generated/core-api";
+// provisioningCategoryValues is checked against the Go set by the shared fixture in session-diagnostics.test.ts.
// ItemDiagnosticTiming holds Core receipt intervals, distinct from the public tool-reported duration_ms.
export type { DiagnosticFailureCode, InitializationStepTiming, ItemDiagnosticTiming };
// The schema types params as a free-form object; each code that carries params has one of these shapes.
export const provisioningCategoryValues = ["timeout", "runtime_disconnected", "unconfirmed", "rejected", "operation_failed", "core_error", "core_restart", "compute_released"] as const;
-export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null; category: (typeof provisioningCategoryValues)[number] | null };
+export type ProvisioningCategory = (typeof provisioningCategoryValues)[number];
+export type ProvisioningFailureParams = { step: "setup" | "python" | "npm" | "file" | "skill" | "harness" | null; index: number | null; exit_code: number | null; category: ProvisioningCategory | null };
export type ConnectionFailureParams = { http_status: number | null };
export type DiagnosticFailure = Omit & { params: Record | ProvisioningFailureParams | ConnectionFailureParams };
export type SessionDiagnosticFailure = DiagnosticFailure & Pick;
@@ -60,7 +62,7 @@ export function projectSessionDiagnostics(value: unknown, sessionId: string): Se
projected.turn_id = f.turn_id as string;
}
} else if (value.failure !== null) return invalidAdminResponse();
- if (!Array.isArray(value.initialization_steps) || value.initialization_steps.length > 200) return invalidAdminResponse();
+ if (!Array.isArray(value.initialization_steps)) return invalidAdminResponse();
const initialization_steps: InitializationStepTiming[] = value.initialization_steps.map((step: unknown) => {
if (!isRecord(step) || !exactFields(step, initializationStepTimingFields) || !isOneOf(initializationStepTimingStepValues, step.step) ||
!Number.isSafeInteger(step.duration_ms) || (step.duration_ms as number) < 0 ||
diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py
index 7d6836881..f76b2c8f8 100644
--- a/scripts/ci_plan.py
+++ b/scripts/ci_plan.py
@@ -100,6 +100,7 @@
)
EXACT_INPUTS = {
"services/core/internal/sandbox/testdata/node-diagnostics.json": ("web", "web-acceptance", "example"),
+ "services/core/internal/sessions/testdata/provisioning-categories.json": ("web", "web-acceptance", "example"),
"services/core/internal/sandbox/testdata/deployment-contract.json": ("distribution",),
"services/core/internal/sandbox/e2b/testdata/configuration-selectors.json": ("distribution",),
}
diff --git a/services/core/internal/api/session_diagnostics.go b/services/core/internal/api/session_diagnostics.go
index 83936ef5f..ed0af2f85 100644
--- a/services/core/internal/api/session_diagnostics.go
+++ b/services/core/internal/api/session_diagnostics.go
@@ -96,7 +96,7 @@ func (h *Handler) getSessionDiagnostics(w http.ResponseWriter, r *http.Request)
}
response := SessionDiagnostics{Object: "core.session_diagnostics", SessionID: public.ID, Status: public.Status, InitializationSteps: []InitializationStepTiming{}}
for _, step := range session.EnvironmentInitializationSteps {
- response.InitializationSteps = append(response.InitializationSteps, InitializationStepTiming{Step: step.Step, Index: step.Index, DurationMS: step.DurationMS})
+ response.InitializationSteps = append(response.InitializationSteps, InitializationStepTiming{Step: string(step.Step), Index: step.Index, DurationMS: step.DurationMS})
}
if public.Status == "failed" {
failure := SessionDiagnosticFailure{DiagnosticFailure: DiagnosticFailure{Code: "internal_error", Params: CoreErrorDetails{}}}
@@ -185,7 +185,7 @@ func provisioningFailureParams(detail *sessions.ProvisioningFailureDetail) CoreE
params["index"] = CoreErrorNumber(float64(*detail.Index))
}
if detail.Category != nil {
- params["category"] = CoreErrorString(*detail.Category)
+ params["category"] = CoreErrorString(string(*detail.Category))
}
if detail.ExitCode != nil {
params["exit_code"] = CoreErrorNumber(float64(*detail.ExitCode))
diff --git a/services/core/internal/api/session_diagnostics_test.go b/services/core/internal/api/session_diagnostics_test.go
index 5447fd04d..67e5efc5a 100644
--- a/services/core/internal/api/session_diagnostics_test.go
+++ b/services/core/internal/api/session_diagnostics_test.go
@@ -107,7 +107,7 @@ func TestDiagnosticsHostedFailureOverridesInputWithoutParsingReason(t *testing.T
session := hostedFailureSession()
session.EnvironmentInputActivity = &sessions.EnvironmentInputActivity{Status: "failed", Failure: "environment_unavailable", LastActiveAt: time.Now()}
session.LastTurn = &sessions.Turn{ID: uuid.NewString(), SessionID: session.ID, Status: sessions.TurnFailed, Outcome: json.RawMessage(`{"error_code":"engine_failed"}`)}
- step, index, exit, category := "setup", 2, 7, "timeout"
+ step, index, exit, category := "setup", 2, 7, sessions.ProvisioningTimeout
session.EnvironmentInitializationSteps = []sessions.InitializationStep{{Step: "npm", DurationMS: 4100}, {Step: "setup", Index: &index, DurationMS: 5200}}
for _, detail := range []*sessions.ProvisioningFailureDetail{nil, {Step: &step, Index: &index, ExitCode: &exit}, {Category: &category}} {
session.EnvironmentFailure = &sessions.EnvironmentFailure{Reason: "private-secret-canary setup_commands[99] exit 254", Detail: detail}
@@ -135,3 +135,14 @@ func TestDiagnosticStatusMatchesOfficialSession(t *testing.T) {
t.Fatal("diagnostics must preserve the pinned official Session status set")
}
}
+
+func TestDiagnosticStepEnumMatchesDeclaredLabels(t *testing.T) {
+ field, _ := reflect.TypeFor[InitializationStepTiming]().FieldByName("Step")
+ var declared []string
+ for _, label := range sessions.InitializationLabels {
+ declared = append(declared, string(label))
+ }
+ if field.Tag.Get("enums") != strings.Join(declared, ",") {
+ t.Fatal("OpenAPI step enum differs from the declared labels", field.Tag.Get("enums"))
+ }
+}
diff --git a/services/core/internal/execution/runtime_initialization.go b/services/core/internal/execution/runtime_initialization.go
index d73bf5187..91177c91b 100644
--- a/services/core/internal/execution/runtime_initialization.go
+++ b/services/core/internal/execution/runtime_initialization.go
@@ -98,12 +98,13 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir
failure := sessions.ProvisioningFailure{}
steps := []sessions.InitializationStep{}
err := w.prepareEnvironment(operation, owner, &failure, &steps)
- // Steps that ran stay recorded for a failed initialization as well.
- record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
- defer stop()
- if recordErr := w.dispatcher.sessionExecution.RecordEnvironmentInitializationSteps(record, owner, steps); recordErr != nil && err == nil {
- err = recordErr
+ // Steps stay recorded for a failed initialization too, but a write failure never fails
+ // one that succeeded. Each step was already logged when it finished.
+ recordSteps, stopSteps := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ if recordErr := w.dispatcher.sessionExecution.RecordEnvironmentInitializationSteps(recordSteps, owner, steps); recordErr != nil {
+ log.Warn(ctx, "Environment initialization steps not recorded", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID)
}
+ stopSteps()
if err == nil {
err = w.dispatcher.sessionExecution.CompleteEnvironmentInitialization(operation, owner)
if err == nil {
@@ -111,11 +112,13 @@ func (w *Worker) initializeEnvironment(ctx context.Context, owner sessions.Envir
}
}
if err != nil {
- log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID)
- // A later scan settles an unrecorded failure; it never retries the setup.
if failure.Step == "" && failure.Category == "" {
failure.Category = provisioningCategory(err)
}
+ log.Warn(ctx, "Environment preparation failed", "environment_id", owner.EnvironmentID, "session_id", owner.SessionID, "step", failure.Step, "category", string(failure.Category))
+ // A later scan settles an unrecorded failure; it never retries the setup.
+ record, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
+ defer stop()
_ = w.dispatcher.sessionExecution.FailEnvironmentInitialization(record, owner, failure)
}
}
@@ -154,6 +157,9 @@ func (w *Worker) prepareEnvironment(ctx context.Context, owner sessions.Environm
operations := setupOperations(setup)
step := func(index int) error {
err := w.lease.CheckOwnership(ctx)
+ if err != nil {
+ err = errors.Join(errOwnershipLost, err)
+ }
if err == nil {
var currentPeer = peer
currentPeer, err = w.dispatcher.authorizedPeer(ctx, owner.DeviceID)
diff --git a/services/core/internal/execution/runtime_setup.go b/services/core/internal/execution/runtime_setup.go
index 000cff4a2..7abd302b1 100644
--- a/services/core/internal/execution/runtime_setup.go
+++ b/services/core/internal/execution/runtime_setup.go
@@ -35,14 +35,16 @@ type unconfirmedError struct{ cause error }
func (*unconfirmedError) Error() string { return "environment initialization operation unconfirmed" }
func (e *unconfirmedError) Unwrap() error { return e.cause }
+var errOwnershipLost = errors.New("Core lost ownership of the initialization")
+
var errRuntimeConnectionChanged = errors.New("Runtime connection changed during initialization")
// stepLabel is the fixed label of the operation in step timing.
-func (operation runtimeSetupOperation) stepLabel() string {
+func (operation runtimeSetupOperation) stepLabel() sessions.InitializationLabel {
if operation.Request.Initialization != nil {
- return operation.Request.Initialization.Action
+ return sessions.InitializationLabel(operation.Request.Initialization.Action)
}
- return operation.Request.Action
+ return sessions.InitializationLabel(operation.Request.Action)
}
// step reports the Runtime-measured timing of an operation that ran.
@@ -51,17 +53,19 @@ func (operation runtimeSetupOperation) step(result proto.RuntimePrepareResultPay
return sessions.InitializationStep{}, false
}
step := sessions.InitializationStep{Step: operation.stepLabel(), DurationMS: result.DurationMS}
- if step.Step == "setup" {
+ if step.Step == sessions.InitializationSetup {
step.Index = &operation.Index
}
return step, true
}
// provisioningCategory classifies a failure without a confirmed failed step.
-func provisioningCategory(err error) string {
+func provisioningCategory(err error) sessions.ProvisioningCategory {
var confirmed *runtimeStepFailure
var unconfirmed *unconfirmedError
switch {
+ case errors.Is(err, errOwnershipLost):
+ return sessions.ProvisioningCoreRestart
case errors.Is(err, context.DeadlineExceeded):
return sessions.ProvisioningTimeout
case errors.Is(err, runtimegateway.ErrSessionClosed), errors.Is(err, runtimegateway.ErrDeviceNotRegistered), errors.Is(err, errRuntimeConnectionChanged):
diff --git a/services/core/internal/execution/runtime_setup_test.go b/services/core/internal/execution/runtime_setup_test.go
index d92deb743..5e6c5ac6c 100644
--- a/services/core/internal/execution/runtime_setup_test.go
+++ b/services/core/internal/execution/runtime_setup_test.go
@@ -132,16 +132,39 @@ func TestRuntimeSetupReportsStepTimingAndCategories(t *testing.T) {
if _, ok = setup.step(proto.RuntimePrepareResultPayload{Outcome: "unknown"}); ok {
t.Fatal("unconfirmed operation reported as run")
}
- for want, err := range map[string]error{
+ for want, err := range map[sessions.ProvisioningCategory]error{
sessions.ProvisioningTimeout: &unconfirmedError{cause: context.DeadlineExceeded},
sessions.ProvisioningRuntimeDisconnected: &unconfirmedError{cause: runtimegateway.ErrSessionClosed},
sessions.ProvisioningUnconfirmed: &unconfirmedError{},
sessions.ProvisioningRejected: &runtimeStepFailure{rejected: true},
sessions.ProvisioningOperationFailed: &runtimeStepFailure{},
sessions.ProvisioningCoreError: sessions.ErrInvalidInput,
+ sessions.ProvisioningCoreRestart: errors.Join(errOwnershipLost, context.DeadlineExceeded),
} {
if got := provisioningCategory(err); got != want {
t.Fatalf("category %s, want %s", got, want)
}
}
}
+
+func TestRuntimeSetupLabelsAreDeclared(t *testing.T) {
+ setup := environmentconfig.Setup{
+ Skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "s", Version: "1", Name: "n", Description: "d"}}},
+ Plugins: []environmentconfig.Plugin{{Metadata: proto.PluginMetadata{Type: "inline", Name: "p", Description: "d"}}},
+ Commands: []environmentconfig.SetupCommand{{Command: "c"}},
+ CapabilityDirectories: []string{"/workspace/generated"},
+ }
+ setup.Packages.NPM, setup.Packages.Python = []string{"a"}, []string{"b"}
+ labels := map[sessions.InitializationLabel]bool{sessions.InitializationFile: true}
+ for _, operation := range setupOperations(setup) {
+ labels[operation.stepLabel()] = true
+ }
+ if len(labels) != len(sessions.InitializationLabels) {
+ t.Fatal("operations and declared labels differ", labels)
+ }
+ for _, label := range sessions.InitializationLabels {
+ if !labels[label] {
+ t.Fatal("undeclared or unproduced label", label)
+ }
+ }
+}
diff --git a/services/core/internal/sessions/environment_termination.go b/services/core/internal/sessions/environment_termination.go
index 2707b7457..b7225a44e 100644
--- a/services/core/internal/sessions/environment_termination.go
+++ b/services/core/internal/sessions/environment_termination.go
@@ -149,6 +149,10 @@ func TerminateEnvironment(ctx context.Context, tx EnvironmentTerminationTx, comp
}
termination := decideEnvironmentTermination(environment.Status, computeExpired)
if termination == terminationFails {
+ 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 {
diff --git a/services/core/internal/sessions/provisioning.go b/services/core/internal/sessions/provisioning.go
index 78f01e2a1..5230f8d89 100644
--- a/services/core/internal/sessions/provisioning.go
+++ b/services/core/internal/sessions/provisioning.go
@@ -2,6 +2,7 @@ package sessions
import (
"fmt"
+ "slices"
"time"
)
@@ -16,10 +17,10 @@ type EnvironmentFailure struct {
// ProvisioningFailureDetail is private, fixed-category evidence from a confirmed
// initialization receipt. It never contains command text, paths or Runtime output.
type ProvisioningFailureDetail struct {
- Step *string `json:"step"`
- Index *int `json:"index"`
- ExitCode *int `json:"exit_code"`
- Category *string `json:"category,omitempty"`
+ Step *string `json:"step"`
+ Index *int `json:"index"`
+ ExitCode *int `json:"exit_code"`
+ Category *ProvisioningCategory `json:"category,omitempty"`
}
// ProvisioningFailureReason is the safe reason for an Environment that
@@ -27,38 +28,62 @@ type ProvisioningFailureDetail struct {
// old receipts, bootstrap rejection and Core restart during initialization.
const ProvisioningFailureReason = "Failed to provision environment: initialization did not complete"
-// Failure categories for an Environment that failed without a confirmed failed
-// step, recorded privately beside the generic public reason.
+// ProvisioningCategory classifies an Environment that failed without a confirmed
+// failed step. It is recorded privately beside the generic public reason.
+type ProvisioningCategory string
+
const (
// ProvisioningTimeout: the initialization budget or a transfer budget expired.
- ProvisioningTimeout = "timeout"
+ ProvisioningTimeout ProvisioningCategory = "timeout"
// ProvisioningRuntimeDisconnected: the Runtime connection closed or changed.
- ProvisioningRuntimeDisconnected = "runtime_disconnected"
+ ProvisioningRuntimeDisconnected ProvisioningCategory = "runtime_disconnected"
// ProvisioningUnconfirmed: the effect of an operation is unknown or its receipt is missing or invalid.
- ProvisioningUnconfirmed = "unconfirmed"
+ ProvisioningUnconfirmed ProvisioningCategory = "unconfirmed"
// ProvisioningRejected: the Runtime refused an operation before applying it.
- ProvisioningRejected = "rejected"
+ ProvisioningRejected ProvisioningCategory = "rejected"
// ProvisioningOperationFailed: the Runtime confirmed failure of an operation with no public step label.
- ProvisioningOperationFailed = "operation_failed"
- // ProvisioningCoreError: Core could not read or validate the stored configuration.
- ProvisioningCoreError = "core_error"
- // ProvisioningCoreRestart: the initialization owner was lost, so progress is unknown.
- ProvisioningCoreRestart = "core_restart"
- // ProvisioningComputeReleased: the Environment's compute ended before initialization completed.
- ProvisioningComputeReleased = "compute_released"
+ ProvisioningOperationFailed ProvisioningCategory = "operation_failed"
+ // ProvisioningCoreError: Core failed to read, validate or record initialization state.
+ ProvisioningCoreError ProvisioningCategory = "core_error"
+ // ProvisioningCoreRestart: Core lost ownership of the initialization, by restart or lease loss.
+ ProvisioningCoreRestart ProvisioningCategory = "core_restart"
+ // ProvisioningComputeReleased: the sandbox was released or never created before initialization completed.
+ ProvisioningComputeReleased ProvisioningCategory = "compute_released"
+)
+
+// ProvisioningCategories is the one authored category set. The shared fixture
+// testdata/provisioning-categories.json checks the client and Web projections.
+var ProvisioningCategories = []ProvisioningCategory{
+ ProvisioningTimeout, ProvisioningRuntimeDisconnected, ProvisioningUnconfirmed, ProvisioningRejected,
+ ProvisioningOperationFailed, ProvisioningCoreError, ProvisioningCoreRestart, ProvisioningComputeReleased,
+}
+
+// InitializationLabel is the fixed label of one initialization operation in step timing.
+type InitializationLabel string
+
+const (
+ InitializationFile InitializationLabel = "file"
+ InitializationSkill InitializationLabel = "skill"
+ InitializationPlugin InitializationLabel = "plugin"
+ InitializationConfigure InitializationLabel = "configure"
+ InitializationNPM InitializationLabel = "npm"
+ InitializationPython InitializationLabel = "python"
+ InitializationSetup InitializationLabel = "setup"
+ InitializationFinalize InitializationLabel = "finalize"
)
-var provisioningCategories = map[string]bool{
- ProvisioningTimeout: true, ProvisioningRuntimeDisconnected: true, ProvisioningUnconfirmed: true, ProvisioningRejected: true,
- ProvisioningOperationFailed: true, ProvisioningCoreError: true, ProvisioningCoreRestart: true, ProvisioningComputeReleased: true,
+// InitializationLabels is the one authored label set; the diagnostics OpenAPI enum is checked against it.
+var InitializationLabels = []InitializationLabel{
+ InitializationFile, InitializationSkill, InitializationPlugin, InitializationConfigure,
+ InitializationNPM, InitializationPython, InitializationSetup, InitializationFinalize,
}
// InitializationStep is the Runtime-reported duration of one initialization
// operation that ran. Step is a fixed label and Index is set for setup commands.
type InitializationStep struct {
- Step string `json:"step"`
- Index *int `json:"index"`
- DurationMS int64 `json:"duration_ms"`
+ Step InitializationLabel `json:"step"`
+ Index *int `json:"index"`
+ DurationMS int64 `json:"duration_ms"`
}
// Provisioning step kinds for ProvisioningFailure.Step.
@@ -79,7 +104,7 @@ type ProvisioningFailure struct {
Step string
Index int
ExitCode int
- Category string
+ Category ProvisioningCategory
}
// Reason renders the public Session error. The setup_commands and Python package
@@ -123,7 +148,7 @@ func (f ProvisioningFailure) Detail() *ProvisioningFailureDetail {
// and returns nil for a missing or unknown step without a known category.
func SanitizedProvisioningDetail(f ProvisioningFailureDetail) *ProvisioningFailureDetail {
if f.Step == nil {
- if f.Category == nil || !provisioningCategories[*f.Category] {
+ if f.Category == nil || !slices.Contains(ProvisioningCategories, *f.Category) {
return nil
}
value := *f.Category
diff --git a/services/core/internal/sessions/provisioning_test.go b/services/core/internal/sessions/provisioning_test.go
index 90ecbc499..e0af72d00 100644
--- a/services/core/internal/sessions/provisioning_test.go
+++ b/services/core/internal/sessions/provisioning_test.go
@@ -1,6 +1,11 @@
package sessions
-import "testing"
+import (
+ "encoding/json"
+ "os"
+ "slices"
+ "testing"
+)
// Reasons contain only a fixed label and an exit status. Setup and Python labels
// match official samples; npm, system, file and Skill labels are unverified.
@@ -25,3 +30,21 @@ func TestProvisioningFailureReasons(t *testing.T) {
}
}
}
+
+func TestProvisioningCategoriesMatchSharedFixture(t *testing.T) {
+ raw, err := os.ReadFile("testdata/provisioning-categories.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var fixture []string
+ if err := json.Unmarshal(raw, &fixture); err != nil {
+ t.Fatal(err)
+ }
+ var declared []string
+ for _, category := range ProvisioningCategories {
+ declared = append(declared, string(category))
+ }
+ if !slices.Equal(fixture, declared) {
+ t.Fatal("fixture differs from the declared categories", fixture, declared)
+ }
+}
diff --git a/services/core/internal/sessions/testdata/provisioning-categories.json b/services/core/internal/sessions/testdata/provisioning-categories.json
new file mode 100644
index 000000000..803556ed8
--- /dev/null
+++ b/services/core/internal/sessions/testdata/provisioning-categories.json
@@ -0,0 +1,10 @@
+[
+ "timeout",
+ "runtime_disconnected",
+ "unconfirmed",
+ "rejected",
+ "operation_failed",
+ "core_error",
+ "core_restart",
+ "compute_released"
+]