From 099d6351b360b67b55e2c9af7ce2f5031925fcdd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Jul 2026 22:54:31 -0400 Subject: [PATCH] =?UTF-8?q?refactor(sessions):=20canonical=20lineage=20mod?= =?UTF-8?q?el=20=E2=80=94=20creation=20provenance,=20fork=20ancestry,=20ge?= =?UTF-8?q?neration=20chain,=20typed=20row=20contract=20(#111861)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sessions): preserve spawn/fork lineage across implicit daily/idle rollover * refactor(sessions): canonical creation model with forkSource ancestry and generation chain * feat(sessions): stamp creation provenance across all creation paths and emit created events * refactor(gateway): lock lineage patching, split control from navigation, add typed session-row contract * docs(gateway): document creation provenance stamping and lineage patch tightening * fix(sessions): keep provenance proof-only on wire fallbacks and strip node-local lineage from cron continuations * fix(gateway): never journal a created event for session adoption * fix(gateway): keep post-create work on adoption while gating the created event * fix(sessions): restore trusted ensure-main provenance and stamp navigation parent at spawn * fix(sessions): allow parentSessionKey through the direct child spawn patch * fix(ci): break type cycles, satisfy export scan, regenerate Swift protocol models * refactor(sessions): replace createdBy with createdActor * fix(protocol): export session row type * fix(sessions): preserve proven creation provenance * fix(sessions): close lineage creation gaps * test(sessions): align atomic spawn lineage coverage * test(sessions): widen transcript search reconcile wait * fix(sessions): stamp reset-created rows * test(sessions): keep reset provenance coverage focused * fix(sessions): journal chat-created rows * test(ci): anchor release skill reads to repo * test(ci): avoid cached module paths --- .../OpenClawProtocol/GatewayModels.swift | 32 +-- config/max-lines-baseline.txt | 1 - ...-session-transcript-schema-baseline.sha256 | 2 +- docs/concepts/multi-user.md | 4 +- docs/gateway/protocol.md | 4 +- packages/gateway-protocol/src/index.ts | 8 +- .../src/schema/sessions-catalog.test.ts | 2 +- .../src/schema/sessions-catalog.ts | 4 +- .../src/schema/sessions-row.ts | 109 ++++++++ .../gateway-protocol/src/schema/sessions.ts | 23 +- src/agents/acp-spawn.test.ts | 40 +-- src/agents/acp-spawn.ts | 55 +++- src/agents/internal-session-effects.test.ts | 7 +- src/agents/internal-session-effects.ts | 2 + src/agents/openclaw-tools.ts | 5 +- src/agents/sessions-spawn-hooks.test.ts | 53 ---- src/agents/subagent-spawn.attachments.test.ts | 53 ---- .../subagent-spawn.model-session.test.ts | 2 +- src/agents/subagent-spawn.test.ts | 10 +- src/agents/subagent-spawn.ts | 60 +++-- src/agents/tools/in-process-gateway.test.ts | 58 ++++ src/agents/tools/in-process-gateway.ts | 18 ++ src/agents/tools/sessions-send-tool.ts | 36 ++- src/agents/tools/sessions-spawn-visible.ts | 15 +- src/agents/tools/sessions.test.ts | 70 +++++ src/auto-reply/reply/get-reply-fast-path.ts | 22 +- .../reply/get-reply-native-slash-fast-path.ts | 8 + .../reply/get-reply.fast-path.test.ts | 88 ++++++ src/auto-reply/reply/session-creator.test.ts | 41 --- src/auto-reply/reply/session-fork.test.ts | 7 + .../reply/session-parent-fork-prepare.ts | 4 + src/auto-reply/reply/session.test.ts | 104 ++++++++ src/auto-reply/reply/session.ts | 40 ++- src/auto-reply/templating.ts | 7 +- src/commands/sessions-table.ts | 3 +- .../session-accessor.conformance.test.ts | 9 + .../session-accessor.sqlite-entry-store.ts | 3 - .../sessions/session-accessor.sqlite-entry.ts | 43 ++- ...ession-accessor.sqlite-message-cut.test.ts | 19 ++ .../session-accessor.sqlite-message-cut.ts | 36 ++- .../session-accessor.sqlite-parent-session.ts | 4 + .../session-accessor.sqlite-status.ts | 36 +-- src/config/sessions/session-accessor.test.ts | 59 +++-- src/config/sessions/session-accessor.types.ts | 4 + src/config/sessions/session-entry-lineage.ts | 8 + .../sessions/session-entry-provenance.ts | 26 ++ .../session-transcript-search.test.ts | 3 +- src/config/sessions/sessions.test.ts | 81 ++++++ src/config/sessions/types.ts | 35 ++- .../isolated-agent/run-session-state.test.ts | 24 +- src/cron/isolated-agent/run-session-state.ts | 19 ++ src/gateway/method-scopes.test.ts | 5 +- src/gateway/server-chat.ts | 3 +- .../server-methods/agent-reset-phase.ts | 4 +- .../server-methods/agent-run-handler.ts | 4 +- .../agent-session-patch.test.ts | 53 ---- .../server-methods/agent-session-patch.ts | 4 - .../server-methods/agent-session-persist.ts | 27 +- .../server-methods/agent-session-reset.ts | 6 +- .../chat-send-user-turn.test.ts | 10 + .../server-methods/chat-send-user-turn.ts | 6 +- .../server-methods/gateway-client-identity.ts | 5 - .../server-methods/session-catalog.test.ts | 39 ++- src/gateway/server-methods/session-catalog.ts | 34 ++- .../session-creation-provenance.test.ts | 16 ++ .../session-creation-provenance.ts | 42 +++ src/gateway/server-methods/sessions-create.ts | 12 +- .../server-methods/sessions-mutations.ts | 4 +- .../server-methods/sessions-rewind.test.ts | 41 ++- src/gateway/server-methods/sessions-rewind.ts | 11 +- src/gateway/server-methods/shared-types.ts | 8 +- src/gateway/server-plugin-runtime-client.ts | 4 + src/gateway/server-plugins.ts | 3 + src/gateway/server-session-events.ts | 2 +- ...erver.agent.gateway-server-agent-a.test.ts | 47 ++++ ...er.agent.subagent-delivery-context.test.ts | 8 +- src/gateway/server.sessions.create.test.ts | 250 ++++++++++++++---- .../server.sessions.list-changed.test.ts | 21 ++ .../server.sessions.reset-models.test.ts | 66 +++++ src/gateway/server.sessions.store-rpc.test.ts | 41 ++- .../server/ws-connection/connect-session.ts | 29 -- ...essage-handler.post-connect-health.test.ts | 6 +- src/gateway/session-create-fork-entry.test.ts | 41 +++ src/gateway/session-create-fork-entry.ts | 7 +- src/gateway/session-create-service.ts | 49 +++- src/gateway/session-event-payload.test.ts | 10 +- src/gateway/session-event-payload.ts | 10 +- src/gateway/session-reset-service.ts | 44 ++- src/gateway/session-utils-creators.test.ts | 22 +- src/gateway/session-utils.subagent.test.ts | 61 ++++- src/gateway/session-utils.test.ts | 12 + src/gateway/session-utils.ts | 68 ++++- src/gateway/session-utils.types.ts | 25 +- src/gateway/sessions-patch-subagent-policy.ts | 99 +++++++ src/gateway/sessions-patch.test.ts | 60 ----- src/gateway/sessions-patch.ts | 214 +-------------- src/plugins/runtime/runtime-agent.test.ts | 3 + src/plugins/runtime/runtime-agent.ts | 7 + src/plugins/session-entry-slot-keys.ts | 6 +- src/sessions/session-state-event-kinds.ts | 2 + src/sessions/session-state-events.test.ts | 20 +- src/sessions/session-state-events.ts | 22 ++ src/shared/session-types.ts | 4 +- src/state/openclaw-agent-db-schema.ts | 10 - src/state/openclaw-agent-db.generated.d.ts | 1 - src/state/openclaw-agent-db.test.ts | 24 -- src/state/openclaw-agent-schema.generated.ts | 1 - src/state/openclaw-agent-schema.sql | 1 - src/talk/agent-consult-runtime.test.ts | 18 +- src/talk/agent-consult-runtime.ts | 7 + src/talk/client-voice-session.test.ts | 32 ++- src/talk/client-voice-session.ts | 23 +- src/tui/embedded-backend.ts | 1 + .../package-acceptance-workflow.test.ts | 16 +- ui/src/api/types.ts | 13 +- .../app-sidebar-session-catalogs.ts | 2 +- .../app-sidebar-session-navigation.ts | 4 +- .../app-sidebar-session-ownership.ts | 18 +- .../app-sidebar-session-row-render.ts | 2 +- .../components/app-sidebar-session-types.ts | 4 +- ui/src/components/session-owner-chip.ts | 45 ++-- ui/src/e2e/session-ownership.e2e.test.ts | 8 +- ui/src/lib/sessions/navigation.test.ts | 2 +- ui/src/lib/sessions/navigation.ts | 3 +- ui/src/lib/sessions/reconcile.test.ts | 16 +- ui/src/lib/sessions/reconcile.ts | 11 +- .../chat/components/chat-pane-header.test.ts | 4 +- .../pages/chat/components/chat-pane-header.ts | 2 +- ui/src/pages/sessions/view.test.ts | 9 +- ui/src/pages/sessions/view.ts | 21 +- .../app-sidebar-cases/session-ownership.ts | 20 +- 131 files changed, 2272 insertions(+), 1049 deletions(-) create mode 100644 packages/gateway-protocol/src/schema/sessions-row.ts create mode 100644 src/agents/tools/in-process-gateway.test.ts delete mode 100644 src/auto-reply/reply/session-creator.test.ts create mode 100644 src/gateway/server-methods/session-creation-provenance.test.ts create mode 100644 src/gateway/server-methods/session-creation-provenance.ts create mode 100644 src/gateway/session-create-fork-entry.test.ts create mode 100644 src/gateway/sessions-patch-subagent-policy.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 2d29acac0896..e0d2dd741939 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -4120,7 +4120,7 @@ public struct SessionCatalogSession: Codable, Sendable { public let pullrequest: SessionCatalogPullRequestSummary? public let archived: Bool public let sessionkey: String? - public let createdby: [String: AnyCodable]? + public let createdactor: [String: AnyCodable]? public let cancontinue: Bool public let canarchive: Bool public let canopenterminal: Bool? @@ -4141,7 +4141,7 @@ public struct SessionCatalogSession: Codable, Sendable { pullrequest: SessionCatalogPullRequestSummary? = nil, archived: Bool, sessionkey: String? = nil, - createdby: [String: AnyCodable]? = nil, + createdactor: [String: AnyCodable]? = nil, cancontinue: Bool, canarchive: Bool, canopenterminal: Bool? = nil) @@ -4161,7 +4161,7 @@ public struct SessionCatalogSession: Codable, Sendable { self.pullrequest = pullrequest self.archived = archived self.sessionkey = sessionkey - self.createdby = createdby + self.createdactor = createdactor self.cancontinue = cancontinue self.canarchive = canarchive self.canopenterminal = canopenterminal @@ -4183,7 +4183,7 @@ public struct SessionCatalogSession: Codable, Sendable { case pullrequest = "pullRequest" case archived case sessionkey = "sessionKey" - case createdby = "createdBy" + case createdactor = "createdActor" case cancontinue = "canContinue" case canarchive = "canArchive" case canopenterminal = "canOpenTerminal" @@ -6624,13 +6624,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let execask: AnyCodable? public let execnode: AnyCodable? public let model: AnyCodable? - public let spawnedby: AnyCodable? public let completionownersessionkey: AnyCodable? - public let spawnedworkspacedir: AnyCodable? - public let spawnedcwd: AnyCodable? - public let spawndepth: AnyCodable? - public let subagentrole: AnyCodable? - public let subagentcontrolscope: AnyCodable? public let inheritedtoolpolicyversion: AnyCodable? public let inheritedtoolallow: AnyCodable? public let inheritedtooldeny: AnyCodable? @@ -6661,13 +6655,7 @@ public struct SessionsPatchParams: Codable, Sendable { execask: AnyCodable? = nil, execnode: AnyCodable? = nil, model: AnyCodable? = nil, - spawnedby: AnyCodable? = nil, completionownersessionkey: AnyCodable? = nil, - spawnedworkspacedir: AnyCodable? = nil, - spawnedcwd: AnyCodable? = nil, - spawndepth: AnyCodable? = nil, - subagentrole: AnyCodable? = nil, - subagentcontrolscope: AnyCodable? = nil, inheritedtoolpolicyversion: AnyCodable? = nil, inheritedtoolallow: AnyCodable? = nil, inheritedtooldeny: AnyCodable? = nil, @@ -6697,13 +6685,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.execask = execask self.execnode = execnode self.model = model - self.spawnedby = spawnedby self.completionownersessionkey = completionownersessionkey - self.spawnedworkspacedir = spawnedworkspacedir - self.spawnedcwd = spawnedcwd - self.spawndepth = spawndepth - self.subagentrole = subagentrole - self.subagentcontrolscope = subagentcontrolscope self.inheritedtoolpolicyversion = inheritedtoolpolicyversion self.inheritedtoolallow = inheritedtoolallow self.inheritedtooldeny = inheritedtooldeny @@ -6735,13 +6717,7 @@ public struct SessionsPatchParams: Codable, Sendable { case execask = "execAsk" case execnode = "execNode" case model - case spawnedby = "spawnedBy" case completionownersessionkey = "completionOwnerSessionKey" - case spawnedworkspacedir = "spawnedWorkspaceDir" - case spawnedcwd = "spawnedCwd" - case spawndepth = "spawnDepth" - case subagentrole = "subagentRole" - case subagentcontrolscope = "subagentControlScope" case inheritedtoolpolicyversion = "inheritedToolPolicyVersion" case inheritedtoolallow = "inheritedToolAllow" case inheritedtooldeny = "inheritedToolDeny" diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index a094ca69909c..bcf3a1d17fdf 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -867,7 +867,6 @@ src/gateway/session-utils.test.ts src/gateway/session-utils.ts src/gateway/sessions-history-http.test.ts src/gateway/sessions-patch.test.ts -src/gateway/sessions-patch.ts src/gateway/talk-realtime-relay.test.ts src/gateway/talk-realtime-relay.ts src/gateway/test-helpers.server.ts diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 index d5611b65d7f3..b1b51969807b 100644 --- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 +++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 @@ -1 +1 @@ -0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql +011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql diff --git a/docs/concepts/multi-user.md b/docs/concepts/multi-user.md index 8fa3b0260b42..d1a7e726aaae 100644 --- a/docs/concepts/multi-user.md +++ b/docs/concepts/multi-user.md @@ -17,7 +17,9 @@ If people must not access each other's sessions, tools, credentials, or files, g ## Ownership and presence -New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp. +New sessions record a write-once `createdActor` when the creation path can prove who caused it. Authenticated people use their durable Gateway profile id; requesting agents and system paths use the same actor field. Sessions created without a proven actor remain unattributed. + +Human display names are resolved from the current Gateway profile when session rows are returned. OpenClaw does not store labels on session entries, so changing a profile name updates the ownership UI without rewriting session history. The web app keeps ownership and presence visually distinct: diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index e826e915f0c4..8af01a144a3e 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -572,13 +572,13 @@ methods. Treat this as feature discovery, not a full enumeration of - `sessions.preview` returns bounded transcript previews for specific session keys. - `sessions.describe` returns one gateway session row for an exact session key. - `sessions.resolve` resolves or canonicalizes a session target. - - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. + - `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key). - `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC. - `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side. - `sessions.send` sends a message into an existing session. - `sessions.steer` is the interrupt-and-steer variant for an active session. - `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. - - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. + - `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected. - `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance. - `sessions.get` returns the full stored session row. - Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders. diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 5107575304d0..5ccbf5678a48 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -26,7 +26,11 @@ export type { WizardNotFoundErrorDetails, } from "./schema/error-codes.js"; export * from "./schema/board.js"; -export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js"; +export { + SessionCreatedActorSchema, + type SessionCreatedActor, + type SessionRow, +} from "./schema/sessions-row.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; import { @@ -413,6 +417,7 @@ import { SecretsResolveParamsSchema, SecretsResolveResultSchema, SessionBranchSchema, + SessionRowSchema, SessionsAbortParamsSchema, SessionsCompactParamsSchema, SessionsCleanupParamsSchema, @@ -1156,6 +1161,7 @@ export { SessionsCompactionBranchParamsSchema, SessionsCompactionRestoreParamsSchema, SessionBranchSchema, + SessionRowSchema, SessionsBranchesListParamsSchema, SessionsBranchesListResultSchema, SessionsBranchesSwitchParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index 6b6d67ab6c5f..64716d8b0970 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -31,7 +31,7 @@ describe("SessionsCatalogListResultSchema", () => { threadId: "thread-1", status: "idle", archived: false, - createdBy: { id: "profile-ada", label: "Ada" }, + createdActor: { type: "human", id: "profile-ada", label: "Ada" }, canContinue: true, canArchive: false, canOpenTerminal: true, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index 850e6a81066e..d1fce8b6ad17 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -3,7 +3,7 @@ import { Type } from "typebox"; import { closedObject } from "./closed-object.js"; import { PluginJsonValueSchema } from "./plugins.js"; import { NonEmptyString } from "./primitives.js"; -import { SessionCreatorIdentitySchema } from "./sessions.js"; +import { SessionCreatedActorSchema } from "./sessions-row.js"; const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString }); @@ -56,7 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({ pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema), archived: Type.Boolean(), sessionKey: Type.Optional(NonEmptyString), - createdBy: Type.Optional(SessionCreatorIdentitySchema), + createdActor: Type.Optional(SessionCreatedActorSchema), canContinue: Type.Boolean(), canArchive: Type.Boolean(), canOpenTerminal: Type.Optional(Type.Boolean()), diff --git a/packages/gateway-protocol/src/schema/sessions-row.ts b/packages/gateway-protocol/src/schema/sessions-row.ts new file mode 100644 index 000000000000..70643fc5243f --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-row.ts @@ -0,0 +1,109 @@ +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +/** Projected actor that caused a session node to be created. */ +export const SessionCreatedActorSchema = closedObject({ + type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]), + id: Type.Optional(NonEmptyString), + label: Type.Optional(NonEmptyString), +}); + +/** Stable Gateway session row fields; mutation envelopes may add null tombstones. */ +export const SessionRowSchema = Type.Object( + { + key: Type.String(), + sessionId: Type.Optional(Type.String()), + kind: Type.Union([ + Type.Literal("direct"), + Type.Literal("group"), + Type.Literal("global"), + Type.Literal("unknown"), + ]), + label: Type.Optional(Type.String()), + displayName: Type.Optional(Type.String()), + derivedTitle: Type.Optional(Type.String()), + lastMessagePreview: Type.Optional(Type.String()), + channel: Type.Optional(Type.String()), + chatType: Type.Optional( + Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]), + ), + updatedAt: Type.Optional(Type.Union([Type.Number(), Type.Null()])), + archived: Type.Optional(Type.Boolean()), + archivedAt: Type.Optional(Type.Number()), + pinned: Type.Optional(Type.Boolean()), + pinnedAt: Type.Optional(Type.Number()), + icon: Type.Optional(Type.String()), + unread: Type.Optional(Type.Boolean()), + lastReadAt: Type.Optional(Type.Number()), + lastActivityAt: Type.Optional(Type.Number()), + lastInteractionAt: Type.Optional(Type.Number()), + status: Type.Optional( + Type.Union([ + Type.Literal("running"), + Type.Literal("done"), + Type.Literal("failed"), + Type.Literal("killed"), + Type.Literal("timeout"), + ]), + ), + lastRunError: Type.Optional(Type.String()), + spawnedBy: Type.Optional(Type.String()), + parentSessionKey: Type.Optional(Type.String()), + controlOwnerSessionKey: Type.Optional(Type.String()), + childSessions: Type.Optional(Type.Array(Type.String())), + forkedFromParent: Type.Optional(Type.Boolean()), + spawnDepth: Type.Optional(Type.Number()), + subagentRole: Type.Optional(Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf")])), + subagentControlScope: Type.Optional( + Type.Union([Type.Literal("children"), Type.Literal("none")]), + ), + swarmGroupId: Type.Optional(Type.String()), + worktree: Type.Optional( + Type.Object({ + id: Type.String(), + branch: Type.String(), + repoRoot: Type.String(), + }), + ), + execNode: Type.Optional(Type.String()), + execCwd: Type.Optional(Type.String()), + spawnedWorkspaceDir: Type.Optional(Type.String()), + spawnedCwd: Type.Optional(Type.String()), + createdVia: Type.Optional( + Type.Union([ + Type.Literal("operator"), + Type.Literal("spawn"), + Type.Literal("channel"), + Type.Literal("cron"), + Type.Literal("talk"), + Type.Literal("run"), + Type.Literal("plugin"), + Type.Literal("internal"), + ]), + ), + createdActor: Type.Optional(SessionCreatedActorSchema), + createdAt: Type.Optional(Type.Number()), + forkSource: Type.Optional( + Type.Object({ + sessionKey: Type.String(), + sessionId: Type.String(), + entryId: Type.Optional(Type.String()), + }), + ), + previousSessionId: Type.Optional(Type.String()), + inputTokens: Type.Optional(Type.Number()), + outputTokens: Type.Optional(Type.Number()), + totalTokens: Type.Optional(Type.Number()), + totalTokensFresh: Type.Optional(Type.Boolean()), + contextTokens: Type.Optional(Type.Number()), + estimatedCostUsd: Type.Optional(Type.Number()), + model: Type.Optional(Type.String()), + modelProvider: Type.Optional(Type.String()), + }, + { additionalProperties: true }, +); + +export type SessionCreatedActor = Static; +export type SessionRow = Static; diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 846322fb549a..5c666fc5ff6d 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -9,6 +9,12 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js"; import { SessionsCreateParamsSchema } from "./sessions-create.js"; export { SessionsCreateParamsSchema }; +export { + SessionCreatedActorSchema, + SessionRowSchema, + type SessionCreatedActor, + type SessionRow, +} from "./sessions-row.js"; export const SESSION_OBSERVER_HEALTH_VALUES = [ "on-track", @@ -20,13 +26,6 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [ "failed", ] as const; -/** Stable identity stamped on a session when an operator creates it. */ -export const SessionCreatorIdentitySchema = closedObject({ - id: NonEmptyString, - label: Type.Optional(NonEmptyString), -}); -export type SessionCreatorIdentity = Static; - /** Trajectory judgment produced for one observed agent session. */ export const SessionObserverHealthSchema = Type.Union([ Type.Literal("on-track"), @@ -478,17 +477,7 @@ export const SessionsPatchParamsSchema = closedObject({ execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), completionOwnerSessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedWorkspaceDir: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedCwd: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])), - subagentRole: Type.Optional( - Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]), - ), - subagentControlScope: Type.Optional( - Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]), - ), inheritedToolPolicyVersion: Type.Optional(Type.Union([Type.Literal(1), Type.Null()])), inheritedToolAllow: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])), inheritedToolDeny: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])), diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index b176e3a41ddc..01d61dfce197 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -70,6 +70,7 @@ const hoisted = vi.hoisted(() => { const countActiveRunsForSessionMock = vi.fn(); const getSubagentRunByChildSessionKeyMock = vi.fn(); const listTasksForOwnerKeyMock = vi.fn(); + const upsertSessionEntryMock = vi.fn(); const createSessionAccessorMock = () => { const resolveMockStorePath = (scope: { agentId?: string; @@ -111,6 +112,8 @@ const hoisted = vi.hoisted(() => { listSessionEntriesReadOnly: listMockEntries, loadSessionEntry: loadMockEntry, loadSessionEntryReadOnly: loadMockEntry, + upsertSessionEntry: async (scope: unknown, patch: SessionEntry) => + await upsertSessionEntryMock(scope, patch), resolveSessionTranscriptRuntimeTarget: async (scope: { agentId: string; sessionId: string; @@ -161,6 +164,7 @@ const hoisted = vi.hoisted(() => { countActiveRunsForSessionMock, getSubagentRunByChildSessionKeyMock, listTasksForOwnerKeyMock, + upsertSessionEntryMock, createSessionAccessorMock, state, }; @@ -455,8 +459,8 @@ function expectGatewayMethodNotCalled(method: string): void { expect(gatewayRequests().some((request) => request.method === method)).toBe(false); } -function expectSessionPatchFields(expected: Record): void { - expectRecordFields(gatewayRequest("sessions.patch").params, expected); +function expectCreatedSessionFields(expected: Record): void { + expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected); } function expectInitializeSessionFields(expected: Record): Record { @@ -703,6 +707,13 @@ describe("spawnAcpDirect", () => { hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0); hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null); hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]); + hoisted.upsertSessionEntryMock + .mockReset() + .mockImplementation(async (_scope: unknown, patch: Partial) => ({ + ...patch, + sessionId: patch.sessionId ?? "sess-123", + updatedAt: patch.updatedAt ?? Date.now(), + })); hoisted.callGatewayMock.mockReset(); hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => { @@ -861,25 +872,25 @@ describe("spawnAcpDirect", () => { expect(accepted.runId).toBe("run-1"); expect(accepted.mode).toBe("session"); expect(accepted.inlineDelivery).toBe(true); - expectSessionPatchFields({ - key: accepted.childSessionKey, + expectCreatedSessionFields({ spawnedBy: "agent:main:main", completionOwnerSessionKey: "agent:main:main", inheritedToolPolicyVersion: 1, + parentSessionKey: "agent:main:main", + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: expect.any(Number), }); expectBindingCallFields({ targetKind: "session", placement: "child", }); - const patchCallIndex = hoisted.callGatewayMock.mock.calls.findIndex( - (call: unknown[]) => (call[0] as { method?: string }).method === "sessions.patch", - ); const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex( (call: unknown[]) => (call[0] as { method?: string }).method === "agent", ); - const patchCallOrder = expectDefined( - hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex], - "hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex] test invariant", + const createCallOrder = expectDefined( + hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0], + "hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0] test invariant", ); const initializeCallOrder = expectDefined( hoisted.initializeSessionMock.mock.invocationCallOrder[0], @@ -889,10 +900,10 @@ describe("spawnAcpDirect", () => { hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex], "hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant", ); - expect(typeof patchCallOrder).toBe("number"); + expect(typeof createCallOrder).toBe("number"); expect(typeof initializeCallOrder).toBe("number"); expect(typeof agentCallOrder).toBe("number"); - expect(patchCallOrder < initializeCallOrder).toBe(true); + expect(createCallOrder < initializeCallOrder).toBe(true); expect(initializeCallOrder < agentCallOrder).toBe(true); expectResolvedIntroTextInBindMetadata(); @@ -1419,9 +1430,8 @@ describe("spawnAcpDirect", () => { agentSessionKey: "agent:main:subagent:parent", }); - const accepted = expectAcceptedSpawn(result); - expectSessionPatchFields({ - key: accepted.childSessionKey, + expectAcceptedSpawn(result); + expectCreatedSessionFields({ spawnedBy: "agent:main:subagent:parent", spawnDepth: 2, subagentRole: "leaf", diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 88ae45b0c51a..1f57f2543cad 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -38,7 +38,9 @@ import { loadSessionEntry, loadSessionEntryReadOnly, resolveSessionTranscriptRuntimeTarget, + upsertSessionEntry, } from "../config/sessions/session-accessor.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; import type { SessionAcpMeta, SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; @@ -59,7 +61,7 @@ import { parseAgentSessionKey, resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; -import { recordSubagentSpawned } from "../sessions/session-state-events.js"; +import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js"; import { listTasksForOwnerKey } from "../tasks/runtime-internal.js"; import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js"; import { @@ -1240,6 +1242,7 @@ export async function spawnAcpDirect( } let sessionCreated = false; + let childCreationEntry: SessionEntry | undefined; let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined; const childIdem = crypto.randomUUID(); const parentAgentId = parentSessionKey @@ -1285,20 +1288,37 @@ export async function spawnAcpDirect( }; const adapter: SpawnBackendAdapter = { async initialize() { - await callGateway({ - method: "sessions.patch", - params: { - key: sessionKey, - spawnedBy: requesterInternalKey, - completionOwnerSessionKey: ownership.completionRequesterSessionKey, - ...admission.childSessionPatch, - inheritedToolPolicyVersion: 1, - ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist), - ...inheritedToolDenyPatch(ctx.inheritedToolDenylist), - ...(params.label ? { label: params.label } : {}), - }, - timeoutMs: 10_000, + const creationStamp = buildSessionCreationStamp({ + via: "spawn", + actor: { type: "agent", id: requesterInternalKey }, }); + const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId }); + const childSessionPatch = admission.childSessionPatch + ? { + spawnDepth: admission.childSessionPatch.spawnDepth, + ...(admission.childSessionPatch.subagentRole + ? { subagentRole: admission.childSessionPatch.subagentRole } + : {}), + subagentControlScope: admission.childSessionPatch.subagentControlScope, + } + : {}; + childCreationEntry = + (await upsertSessionEntry( + { storePath, sessionKey }, + { + ...creationStamp, + spawnedBy: requesterInternalKey, + completionOwnerSessionKey: ownership.completionRequesterSessionKey, + // Navigation parent is stamped at creation so the durable tree edge + // does not depend on the control-lineage field. + parentSessionKey: requesterInternalKey, + ...childSessionPatch, + inheritedToolPolicyVersion: 1, + ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist), + ...inheritedToolDenyPatch(ctx.inheritedToolDenylist), + ...(params.label ? { label: params.label } : {}), + }, + )) ?? undefined; sessionCreated = true; const initializedSession = await initializeAcpSpawnRuntime({ cfg, @@ -1335,6 +1355,13 @@ export async function spawnAcpDirect( binding: state.binding, }); // ACP bypasses the native adapter, so seed the same child lineage before dispatch. + if (childCreationEntry) { + recordSessionCreated({ + sessionKey, + agentId: targetAgentId, + entry: childCreationEntry, + }); + } recordSubagentSpawned({ childSessionKey: sessionKey, childRunId: childIdem, diff --git a/src/agents/internal-session-effects.test.ts b/src/agents/internal-session-effects.test.ts index 55ec79c060fb..89346bbfd2fc 100644 --- a/src/agents/internal-session-effects.test.ts +++ b/src/agents/internal-session-effects.test.ts @@ -26,7 +26,12 @@ describe("internal session effects", () => { expect(target.sessionKey).toMatch(/^agent:main:internal-session-effects:run_with_space-/); expect(target.sessionId).toMatch(/^internal-session-effects-run_with_space-/); - expect(loadExactSessionEntry(target)?.entry.sessionId).toBe(target.sessionId); + expect(loadExactSessionEntry(target)?.entry).toMatchObject({ + sessionId: target.sessionId, + createdVia: "internal", + createdActor: { type: "system" }, + createdAt: expect.any(Number), + }); expect(listSessionEntries({ storePath })).toEqual([]); await expect(loadTranscriptEvents(target)).resolves.toEqual([ expect.objectContaining({ id: target.sessionId, type: "session" }), diff --git a/src/agents/internal-session-effects.ts b/src/agents/internal-session-effects.ts index d9bb0552735d..ecca1bcac46d 100644 --- a/src/agents/internal-session-effects.ts +++ b/src/agents/internal-session-effects.ts @@ -7,6 +7,7 @@ import { replaceTranscriptEvents, upsertSessionEntry, } from "../config/sessions/session-accessor.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { createSessionTranscriptHeader } from "../config/sessions/transcript-header.js"; import type { SessionEntry } from "../config/sessions/types.js"; @@ -93,6 +94,7 @@ export async function prepareInternalSessionEffectsSession(params: { } const now = Date.now(); const entry = await upsertSessionEntry(scope, { + ...buildSessionCreationStamp({ via: "internal", actor: { type: "system" } }), sessionId: scope.sessionId, sessionStartedAt: now, updatedAt: now, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index c8fd26db8510..3a0ef652d751 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -645,12 +645,15 @@ export function createOpenClawTools( config: resolvedConfig, senderIsOwner: options?.senderIsOwner, }), + // No explicit callGateway: the tool defaults to the same in-process + // caller, and an injected override would disable the trusted creation + // stamp for materialized agent roots (opts.callGateway === undefined + // is the gate in ensureConfiguredAgentMainSession). createSessionsSendTool({ agentSessionKey: options?.agentSessionKey, agentChannel: options?.agentChannel, sandboxed: options?.sandboxed, config: resolvedConfig, - callGateway, }), ]), ...(includeSubagentSpawnTool diff --git a/src/agents/sessions-spawn-hooks.test.ts b/src/agents/sessions-spawn-hooks.test.ts index c48f0709e266..666c1bff64fd 100644 --- a/src/agents/sessions-spawn-hooks.test.ts +++ b/src/agents/sessions-spawn-hooks.test.ts @@ -591,57 +591,4 @@ describe("sessions_spawn subagent lifecycle hooks", () => { "delete params", ); }); - - it("cleans up the provisional session when lineage patching fails after thread binding", async () => { - const store: Record> = {}; - hoisted.updateSessionStoreMock.mockImplementation( - async (_storePath: unknown, mutator: unknown) => { - if (typeof mutator !== "function") { - throw new Error("missing session store mutator"); - } - await mutator(store); - if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) { - throw new Error("lineage patch failed"); - } - return store; - }, - ); - hoisted.callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: Record }; - if (request.method === "sessions.delete") { - return { ok: true }; - } - if (request.method === "agent") { - return { runId: "run-1", status: "accepted", acceptedAt: 1_001 }; - } - return {}; - }); - - const result = await spawn({ - thread: true, - mode: "session", - agentAccountId: "work", - agentTo: "channel:123", - agentThreadId: "456", - context: "isolated", - }); - - expect(result.status).toBe("error"); - expect(result.error).toContain("lineage patch failed"); - expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled(); - expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled(); - const methods = getGatewayMethods(); - expect(methods).toContain("sessions.delete"); - expect(methods).not.toContain("agent"); - const deleteCall = findGatewayRequest("sessions.delete"); - expectFields( - deleteCall?.params, - { - key: result.childSessionKey, - deleteTranscript: true, - emitLifecycleHooks: true, - }, - "delete params", - ); - }); }); diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 683972a06d4b..56af28a2acf7 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -218,57 +218,4 @@ describe("spawnSubagentDirect filename validation", () => { fs.rmSync(homeDir, { recursive: true, force: true }); } }); - - it("removes materialized attachments when lineage patching fails", async () => { - // Attachments are created before the child session lineage patch; failures - // must delete both the child session and materialized files. - const calls: Array<{ method?: string; params?: Record }> = []; - const store: Record> = {}; - updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => { - if (typeof mutator !== "function") { - throw new Error("missing session store mutator"); - } - await mutator(store); - if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) { - throw new Error("lineage patch failed"); - } - return store; - }); - callGatewayMock.mockImplementation(async (opts: unknown) => { - const request = opts as { method?: string; params?: Record }; - calls.push(request); - if (request.method === "sessions.delete") { - return { ok: true }; - } - return {}; - }); - - const { spawnSubagentDirect } = subagentSpawnModule; - const result = await spawnSubagentDirect( - { - task: "test", - attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }], - }, - ctx, - ); - - expect(result.status).toBe("error"); - expect(result.error).toContain("lineage patch failed"); - const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments"); - const retainedDirs = fs.existsSync(attachmentsRoot) - ? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith(".")) - : []; - expect(retainedDirs).toHaveLength(0); - const deleteCall = calls.find((entry) => entry.method === "sessions.delete"); - const deleteParams = deleteCall?.params as - | { - key?: string; - deleteTranscript?: boolean; - emitLifecycleHooks?: boolean; - } - | undefined; - expect(deleteParams?.key).toMatch(/^agent:main:subagent:/); - expect(deleteParams?.deleteTranscript).toBe(true); - expect(deleteParams?.emitLifecycleHooks).toBe(false); - }); }); diff --git a/src/agents/subagent-spawn.model-session.test.ts b/src/agents/subagent-spawn.model-session.test.ts index b4c9155b9cdd..f23f33c4da45 100644 --- a/src/agents/subagent-spawn.model-session.test.ts +++ b/src/agents/subagent-spawn.model-session.test.ts @@ -84,7 +84,7 @@ describe("spawnSubagentDirect runtime model persistence", () => { expect(result.modelApplied).toBe(true); expect(result.resolvedModel).toBe("openai/gpt-5.4"); expect(result.resolvedProvider).toBe("openai"); - expect(updateSessionStoreMock).toHaveBeenCalledTimes(3); + expect(updateSessionStoreMock).toHaveBeenCalledTimes(2); expectPersistedRuntimeModel({ persistedStore, sessionKey: /^agent:main:subagent:/, diff --git a/src/agents/subagent-spawn.test.ts b/src/agents/subagent-spawn.test.ts index 9e7cf3bfb3bf..25d55752fcf9 100644 --- a/src/agents/subagent-spawn.test.ts +++ b/src/agents/subagent-spawn.test.ts @@ -884,7 +884,15 @@ describe("spawnSubagentDirect seam flow", () => { expect(result.childSessionKey).toMatch(/^agent:main:subagent:/); const childSessionKey = result.childSessionKey as string; - expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(3); + expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(2); + expect(persistedStore?.[childSessionKey]).toMatchObject({ + spawnedBy: "agent:main:main", + completionOwnerSessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: expect.any(Number), + }); const registerInput = firstRegisteredSubagentRun(); const requesterOrigin = requireRecord(registerInput.requesterOrigin); expect(registerInput.runId).toBe("run-1"); diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 8beefcba1d01..a8bc6a2398d1 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -18,6 +18,7 @@ import { resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingSpawnPolicy, } from "../channels/thread-bindings-policy.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { SubagentSpawnPreparation } from "../context-engine/types.js"; @@ -26,7 +27,7 @@ import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js"; import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js"; import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js"; import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; -import { recordSubagentSpawned } from "../sessions/session-state-events.js"; +import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js"; import type { FastMode } from "../shared/fast-mode.js"; import { resolveUserPath } from "../utils.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; @@ -243,15 +244,15 @@ async function callSubagentGateway( authorization?: SubagentLaunchAuthorization, ): Promise>> { // Subagent lifecycle requires methods spanning multiple scope tiers - // (sessions.patch / sessions.delete → admin, agent → write). When each call + // (sessions.delete → admin, agent → write). When each call // independently negotiates least-privilege scopes the first connection pairs // at a lower tier and every subsequent higher-tier call triggers a // scope-upgrade handshake that headless gateway-client connections cannot // complete interactively, causing close(1008) "pairing required" (#59428). // // Only admin-requiring calls are pinned to ADMIN_SCOPE; other methods (e.g. - // "agent" -> write) keep their least-privilege scope. The params-aware - // resolver keeps spawn-metadata sessions.patch calls on the admin tier. + // "agent" -> write) keep their least-privilege scope. Apply the trusted + // launch authorization before resolving the request's required scope. const authorizedParams = params.params != null && typeof params.params === "object" && !Array.isArray(params.params) ? applySubagentLaunchAuthorization(params.params as Record, authorization) @@ -390,6 +391,9 @@ function buildDirectChildSessionPatch(patch: Record): Partial, + creationStamp?: ReturnType, ): Promise => { try { const target = resolveGatewaySessionStoreTarget({ cfg, key: childSessionKey, }); - await upsertSessionEntry( + const updatedEntry = await upsertSessionEntry( { storePath: target.storePath, sessionKey: target.canonicalKey, }, - buildDirectChildSessionPatch(patch), + { ...buildDirectChildSessionPatch(patch), ...creationStamp }, ); + childCreationEntry ??= updatedEntry ?? undefined; return undefined; } catch (err) { const message = @@ -1364,6 +1371,13 @@ export async function spawnSubagentDirect( }; const initialChildSessionPatch: Record = { + spawnedBy: spawnedByKey, + completionOwnerSessionKey: ownership.completionRequesterSessionKey, + // Navigation and control lineage commit with the creation stamp so a + // launch failure cannot leave a durable but parentless child row. + parentSessionKey: spawnedByKey, + ...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}), + ...(spawnedCwd ? { spawnedCwd } : {}), ...admission.childSessionPatch, inheritedToolPolicyVersion: 1, ...inheritedToolAllowPatch(ctx.inheritedToolAllowlist), @@ -1374,7 +1388,13 @@ export async function spawnSubagentDirect( ...(params.outputSchema ? { swarmOutputSchema: params.outputSchema } : {}), }; - const initialPatchError = await patchChildSession(initialChildSessionPatch); + const initialPatchError = await patchChildSession( + initialChildSessionPatch, + buildSessionCreationStamp({ + via: "spawn", + actor: { type: "agent", id: requesterInternalKey }, + }), + ); if (initialPatchError) { return { status: "error", @@ -1530,32 +1550,18 @@ export async function spawnSubagentDirect( persistentSession: spawnMode === "session", task, }); - const spawnedMetadata = normalizeSpawnedRunMetadata({ spawnedBy: spawnedByKey, ...toolSpawnMetadata, workspaceDir: spawnedWorkspaceDir, }); - const spawnLineagePatchError = await patchChildSession({ - spawnedBy: spawnedByKey, - completionOwnerSessionKey: ownership.completionRequesterSessionKey, - ...(spawnedMetadata.workspaceDir - ? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir } - : {}), - ...(spawnedCwd ? { spawnedCwd } : {}), - }); - if (spawnLineagePatchError) { - await cleanupFailedSpawnBeforeAgentStart({ - childSessionKey, - attachmentAbsDir, - emitLifecycleHooks: threadBindingReady, - deleteTranscript: true, + + if (childCreationEntry) { + recordSessionCreated({ + sessionKey: childSessionKey, + agentId: targetAgentId, + entry: childCreationEntry, }); - return { - status: "error", - error: spawnLineagePatchError, - childSessionKey, - }; } recordSubagentSpawned({ childSessionKey, diff --git a/src/agents/tools/in-process-gateway.test.ts b/src/agents/tools/in-process-gateway.test.ts new file mode 100644 index 000000000000..322bcc492eac --- /dev/null +++ b/src/agents/tools/in-process-gateway.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + hasContext: true, + dispatch: vi.fn(), + callGatewayTool: vi.fn(), +})); + +vi.mock("../../gateway/method-scopes.js", () => ({ + resolveLeastPrivilegeOperatorScopesForMethod: () => ["operator.write"], +})); + +vi.mock("../../gateway/server-plugins.js", () => ({ + dispatchGatewayMethodInProcess: mocks.dispatch, + getInProcessGatewayRequestContext: vi.fn(), + hasInProcessGatewayContext: () => mocks.hasContext, +})); + +vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool })); + +import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js"; + +describe("trusted in-process Gateway session creation", () => { + beforeEach(() => { + mocks.hasContext = true; + mocks.dispatch.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" }); + mocks.callGatewayTool.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" }); + }); + + it("surfaces creation provenance only on in-process dispatch", async () => { + const creation = { + via: "spawn" as const, + actor: { type: "agent" as const, id: "agent:main:main" }, + }; + await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation); + + expect(mocks.dispatch).toHaveBeenCalledWith( + "sessions.create", + { agentId: "main" }, + { + forceSyntheticClient: true, + sessionCreation: creation, + syntheticScopes: ["operator.write"], + }, + ); + expect(mocks.callGatewayTool).not.toHaveBeenCalled(); + + mocks.hasContext = false; + await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation); + + expect(mocks.callGatewayTool).toHaveBeenCalledWith( + "sessions.create", + {}, + { agentId: "main" }, + { scopes: ["operator.write"] }, + ); + }); +}); diff --git a/src/agents/tools/in-process-gateway.ts b/src/agents/tools/in-process-gateway.ts index c1fd0a4a2bef..4f9206ae3e31 100644 --- a/src/agents/tools/in-process-gateway.ts +++ b/src/agents/tools/in-process-gateway.ts @@ -1,5 +1,6 @@ /** In-process Gateway calls for built-in agent tools. */ import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js"; +import type { TrustedSessionCreation } from "../../gateway/server-methods/session-creation-provenance.js"; import type { GatewayRequestContext } from "../../gateway/server-methods/types.js"; import { dispatchGatewayMethodInProcess, @@ -34,3 +35,20 @@ export const callInProcessGatewayTool: InProcessGatewayCaller = async ( } return await callGatewayTool(method, {}, params, { scopes }); }; + +export async function callInProcessGatewayToolWithCreation>( + method: string, + params: Record, + creation: TrustedSessionCreation, +): Promise { + const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method, params); + if (hasInProcessGatewayContext()) { + return await dispatchGatewayMethodInProcess(method, params, { + forceSyntheticClient: true, + sessionCreation: creation, + syntheticScopes: scopes, + }); + } + // The fallback is a real Gateway request; trusted creation metadata never crosses the wire. + return await callGatewayTool(method, {}, params, { scopes }); +} diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index 88aff6f1964c..d799fe2f9720 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -50,6 +50,10 @@ import { } from "../tool-description-presets.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNonNegativeIntegerParam, readStringParam } from "./common.js"; +import { + callInProcessGatewayToolWithCreation, + hasInProcessGatewayToolContext, +} from "./in-process-gateway.js"; import { runWithScopedSessionAccess } from "./scoped-session-access.js"; import { createSessionVisibilityGuard, @@ -189,6 +193,8 @@ async function ensureConfiguredAgentMainSession(params: { callGateway: GatewayCaller; sessionKey: string; mainKey: string; + requesterSessionKey?: string; + useTrustedInProcessCreation: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { if ( !isConfiguredAgentMainSessionKey({ @@ -209,14 +215,26 @@ async function ensureConfiguredAgentMainSession(params: { return { ok: true }; } catch { try { - await params.callGateway({ - method: "sessions.create", - params: { - key: params.sessionKey, - agentId: resolveAgentIdFromSessionKey(params.sessionKey), - }, - timeoutMs: 10_000, - }); + const createParams = { + key: params.sessionKey, + agentId: resolveAgentIdFromSessionKey(params.sessionKey), + }; + if ( + params.useTrustedInProcessCreation && + params.requesterSessionKey && + hasInProcessGatewayToolContext() + ) { + await callInProcessGatewayToolWithCreation("sessions.create", createParams, { + via: "internal", + actor: { type: "agent", id: params.requesterSessionKey }, + }); + } else { + await params.callGateway({ + method: "sessions.create", + params: createParams, + timeoutMs: 10_000, + }); + } return { ok: true }; } catch (err) { return { ok: false, error: formatErrorMessage(err) }; @@ -621,6 +639,8 @@ export function createSessionsSendTool(opts?: { callGateway: gatewayCall, sessionKey: resolvedKey, mainKey, + requesterSessionKey, + useTrustedInProcessCreation: opts?.callGateway === undefined, }); if (!ensuredSession.ok) { return jsonResult({ diff --git a/src/agents/tools/sessions-spawn-visible.ts b/src/agents/tools/sessions-spawn-visible.ts index a53d839a841d..af10076e7b84 100644 --- a/src/agents/tools/sessions-spawn-visible.ts +++ b/src/agents/tools/sessions-spawn-visible.ts @@ -24,7 +24,11 @@ import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js"; import { resolveConfiguredSubagentRunTimeoutSeconds } from "../subagent-spawn-plan.js"; import { resolveSubagentTargetPolicy } from "../subagent-target-policy.js"; import { normalizeToolModelOverride, readStringParam, ToolInputError } from "./common.js"; -import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js"; +import { + callInProcessGatewayTool, + callInProcessGatewayToolWithCreation, + type InProcessGatewayCaller, +} from "./in-process-gateway.js"; import { reserveVisibleChildSlot } from "./sessions-spawn-visible-admission.js"; export const VISIBLE_SESSIONS_SPAWN_SCHEMA = { @@ -274,7 +278,14 @@ export async function maybeSpawnVisibleSession(params: { } try { const gatewayCall = params.options?.callGateway ?? callInProcessGatewayTool; - const response = await gatewayCall<{ + const createGatewayCall: InProcessGatewayCaller = + params.options?.callGateway ?? + ((method, requestParams) => + callInProcessGatewayToolWithCreation(method, requestParams, { + via: "spawn", + actor: { type: "agent", id: requesterKey }, + })); + const response = await createGatewayCall<{ key?: string; runStarted?: boolean; runId?: string; diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 7cf4f09562b9..77b442f12e7b 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -10,6 +10,12 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { extractAssistantText, sanitizeTextContent } from "./chat-history-text.js"; const callGatewayMock = vi.fn(); +const inProcessCreationMock = vi.fn( + async (..._args: [unknown, unknown, unknown]): Promise => ({}), +); +// Default false mirrors running outside a gateway process; the trusted-creation +// regression test flips it on and restores it. +let inProcessGatewayContextAvailable = false; const facadeRuntimeMock = vi.hoisted(() => ({ sessionKeyResolvers: new Map< string, @@ -25,6 +31,11 @@ const facadeRuntimeMock = vi.hoisted(() => ({ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); +vi.mock("./in-process-gateway.js", () => ({ + callInProcessGatewayToolWithCreation: (method: unknown, params: unknown, creation: unknown) => + inProcessCreationMock(method, params, creation), + hasInProcessGatewayToolContext: () => inProcessGatewayContextAvailable, +})); vi.mock("../../plugin-sdk/facade-runtime.js", async () => { const actual = await vi.importActual( "../../plugin-sdk/facade-runtime.js", @@ -1243,4 +1254,63 @@ describe("sessions_send gating", () => { expect(waitTimeouts).toEqual([MAX_TIMER_TIMEOUT_MS]); }); }); + +describe("sessions_send agent-main materialization provenance", () => { + it("uses the trusted in-process creation stamp in the production assembly (no injected caller)", async () => { + inProcessGatewayContextAvailable = true; + inProcessCreationMock.mockClear(); + loadConfigMock.mockReturnValue({ + session: { scope: "per-sender", mainKey: "main" }, + tools: { + agentToAgent: { enabled: false }, + sessions: { visibility: "all" }, + }, + }); + callGatewayMock.mockImplementation(async (opts: unknown) => { + const request = opts as { method?: string }; + if (request.method === "sessions.resolve") { + // Unmaterialized agent main: the probe fails, forcing creation. + throw new Error("unknown session: agent:main:main"); + } + if (request.method === "sessions.create") { + throw new Error("plain sessions.create must not be used for trusted materialization"); + } + if (request.method === "chat.history") { + return { messages: [] }; + } + if (request.method === "agent") { + return { runId: "run-ensure-main", acceptedAt: 1 }; + } + return {}; + }); + // Mirror production assembly (openclaw-tools.ts): no callGateway override, so + // ensureConfiguredAgentMainSession takes the trusted in-process branch. + const tool = createSessionsSendTool({ + agentSessionKey: "agent:main:dashboard:req-provenance", + agentChannel: MAIN_AGENT_CHANNEL, + }); + + try { + const result = await tool.execute("call-ensure-main-provenance", { + sessionKey: "agent:main:main", + message: "wake up", + timeoutSeconds: 0, + }); + + expect(requireDetails(result).status).toBe("accepted"); + expect(inProcessCreationMock).toHaveBeenCalledTimes(1); + expect(inProcessCreationMock).toHaveBeenCalledWith( + "sessions.create", + { key: "agent:main:main", agentId: "main" }, + { + via: "internal", + actor: { type: "agent", id: "agent:main:dashboard:req-provenance" }, + }, + ); + } finally { + inProcessGatewayContextAvailable = false; + inProcessCreationMock.mockClear(); + } + }); +}); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index b11e572bd915..ccf60647bf9c 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -9,6 +9,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import { resolveStorePath } from "../../config/sessions/paths.js"; import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js"; +import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; @@ -214,8 +215,25 @@ export function initFastReplySessionState(params: { const sessionEntry: SessionEntry = { ...(!resetTriggered ? existingEntry : undefined), sessionId, - ...((resetTriggered || !existingEntry) && ctx.SessionCreator - ? { createdBy: { ...ctx.SessionCreator } } + ...(!existingEntry && ctx.SessionCreation + ? buildSessionCreationStamp(ctx.SessionCreation) + : {}), + ...(resetTriggered && existingEntry + ? { + previousSessionId: existingEntry.sessionId, + spawnedBy: existingEntry.spawnedBy, + spawnedWorkspaceDir: existingEntry.spawnedWorkspaceDir, + spawnedCwd: existingEntry.spawnedCwd, + parentSessionKey: existingEntry.parentSessionKey, + forkedFromParent: existingEntry.forkedFromParent, + forkSource: existingEntry.forkSource, + createdVia: existingEntry.createdVia, + createdActor: existingEntry.createdActor, + createdAt: existingEntry.createdAt, + spawnDepth: existingEntry.spawnDepth, + subagentRole: existingEntry.subagentRole, + subagentControlScope: existingEntry.subagentControlScope, + } : {}), sessionFile, updatedAt: now, diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index a1461df8cb8e..bb337b19720f 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -6,6 +6,7 @@ import { } from "../../agents/model-selection.js"; import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SkillCommandSpec } from "../../skills/types.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; @@ -169,6 +170,13 @@ export async function maybeResolveNativeSlashCommandFastReply(params: { }; } const persistedInitialEntry = persistence.entry; + if (creatingSession) { + recordSessionCreated({ + sessionKey: sessionState.sessionKey, + agentId: params.agentId, + entry: persistedInitialEntry, + }); + } // Commit the synthesized activity/channel touch before commands or directives // capture their own mutation baseline. sessionState.sessionEntry = persistedInitialEntry; diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 159ff224827e..b10ba8bedd61 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -13,6 +13,8 @@ import { MODEL_SELECTION_LOCKED_RESET_MESSAGE, ModelSelectionLockedError, } from "../../sessions/model-overrides.js"; +import { listSessionStateEventsSince } from "../../sessions/session-state-events.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import { handleGoalCommand } from "./commands-goal.js"; import { buildFastReplyCommandContext, initFastReplySessionState } from "./get-reply-fast-path.js"; @@ -200,6 +202,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { }); afterEach(() => { + closeOpenClawStateDatabaseForTest(); cliBackendsTesting.resetDepsForTest(); vi.unstubAllEnvs(); }); @@ -604,6 +607,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { it("handles native slash directives before workspace bootstrap", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-")); const targetSessionKey = "agent:main:telegram:123"; + vi.stubEnv("OPENCLAW_STATE_DIR", path.join(home, "state")); const cfg = markCompleteReplyConfig({ agents: { defaults: { @@ -628,6 +632,10 @@ describe("getReplyFromConfig fast test bootstrap", () => { CommandAuthorized: true, SessionKey: "telegram:slash:123", CommandTargetSessionKey: targetSessionKey, + SessionCreation: { + via: "operator", + actor: { type: "human", id: "profile-native-slash" }, + }, }), undefined, cfg, @@ -644,6 +652,13 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled(); expect(mocks.handleCommands).toHaveBeenCalledOnce(); expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce(); + expect(listSessionStateEventsSince(targetSessionKey, "main", 0, 20).events).toContainEqual( + expect.objectContaining({ + kind: "created", + actorType: "human", + actorId: "profile-native-slash", + }), + ); const directiveParams = requireDirectiveParams(); expect(directiveParams.sessionKey).toBe(targetSessionKey); expect(directiveParams.workspaceDir).toBe("/tmp/workspace"); @@ -759,6 +774,28 @@ describe("getReplyFromConfig fast test bootstrap", () => { ); }); + it("stamps trusted creation provenance during fast bootstrap", () => { + const result = initFastReplySessionState({ + ctx: buildGetReplyCtx({ + SessionKey: "agent:main:dashboard:created", + SessionCreation: { + via: "operator", + actor: { type: "human", id: "profile-ada" }, + }, + }), + cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: "/tmp/workspace", + }); + + expect(result.sessionEntry).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: "profile-ada" }, + createdAt: expect.any(Number), + }); + }); + it("preserves usage footer mode during fast reset bootstrap", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-usage-")); const storePath = path.join(home, "sessions.json"); @@ -788,6 +825,57 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(result.sessionEntry.responseUsage).toBe("full"); }); + it("preserves node provenance and lineage during fast reset bootstrap", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-lineage-")); + const storePath = path.join(home, "sessions.json"); + const sessionKey = "agent:main:telegram:lineage"; + await seedFastPathSessionStore(storePath, { + [sessionKey]: { + sessionId: "existing-fast-reset-lineage", + updatedAt: Date.now(), + spawnedBy: "agent:main:main", + parentSessionKey: "agent:main:dashboard:parent", + spawnedWorkspaceDir: "/tmp/workspace", + spawnedCwd: "/tmp/repo", + forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" }, + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_234, + spawnDepth: 2, + subagentRole: "orchestrator", + subagentControlScope: "children", + }, + }); + + const result = initFastReplySessionState({ + ctx: buildGetReplyCtx({ + Body: "/reset", + RawBody: "/reset", + CommandBody: "/reset", + SessionKey: sessionKey, + }), + cfg: { session: { store: storePath } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: home, + }); + + expect(result.sessionEntry).toMatchObject({ + previousSessionId: "existing-fast-reset-lineage", + spawnedBy: "agent:main:main", + parentSessionKey: "agent:main:dashboard:parent", + spawnedWorkspaceDir: "/tmp/workspace", + spawnedCwd: "/tmp/repo", + forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" }, + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_234, + spawnDepth: 2, + subagentRole: "orchestrator", + subagentControlScope: "children", + }); + }); + it("rejects a fast reset bootstrap for a model-locked session", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-locked-")); const storePath = path.join(home, "sessions.json"); diff --git a/src/auto-reply/reply/session-creator.test.ts b/src/auto-reply/reply/session-creator.test.ts deleted file mode 100644 index 3238403d8a65..000000000000 --- a/src/auto-reply/reply/session-creator.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; -import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; -import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; -import { initSessionState } from "./session.js"; - -let tempDir: string | undefined; - -afterEach(async () => { - closeOpenClawAgentDatabasesForTest(); - if (tempDir) { - await fs.rm(tempDir, { force: true, recursive: true }); - tempDir = undefined; - } -}); - -it("clears the previous creator when an ownerless turn starts a new generation", async () => { - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-")); - const storePath = path.join(tempDir, "sessions.json"); - const sessionKey = "agent:main:telegram:chat:creator"; - await upsertSessionEntry( - { sessionKey, storePath }, - { - createdBy: { id: "alice@example.com", label: "Alice" }, - sessionId: "owned-session", - updatedAt: 1, - }, - ); - - const result = await initSessionState({ - ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey }, - cfg: { session: { store: storePath } } as OpenClawConfig, - commandAuthorized: true, - }); - - expect(result.isNewSession).toBe(true); - expect(result.sessionEntry).not.toHaveProperty("createdBy"); -}); diff --git a/src/auto-reply/reply/session-fork.test.ts b/src/auto-reply/reply/session-fork.test.ts index 3f22f422334e..e08c8b672ff8 100644 --- a/src/auto-reply/reply/session-fork.test.ts +++ b/src/auto-reply/reply/session-fork.test.ts @@ -188,6 +188,10 @@ describe("forkSessionEntryFromParent", () => { const stored = loadSessionEntry({ agentId: "main", sessionKey, storePath }); expect(stored).toMatchObject({ forkedFromParent: true, + forkSource: { + sessionKey: parentSessionKey, + sessionId: "parent-session", + }, label: "forked child", sessionFile: result.fork.sessionFile, sessionId: result.fork.sessionId, @@ -269,6 +273,9 @@ describe("forkSessionEntryFromParent", () => { sessionId: "", updatedAt: expect.any(Number), }); + expect( + loadSessionEntry({ agentId: "main", sessionKey, storePath })?.forkSource, + ).toBeUndefined(); }); it("skips stale-token SQLite parents using transcript usage estimates", async () => { diff --git a/src/auto-reply/reply/session-parent-fork-prepare.ts b/src/auto-reply/reply/session-parent-fork-prepare.ts index 386bc195a281..4f8cafe25d42 100644 --- a/src/auto-reply/reply/session-parent-fork-prepare.ts +++ b/src/auto-reply/reply/session-parent-fork-prepare.ts @@ -60,6 +60,10 @@ export async function prepareReplySessionParentFork(params: { ...buildMainSessionRecoveryClearPatch(params.sessionEntry), sessionId: fork.sessionId, sessionFile: fork.sessionFile, + forkSource: { + sessionKey: params.parentSessionKey, + sessionId: parentEntry.sessionId, + }, forkedFromParent: true, totalTokens: undefined, totalTokensFresh: false, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index b713b5641247..77251770acae 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -36,6 +36,8 @@ import { isSessionLifecycleMutationActive, runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; +import { listSessionStateEventsSince } from "../../sessions/session-state-events.js"; +import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { createChannelTestPluginBase, createTestRegistry, @@ -579,6 +581,7 @@ beforeEach(() => { }); }); afterEach(async () => { + closeOpenClawStateDatabaseForTest(); resetSystemEventsForTest(); await sessionMcpTesting.resetSessionMcpRuntimeManager(); }); @@ -720,6 +723,10 @@ describe("initSessionState thread forking", () => { expect(result.sessionEntry.totalTokensFresh).toBe(false); expect(result.sessionEntry.forkedFromParent).toBe(true); + expect(result.sessionEntry.forkSource).toEqual({ + sessionKey: parentSessionKey, + sessionId: parentSessionId, + }); expect(result.sessionEntry.sessionFile).toBe( formatSqliteSessionFileMarker({ agentId: "main", @@ -811,6 +818,10 @@ describe("initSessionState thread forking", () => { expect(first.sessionEntry.sessionId).not.toBe("preseed-thread-session"); expect(first.sessionEntry.forkedFromParent).toBe(true); + expect(first.sessionEntry.forkSource).toEqual({ + sessionKey: parentSessionKey, + sessionId: parentSessionId, + }); expect(first.sessionEntry.totalTokens).toBeUndefined(); expect(first.sessionEntry.totalTokensFresh).toBe(false); expect(first.sessionEntry.abortedLastRun).toBe(false); @@ -899,6 +910,7 @@ describe("initSessionState thread forking", () => { // Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent expect(result.sessionEntry.forkedFromParent).toBe(true); + expect(result.sessionEntry.forkSource).toBeUndefined(); // Session ID should NOT match the parent — it should be a fresh UUID expect(result.sessionEntry.sessionId).not.toBe(parentSessionId); // Session file should NOT be the parent's file (it was not forked) @@ -1308,6 +1320,97 @@ describe("initSessionState RawBody", () => { expect(store[sessionKey]?.modelOverrideSource).toBe("user"); }); + it("stamps trusted creation provenance when initializing a missing session", async () => { + const root = await makeCaseDir("openclaw-session-creation-provenance-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:dashboard:created"; + + const result = await withEnvAsync( + { OPENCLAW_STATE_DIR: path.join(root, "state") }, + async () => { + const initialized = await initSessionState({ + ctx: { + RawBody: "hello", + ChatType: "direct", + SessionKey: sessionKey, + SessionCreation: { + via: "operator", + actor: { type: "human", id: "profile-ada" }, + }, + }, + cfg: { session: { store: storePath } } as OpenClawConfig, + commandAuthorized: true, + }); + expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual( + expect.objectContaining({ + kind: "created", + actorType: "human", + actorId: "profile-ada", + }), + ); + return initialized; + }, + ); + expect(result.sessionEntry).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: "profile-ada" }, + createdAt: expect.any(Number), + }); + }); + + it("preserves session lineage across an implicit daily stale rollover (#90119)", async () => { + const root = await makeCaseDir("openclaw-daily-rollover-lineage-"); + const storePath = path.join(root, "sessions.json"); + const sessionKey = "agent:main:subagent:daily-rollover-lineage"; + const existingSessionId = "session-before-daily-reset-lineage"; + const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000; + const lineage = { + spawnedBy: "agent:main:main", + spawnedWorkspaceDir: "/tmp/child-workspace", + spawnedCwd: "/tmp/task-repo", + parentSessionKey: "agent:main:main", + forkedFromParent: true, + forkSource: { + sessionKey: "agent:main:root", + sessionId: "root-transcript-generation", + }, + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: staleStartedAt - 1_000, + spawnDepth: 1, + subagentRole: "leaf", + subagentControlScope: "none", + } as const; + + await writeSessionStoreFast(storePath, { + [sessionKey]: { + sessionId: existingSessionId, + updatedAt: staleStartedAt, + sessionStartedAt: staleStartedAt, + lastInteractionAt: staleStartedAt, + ...lineage, + }, + }); + + const result = await initSessionState({ + ctx: { + RawBody: "continue child work", + ChatType: "direct", + SessionKey: sessionKey, + }, + cfg: { + session: { store: storePath, reset: { mode: "daily", atHour: 4 } }, + } as OpenClawConfig, + commandAuthorized: true, + }); + + expect(result.isNewSession).toBe(true); + expect(result.resetTriggered).toBe(false); + expect(result.sessionId).not.toBe(existingSessionId); + expect(result.sessionEntry.previousSessionId).toBe(existingSessionId); + expectEntryFields(result.sessionEntry, lineage); + }); + it("preserves user-set behavior and pinned state across an implicit daily stale rollover (#92562)", async () => { // Regression: session-level behavior overrides (/think, /verbose, /reasoning, // /trace, ttsAuto) survive an explicit /new but were dropped after the @@ -3979,6 +4082,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset", const overrides = { spawnedBy: "agent:main:main", spawnedWorkspaceDir: "/tmp/child-workspace", + spawnedCwd: "/tmp/task-repo", parentSessionKey: "agent:main:main", forkedFromParent: true, spawnDepth: 2, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 0117bb6df6cf..a6771b5af53c 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -34,6 +34,8 @@ import { commitReplySessionInitialization, loadReplySessionInitializationSnapshot, } from "../../config/sessions/session-accessor.js"; +import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js"; +import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { resolveSessionKey } from "../../config/sessions/session-key.js"; import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js"; import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js"; @@ -77,6 +79,7 @@ import { interruptSessionWorkAdmissions, runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; +import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { classifySessionStateActor, registerMainSessionGroupWatch, @@ -477,6 +480,10 @@ async function initSessionStateAttemptLocked( let persistedSpawnedCwd: SessionEntry["spawnedCwd"]; let persistedParentSessionKey: SessionEntry["parentSessionKey"]; let persistedForkedFromParent: SessionEntry["forkedFromParent"]; + let persistedForkSource: SessionEntry["forkSource"]; + let persistedCreatedVia: SessionEntry["createdVia"]; + let persistedCreatedActor: SessionEntry["createdActor"]; + let persistedCreatedAt: SessionEntry["createdAt"]; let persistedSpawnDepth: SessionEntry["spawnDepth"]; let persistedSubagentRole: SessionEntry["subagentRole"]; let persistedSubagentControlScope: SessionEntry["subagentControlScope"]; @@ -584,6 +591,7 @@ async function initSessionStateAttemptLocked( ctx, }); const entry = initializationSnapshot.currentEntry; + const createdNewEntry = entry === undefined; const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry); if (archivedSessionError) { throw new Error(archivedSessionError); @@ -804,17 +812,18 @@ async function initSessionStateAttemptLocked( persistedResponseUsage = entry.responseUsage; persistedLabel = entry.label; persistedDisplayName = entry.displayName; - } - // When a reset trigger (/new, /reset) starts a new session, also rotate the - // underlying CLI conversation and carry forward spawn lineage. - if (resetTriggered && entry) { - // Explicit /new and /reset should rotate the underlying CLI conversation too. - // Keep the model/auth choice, but force the next turn to mint a fresh CLI binding. + // Explicit /new and /reset rotate CLI conversation bindings elsewhere. + // Lineage/control facts belong to the session node and survive ANY rollover + // from an existing entry, following the #90119 carry pattern. persistedSpawnedBy = entry.spawnedBy; persistedSpawnedWorkspaceDir = entry.spawnedWorkspaceDir; persistedSpawnedCwd = entry.spawnedCwd; persistedParentSessionKey = entry.parentSessionKey; persistedForkedFromParent = entry.forkedFromParent; + persistedForkSource = entry.forkSource; + persistedCreatedVia = entry.createdVia; + persistedCreatedActor = entry.createdActor; + persistedCreatedAt = entry.createdAt; persistedSpawnDepth = entry.spawnDepth; persistedSubagentRole = entry.subagentRole; persistedSubagentControlScope = entry.subagentControlScope; @@ -902,16 +911,11 @@ async function initSessionStateAttemptLocked( const lastTo = deliveryFields.lastTo ?? lastToRaw; const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw; const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw; + const creationStamp = + !entry && ctx.SessionCreation ? buildSessionCreationStamp(ctx.SessionCreation) : undefined; sessionEntry = { ...baseEntry, sessionId, - ...(isNewSession - ? ctx.SessionCreator - ? { createdBy: { ...ctx.SessionCreator } } - : {} - : baseEntry?.createdBy - ? { createdBy: baseEntry.createdBy } - : {}), updatedAt: Date.now(), sessionStartedAt: isNewSession ? now @@ -930,6 +934,7 @@ async function initSessionStateAttemptLocked( pinnedAt: entry?.pinnedAt, usageFamilyKey, usageFamilySessionIds, + previousSessionId: previousSessionEntry?.sessionId ?? baseEntry?.previousSessionId, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, modelOverrideSource: persistedModelOverrideSource ?? baseEntry?.modelOverrideSource, @@ -947,6 +952,10 @@ async function initSessionStateAttemptLocked( spawnedCwd: persistedSpawnedCwd ?? baseEntry?.spawnedCwd, parentSessionKey: persistedParentSessionKey ?? baseEntry?.parentSessionKey, forkedFromParent: persistedForkedFromParent ?? baseEntry?.forkedFromParent, + forkSource: persistedForkSource ?? baseEntry?.forkSource, + createdVia: persistedCreatedVia ?? baseEntry?.createdVia ?? creationStamp?.createdVia, + createdActor: persistedCreatedActor ?? baseEntry?.createdActor ?? creationStamp?.createdActor, + createdAt: persistedCreatedAt ?? baseEntry?.createdAt ?? creationStamp?.createdAt, spawnDepth: persistedSpawnDepth ?? baseEntry?.spawnDepth, subagentRole: persistedSubagentRole ?? baseEntry?.subagentRole, subagentControlScope: persistedSubagentControlScope ?? baseEntry?.subagentControlScope, @@ -999,7 +1008,7 @@ async function initSessionStateAttemptLocked( sessionEntry.displayName = threadLabel; } const parentSessionKey = normalizeOptionalString(ctx.ParentSessionKey); - const alreadyForked = sessionEntry.forkedFromParent === true; + const alreadyForked = sessionEntryForkedFromParent(sessionEntry); if (params.signal?.aborted === true) { throw new Error("reply session initialization aborted"); } @@ -1093,6 +1102,9 @@ async function initSessionStateAttemptLocked( } sessionEntry = committed.sessionEntry; sessionId = sessionEntry.sessionId; + if (createdNewEntry) { + recordSessionCreated({ sessionKey, agentId, entry: sessionEntry }); + } if ( !isSystemEvent && classifySessionStateActor({ inputProvenance: ctx.InputProvenance }).actorType === "human" diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 6179cef154db..186858010fa9 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -276,8 +276,11 @@ export type MsgContext = { OwnerAllowFrom?: Array; SenderName?: string; SenderId?: string; - /** Trusted Gateway operator identity used only when creating a session. */ - SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity; + /** Trusted in-process creation provenance; never populated from channel payloads. */ + SessionCreation?: { + via: import("../config/sessions/session-entry-provenance.js").SessionCreatedVia; + actor?: import("../config/sessions/session-entry-provenance.js").SessionCreatedActor; + }; SenderUsername?: string; SenderTag?: string; SenderE164?: string; diff --git a/src/commands/sessions-table.ts b/src/commands/sessions-table.ts index 9374eea1a833..f00ef0630b40 100644 --- a/src/commands/sessions-table.ts +++ b/src/commands/sessions-table.ts @@ -7,6 +7,7 @@ import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { theme } from "../../packages/terminal-core/src/theme.js"; import type { SessionEntry } from "../config/sessions.js"; +import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; /** Display row derived from a persisted session entry. */ @@ -66,7 +67,7 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi spawnedWorkspaceDir: entry?.spawnedWorkspaceDir, spawnedCwd: entry?.spawnedCwd, parentSessionKey: entry?.parentSessionKey, - forkedFromParent: entry?.forkedFromParent, + forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined, spawnDepth: entry?.spawnDepth, subagentRole: entry?.subagentRole, subagentControlScope: entry?.subagentControlScope, diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index 01c5fd9eef5a..f7a88069de51 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1258,7 +1258,11 @@ describe("sqlite session normalization", () => { await upsertSqliteSessionEntry(scope, { channel: "telegram", chatType: "group", + createdVia: "channel", + createdActor: { type: "human", id: "telegram-sender" }, + createdAt: 1_782_973_390_000, displayName: "telegram:g-bucephalus-+-topics", + forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" }, sessionId: oldSessionId, status: "killed", updatedAt: 1_782_973_392_492, @@ -1298,6 +1302,11 @@ describe("sqlite session normalization", () => { expect(loadSqliteSessionEntry(scope)).toEqual( expect.objectContaining({ sessionId: newSessionId, + createdVia: "channel", + createdActor: { type: "human", id: "telegram-sender" }, + createdAt: 1_782_973_390_000, + forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" }, + previousSessionId: oldSessionId, usageFamilyKey: sessionKey, usageFamilySessionIds: [oldSessionId, newSessionId], }), diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index 112924395719..91dabf0b9846 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -25,7 +25,6 @@ import { import { normalizeSqliteStatus, parseSqliteSessionEntryJson as parseSessionEntryRow, - serializeSqliteSessionCreatorIdentity, } from "./session-accessor.sqlite-status.js"; import { readTranscriptMutationStateInTransaction, @@ -488,7 +487,6 @@ export function writeSessionEntry( entry_json: JSON.stringify(normalizedEntry), updated_at: updatedAt, status: normalizeSqliteStatus(normalizedEntry.status), - created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy), }) .onConflict((conflict) => conflict.column("session_key").doUpdateSet({ @@ -496,7 +494,6 @@ export function writeSessionEntry( entry_json: JSON.stringify(normalizedEntry), updated_at: updatedAt, status: normalizeSqliteStatus(normalizedEntry.status), - created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy), }), ), ); diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index e4380cdb5a57..0343e6dfce55 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -64,6 +64,7 @@ import { readSqliteSessionEntriesByStatus, } from "./session-accessor.sqlite-status.js"; import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js"; +import { buildSessionCreationStamp } from "./session-entry-provenance.js"; import { kickSessionHistoryDiskBudgetMaintenance } from "./session-history-eviction.js"; import { resolveSessionStorePathForScope } from "./session-store-path.js"; import type { GroupKeyResolution, SessionEntry } from "./types.js"; @@ -419,13 +420,27 @@ export async function recordSqliteInboundSessionMeta(params: { const createIfMissing = params.createIfMissing ?? true; return await patchSqliteSessionEntry( { sessionKey: params.sessionKey, storePath: params.storePath }, - (_entry, context) => - deriveSessionMetaPatch({ + (_entry, context) => { + const metadataPatch = deriveSessionMetaPatch({ ctx: params.ctx, sessionKey: params.sessionKey, existing: context.existingEntry, groupResolution: params.groupResolution, - }), + }); + if (context.existingEntry) { + return metadataPatch; + } + const senderId = params.ctx.From?.trim(); + return { + ...buildSessionCreationStamp( + params.ctx.SessionCreation ?? { + via: "channel", + actor: { type: "human", ...(senderId ? { id: senderId } : {}) }, + }, + ), + ...metadataPatch, + }; + }, { // Inbound metadata must not refresh activity timestamps; idle reset // evaluation relies on updatedAt from actual session turns. @@ -452,8 +467,8 @@ export async function updateSqliteSessionLastRoute(params: { const createIfMissing = params.createIfMissing ?? true; return await patchSqliteSessionEntry( { sessionKey: params.sessionKey, storePath: params.storePath }, - (_entry, context) => - deriveLastRoutePatch({ + (_entry, context) => { + const routePatch = deriveLastRoutePatch({ channel: params.channel, to: params.to, accountId: params.accountId, @@ -464,7 +479,23 @@ export async function updateSqliteSessionLastRoute(params: { groupResolution: params.groupResolution, existing: context.existingEntry, sessionKey: params.sessionKey, - }), + }); + if (context.existingEntry) { + return routePatch; + } + const senderId = params.ctx?.From?.trim(); + return { + ...buildSessionCreationStamp( + params.ctx?.SessionCreation ?? { + via: "channel", + ...(params.ctx + ? { actor: { type: "human" as const, ...(senderId ? { id: senderId } : {}) } } + : {}), + }, + ), + ...routePatch, + }; + }, { // Route updates must not refresh activity timestamps (#49515). preserveActivity: true, diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index 5af4dfec76d1..5684fbc6e0ac 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -37,7 +37,11 @@ async function createSession(options: { activeLeafTarget?: string } = {}) { cliSessionIds: { "claude-cli": "claude-conversation" }, compactionCount: 2, contextTokens: 100_000, + createdVia: "operator", + createdActor: { type: "human", id: "profile-1" }, + createdAt: 1_000, deliveryContext: { channel: "telegram", to: "chat-123" }, + forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" }, lastChannel: "telegram", lastTo: "chat-123", lifecycleRevision: "source-lifecycle-revision", @@ -210,6 +214,11 @@ describe("SQLite session message cuts", () => { cliSessionIds: undefined, compactionCount: undefined, contextTokens: undefined, + createdVia: "operator", + createdActor: { type: "human", id: "profile-1" }, + createdAt: 1_000, + forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" }, + previousSessionId: "message-cut-source", }); expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" }); }); @@ -274,6 +283,16 @@ describe("SQLite session message cuts", () => { expect(result.entry.lastChannel).toBeUndefined(); expect(result.entry.lastTo).toBeUndefined(); expect(result.entry.parentSessionKey).toBe(canonicalSourceKey); + expect(result.entry.previousSessionId).toBeUndefined(); + expect(result.entry.forkedFromParent).toBeUndefined(); + expect(result.entry.createdVia).toBeUndefined(); + expect(result.entry.createdActor).toBeUndefined(); + expect(result.entry.createdAt).toBeUndefined(); + expect(result.entry.forkSource).toEqual({ + sessionKey: canonicalSourceKey, + sessionId: "message-cut-source", + entryId: "user-2", + }); expect(result.entry).toMatchObject({ modelOverride: "gpt-5", modelOverrideSource: "user", diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 50221d321bc1..0c450679d2b7 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -33,6 +33,7 @@ import type { SessionMessageCutMutationParams, SessionMessageCutMutationResult, } from "./session-accessor.types.js"; +import { buildSessionCreationStamp } from "./session-entry-provenance.js"; import { inheritSessionSelection } from "./session-entry-selection.js"; import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js"; import { parseSqliteSessionFileMarker } from "./sqlite-marker.js"; @@ -142,6 +143,7 @@ async function mutateSqliteSessionAtMessage( result = mutateSqliteSessionAtMessageInTransaction(database, resolved, { entryId: params.entryId, canonicalSourceKey, + creation: params.creation, mode, sourceKey, targetKey, @@ -158,6 +160,7 @@ function mutateSqliteSessionAtMessageInTransaction( resolved: ResolvedSqliteScope, params: { canonicalSourceKey: string; + creation?: SessionMessageCutMutationParams["creation"]; entryId: string; mode: SessionTranscriptMutationMode; sourceKey: string; @@ -215,13 +218,25 @@ function mutateSqliteSessionAtMessageInTransaction( // Rotating transcript identity fences stale live managers: later snapshot-replace writes // target the old session and cannot erase this leaf repoint from the active session. - const nextEntry = cloneMessageCutSessionEntry({ - currentEntry, - forked: params.mode === "fork", - nextSessionFile, - nextSessionId, - parentSessionKey: params.mode === "fork" ? params.canonicalSourceKey : undefined, - }); + const nextEntry = { + ...cloneMessageCutSessionEntry({ + currentEntry, + forked: params.mode === "fork", + forkSource: + params.mode === "fork" + ? { + sessionKey: params.canonicalSourceKey, + sessionId: currentEntry.sessionId, + entryId: params.entryId, + } + : undefined, + nextSessionFile, + nextSessionId, + }), + ...(params.mode === "fork" && params.creation + ? buildSessionCreationStamp(params.creation) + : {}), + }; writeSessionEntry(database, params.targetKey, nextEntry); return { status: "created", @@ -356,9 +371,9 @@ function resolveMessageCut( function cloneMessageCutSessionEntry(params: { currentEntry: SessionEntry; forked: boolean; + forkSource?: NonNullable; nextSessionFile: string; nextSessionId: string; - parentSessionKey?: string; }): SessionEntry { const baseEntry = params.forked ? inheritSessionSelection(params.currentEntry) @@ -407,7 +422,10 @@ function cloneMessageCutSessionEntry(params: { abortCutoffTimestamp: undefined, usageFamilyKey: params.forked ? undefined : params.currentEntry.usageFamilyKey, usageFamilySessionIds: params.forked ? undefined : params.currentEntry.usageFamilySessionIds, - ...(params.parentSessionKey ? { parentSessionKey: params.parentSessionKey } : {}), + previousSessionId: params.forked ? undefined : params.currentEntry.sessionId, + ...(params.forkSource + ? { forkSource: params.forkSource, parentSessionKey: params.forkSource.sessionKey } + : {}), }; } diff --git a/src/config/sessions/session-accessor.sqlite-parent-session.ts b/src/config/sessions/session-accessor.sqlite-parent-session.ts index d0e0518a680d..55fc923fb45f 100644 --- a/src/config/sessions/session-accessor.sqlite-parent-session.ts +++ b/src/config/sessions/session-accessor.sqlite-parent-session.ts @@ -218,6 +218,10 @@ export async function forkSqliteSessionEntryFromParentTarget( }); const next = mergeSessionEntry(freshBase, { ...patch, + forkSource: { + sessionKey: parentTarget.canonicalKey, + sessionId: freshParent.sessionId, + }, forkedFromParent: true, sessionFile: fork.transcript.sessionFile, sessionId: fork.transcript.sessionId, diff --git a/src/config/sessions/session-accessor.sqlite-status.ts b/src/config/sessions/session-accessor.sqlite-status.ts index d125210aa008..d73ccc88b5cb 100644 --- a/src/config/sessions/session-accessor.sqlite-status.ts +++ b/src/config/sessions/session-accessor.sqlite-status.ts @@ -19,42 +19,12 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null : null; } -function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - const candidate = value as { id?: unknown; label?: unknown }; - const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; - if (!id) { - return undefined; - } - const label = typeof candidate.label === "string" ? candidate.label.trim() : ""; - return { id, ...(label ? { label } : {}) }; -} - -export function serializeSqliteSessionCreatorIdentity( - createdBy: SessionEntry["createdBy"], -): string | null { - const normalized = normalizeSessionCreatorIdentity(createdBy); - return normalized ? JSON.stringify(normalized) : null; -} - export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null { try { const parsed = JSON.parse(row.entry_json) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return null; - } - const entry = parsed as SessionEntry; - // entry_json stays authoritative across downgrade/upgrade cycles: an older - // binary can rewrite it without knowing about the additive projection column. - const createdBy = normalizeSessionCreatorIdentity(entry.createdBy); - if (createdBy) { - entry.createdBy = createdBy; - } else { - delete entry.createdBy; - } - return entry; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as SessionEntry) + : null; } catch { return null; } diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 233d7cac1a4f..19f225ff2279 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -105,7 +105,6 @@ describe("session accessor seam", () => { }; await upsertSessionEntry(scope, { - createdBy: { id: "profile-ada", label: "Ada Lovelace" }, model: "gpt-5.5", sessionId: "session-1", updatedAt: 10, @@ -113,20 +112,10 @@ describe("session accessor seam", () => { expect(loadSessionEntry(scope)).toMatchObject({ model: "gpt-5.5", - createdBy: { id: "profile-ada", label: "Ada Lovelace" }, sessionId: "session-1", updatedAt: expect.any(Number), }); expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number)); - const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, { - agentId: "main", - }).path; - const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath }); - expect( - database.db - .prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?") - .get(scope.sessionKey), - ).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' }); expect(listSessionEntries({ storePath })).toEqual([ { sessionKey: "agent:main:main", @@ -138,17 +127,6 @@ describe("session accessor seam", () => { }, ]); - // A downgraded writer knows only entry_json and can leave the additive - // projection untouched. Re-upgrade must not resurrect that stale creator. - database.db - .prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?") - .run( - JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }), - 15, - scope.sessionKey, - ); - expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy"); - await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 }); expect(loadSessionEntry(scope)).toMatchObject({ @@ -156,7 +134,6 @@ describe("session accessor seam", () => { sessionId: "session-1", updatedAt: expect.any(Number), }); - expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy"); }); it("lists retained transcript instances across same-key session rotation", async () => { @@ -496,12 +473,48 @@ describe("session accessor seam", () => { const recorded = await recordInboundSessionMeta({ storePath, sessionKey, ctx }); expect(recorded?.origin?.provider).toBe("webchat"); + expect(recorded).toMatchObject({ + createdVia: "channel", + createdActor: { type: "human", id: "webchat:user-1" }, + createdAt: expect.any(Number), + }); + const creationStamp = { + createdVia: recorded?.createdVia, + createdActor: recorded?.createdActor, + createdAt: recorded?.createdAt, + }; + + await recordInboundSessionMeta({ + storePath, + sessionKey, + ctx: { ...ctx, From: "webchat:different-sender" }, + }); + expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject(creationStamp); // Detached result: caller mutations must never leak into cached store state. if (recorded) { recorded.origin = { provider: "mutated" }; } expect(loadSessionEntry({ sessionKey, storePath })?.origin?.provider).toBe("webchat"); + + const operatorKey = "agent:main:dashboard:operator-created"; + const operator = await recordInboundSessionMeta({ + storePath, + sessionKey: operatorKey, + ctx: { + ...ctx, + SessionKey: operatorKey, + SessionCreation: { + via: "operator", + actor: { type: "human", id: "profile-ada" }, + }, + }, + }); + expect(operator).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: "profile-ada" }, + createdAt: expect.any(Number), + }); }); it("does not create sessions when inbound meta recording opts out of upsert", async () => { diff --git a/src/config/sessions/session-accessor.types.ts b/src/config/sessions/session-accessor.types.ts index 6394fbb36684..0843606bd1c4 100644 --- a/src/config/sessions/session-accessor.types.ts +++ b/src/config/sessions/session-accessor.types.ts @@ -665,6 +665,10 @@ export type SessionMessageCutMutationResult = export type SessionMessageCutMutationParams = { agentId?: string; + creation?: { + via: import("./session-entry-provenance.js").SessionCreatedVia; + actor?: import("./session-entry-provenance.js").SessionCreatedActor; + }; entryId: string; env?: NodeJS.ProcessEnv; sessionKey: string; diff --git a/src/config/sessions/session-entry-lineage.ts b/src/config/sessions/session-entry-lineage.ts index 97e9a095b73c..70ad3974c192 100644 --- a/src/config/sessions/session-entry-lineage.ts +++ b/src/config/sessions/session-entry-lineage.ts @@ -1,6 +1,13 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { SessionEntry } from "./types.js"; +/** True when this entry's transcript began as a copy of a parent (actual forkSource ancestry or the legacy/thread-settled marker). */ +export function sessionEntryForkedFromParent( + entry: Pick | undefined, +): boolean { + return entry?.forkSource !== undefined || entry?.forkedFromParent === true; +} + export function preserveSqliteSameKeySessionRolloverLineage(params: { next: SessionEntry; previous: SessionEntry; @@ -13,6 +20,7 @@ export function preserveSqliteSameKeySessionRolloverLineage(params: { } return { ...params.next, + previousSessionId, usageFamilyKey: params.next.usageFamilyKey ?? params.previous.usageFamilyKey ?? params.sessionKey, usageFamilySessionIds: uniqueStrings([ diff --git a/src/config/sessions/session-entry-provenance.ts b/src/config/sessions/session-entry-provenance.ts index 79f4a5255160..804f05f08509 100644 --- a/src/config/sessions/session-entry-provenance.ts +++ b/src/config/sessions/session-entry-provenance.ts @@ -1,5 +1,31 @@ import type { HookExternalContentSource } from "../../security/external-content.js"; +/** Kept aligned with SessionStateActorType (src/sessions/session-state-event-kinds.ts); not imported to avoid layering config/sessions onto src/sessions. */ +export type SessionCreatedActor = { type: "human" | "agent" | "system"; id?: string }; +export type SessionCreatedVia = + | "operator" // gateway sessions.create (Control UI / operator clients) + | "spawn" // sessions_spawn native or ACP subagent spawn + | "channel" // inbound channel conversation materialization + | "cron" + | "talk" + | "run" // create-on-run materialization (agent-session-persist) + | "plugin" // trusted plugin runtime creation + | "internal"; // internal/hidden sessions (internal-session-effects, voice bare rows) + +// Return shape mirrors the SessionEntry creation fields as a leaf contract; +// types.ts imports from here, never the reverse (madge cycle guard). +export function buildSessionCreationStamp(params: { + via: SessionCreatedVia; + actor?: SessionCreatedActor; + now?: number; +}): { createdVia: SessionCreatedVia; createdActor?: SessionCreatedActor; createdAt: number } { + return { + createdVia: params.via, + ...(params.actor ? { createdActor: params.actor } : {}), + createdAt: params.now ?? Date.now(), + }; +} + export type SessionEntryProvenance = { /** Plugin id that owns this session through a trusted runtime creation seam. */ pluginOwnerId?: string; diff --git a/src/config/sessions/session-transcript-search.test.ts b/src/config/sessions/session-transcript-search.test.ts index 0264021ef07f..14f161410eee 100644 --- a/src/config/sessions/session-transcript-search.test.ts +++ b/src/config/sessions/session-transcript-search.test.ts @@ -74,7 +74,8 @@ function search(query: string, options: { limit?: number; sessionKeys?: string[] async function waitForSearchReconcile(query: string): Promise { await vi.waitFor(() => expect(search(query).indexing).toBe(false), { interval: 10, - timeout: 5_000, + // Compact CI shards can delay the asynchronous index worker beyond five seconds. + timeout: 15_000, }); } diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 6547d58059f5..a2d705dcb72a 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -19,6 +19,7 @@ import { import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js"; import { mergeRestartRecoveryTerminalRunIds } from "./restart-recovery-state.js"; import { loadSessionEntry } from "./session-accessor.js"; +import { buildSessionCreationStamp } from "./session-entry-provenance.js"; import { resolveAndPersistSessionFile } from "./session-file.js"; import { formatSqliteSessionFileMarker } from "./sqlite-marker.js"; import { readSessionStoreCache, writeSessionStoreCache } from "./store-cache.js"; @@ -453,6 +454,28 @@ describe("session store writer queue", () => { expect(store["agent:main:array"]).toBeUndefined(); }); + it("round-trips durable session creation and lineage fields", async () => { + const key = "agent:main:lineage-roundtrip"; + const entry: SessionEntry = { + sessionId: "lineage-roundtrip-session", + updatedAt: 200, + createdVia: "operator", + createdActor: { type: "human", id: "profile-1" }, + createdAt: 100, + forkSource: { + sessionKey: "agent:main:source", + sessionId: "source-session", + entryId: "source-entry", + }, + previousSessionId: "previous-generation", + }; + const { storePath } = await makeTmpStore(); + + await saveSessionStore(storePath, { [key]: entry }, { skipMaintenance: true }); + + expect(loadSessionStore(storePath, { skipCache: true })[key]).toEqual(entry); + }); + it("strips malformed pending final-delivery fields on load", async () => { const { storePath } = await makeTmpStore({ "agent:main:bad-pending": { @@ -1117,6 +1140,64 @@ describe("session store writer queue", () => { expect(merged.modelProvider).toBeUndefined(); }); + it("builds a deterministic session creation stamp", () => { + expect( + buildSessionCreationStamp({ + via: "spawn", + actor: { type: "agent", id: "agent:main:requester" }, + now: 123, + }), + ).toEqual({ + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:requester" }, + createdAt: 123, + }); + }); + + it("keeps session creation and fork ancestry fields write-once", () => { + const existing: SessionEntry = { + sessionId: "session-write-once", + updatedAt: 100, + createdVia: "channel", + createdActor: { type: "human", id: "sender-1" }, + createdAt: 50, + forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" }, + }; + + expect( + mergeSessionEntry(existing, { + createdVia: undefined, + createdActor: { type: "system", id: "replacement" }, + createdAt: 200, + forkSource: undefined, + }), + ).toMatchObject({ + createdVia: "channel", + createdActor: { type: "human", id: "sender-1" }, + createdAt: 50, + forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" }, + }); + }); + + it("fills absent session creation and fork ancestry fields", () => { + expect( + mergeSessionEntry( + { sessionId: "session-fill", updatedAt: 100 }, + { + createdVia: "internal", + createdActor: { type: "system" }, + createdAt: 75, + forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" }, + }, + ), + ).toMatchObject({ + createdVia: "internal", + createdActor: { type: "system" }, + createdAt: 75, + forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" }, + }); + }); + it("rewrites generated sessionFile paths when session id changes", () => { const previousSessionId = "11111111-1111-4111-8111-111111111111"; const nextSessionId = "22222222-2222-4222-8222-222222222222"; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 17f8323f4288..c6a9f3953254 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -7,7 +7,6 @@ import type { } from "@openclaw/acp-core/types"; import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; -import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; @@ -17,7 +16,11 @@ import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { TtsAutoMode } from "../types.tts.js"; import type { MainRestartRecoveryState } from "./main-session-recovery.types.js"; import type { SessionRestartRecoveryState } from "./restart-recovery-types.js"; -import type { SessionEntryProvenance } from "./session-entry-provenance.js"; +import type { + SessionCreatedActor, + SessionCreatedVia, + SessionEntryProvenance, +} from "./session-entry-provenance.js"; import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js"; import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js"; @@ -252,8 +255,6 @@ export type SessionEntry = SessionRestartRecoveryState & /** Durable one-shot prompt additions drained before the next agent turn. */ pluginNextTurnInjections?: Record; sessionId: string; - /** Operator identity captured once for this session generation. */ - createdBy?: SessionCreatorIdentity; updatedAt: number; /** Opaque owner revision used to reject stale lifecycle mutations. */ lifecycleRevision?: string; @@ -294,7 +295,17 @@ export type SessionEntry = SessionRestartRecoveryState & worktree?: { id: string; branch: string; repoRoot: string }; /** Explicit parent session linkage for dashboard-created child sessions. */ parentSessionKey?: string; - /** True after a thread/topic session has been forked from its parent transcript once. */ + /** How this session node came to exist; written once and retained across sessionId rotations. */ + createdVia?: SessionCreatedVia; + /** Actor that caused node creation, with an optional profile, session, or sender id; written once. */ + createdActor?: SessionCreatedActor; + /** Node creation time (ms); unlike sessionStartedAt, survives sessionId rotations. */ + createdAt?: number; + /** Exact source generation and optional cut entry for an actual transcript-copy fork. */ + forkSource?: { sessionKey: string; sessionId: string; entryId?: string }; + /** Session id of the prior transcript generation under this same session key. */ + previousSessionId?: string; + /** Thread parent-seeding settled marker; also set when seeding is deliberately skipped. */ forkedFromParent?: boolean; /** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */ spawnDepth?: number; @@ -671,6 +682,20 @@ function mergeSessionEntryWithPolicy( (existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt), }; + // Node creation and exact fork ancestry are write-once; patches may only fill absent values. + if (existing.createdVia !== undefined) { + next.createdVia = existing.createdVia; + } + if (existing.createdActor !== undefined) { + next.createdActor = existing.createdActor; + } + if (existing.createdAt !== undefined) { + next.createdAt = existing.createdAt; + } + if (existing.forkSource !== undefined) { + next.forkSource = existing.forkSource; + } + if (existing.sessionId !== sessionId) { // Session id rotations should move transcript paths when they match known reset/fork shapes. const patchHasSessionFile = Object.hasOwn(patch, "sessionFile"); diff --git a/src/cron/isolated-agent/run-session-state.test.ts b/src/cron/isolated-agent/run-session-state.test.ts index 0efbd9c57429..b236412651ca 100644 --- a/src/cron/isolated-agent/run-session-state.test.ts +++ b/src/cron/isolated-agent/run-session-state.test.ts @@ -65,6 +65,9 @@ describe("createPersistCronSessionEntry", () => { lifecycleRevision, modelProvider: "claude-cli", model: "claude-opus-4-8", + // Node-local lineage on the base row must not leak onto the :run: node. + previousSessionId: "base-prior-generation", + forkSource: { sessionKey: "agent:main:other", sessionId: "other-generation" }, }), ), lifecycleRevision, @@ -87,7 +90,12 @@ describe("createPersistCronSessionEntry", () => { storePath: cronSession.storePath, update: expect.any(Function), }); + expect(store[runSessionKey]?.previousSessionId).toBeUndefined(); + expect(store[runSessionKey]?.forkSource).toBeUndefined(); expect(store[runSessionKey]).toMatchObject({ + createdVia: "cron", + createdActor: { type: "system" }, + createdAt: expect.any(Number), sessionId: "run-session-id", modelProvider: "claude-cli", model: "claude-opus-4-8", @@ -165,7 +173,8 @@ describe("createPersistCronSessionEntry", () => { }, }), ); - const persistSessionEntry = vi.fn(async () => {}); + const persistedStore: Record = {}; + const persistSessionEntry = makeGuardedPersistSessionEntry(persistedStore); const persist = createPersistCronSessionEntry({ cronSession, @@ -175,12 +184,21 @@ describe("createPersistCronSessionEntry", () => { await persist(); - expect(cronSession.store["agent:main:cron:job"]).toBe(cronSession.sessionEntry); + expect(cronSession.store["agent:main:cron:job"]).toMatchObject({ + createdVia: "cron", + createdActor: { type: "system" }, + createdAt: expect.any(Number), + }); + expect(persistedStore["agent:main:cron:job"]).toMatchObject({ + createdVia: "cron", + createdActor: { type: "system" }, + createdAt: expect.any(Number), + }); expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined(); expect(persistSessionEntry).toHaveBeenCalledWith({ storePath: "/tmp/sessions.json", sessionKey: "agent:main:cron:job", - fallbackEntry: cronSession.sessionEntry, + fallbackEntry: expect.objectContaining({ sessionId: "run-session-id" }), update: expect.any(Function), }); }); diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts index 75543845cff2..79887a610eb1 100644 --- a/src/cron/isolated-agent/run-session-state.ts +++ b/src/cron/isolated-agent/run-session-state.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { isDeepStrictEqual } from "node:util"; import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js"; import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js"; import { isCronSessionKey } from "../../sessions/session-key-utils.js"; @@ -112,6 +113,14 @@ export function createPersistCronSessionEntry(params: { sessionKey: params.agentSessionKey, fallbackEntry: persistedEntry, update: (currentEntry) => { + if (!currentEntry) { + const creationStamp = buildSessionCreationStamp({ + via: "cron", + actor: { type: "system" }, + }); + committedEntry = { ...persistedEntry, ...creationStamp }; + mergedLiveEntry = { ...liveEntry, ...creationStamp }; + } const ownsCurrentRevision = currentEntry?.lifecycleRevision === params.cronSession.lifecycleRevision; const currentRevisionActive = Boolean( @@ -189,6 +198,13 @@ export function createCronRunContinuationSession(params: { entry?.cronRunContinuation?.lifecycleRevision === continuation.lifecycleRevision; const persist = async (create: boolean, phase: "running" | "ready", basePersisted = false) => { const source = structuredClone(params.cronSession.sessionEntry); + delete source.createdVia; + delete source.createdActor; + delete source.createdAt; + // Node-local lineage must not leak across keys: the base row's generation + // chain and fork ancestry describe the cron root, not this :run: node. + delete source.previousSessionId; + delete source.forkSource; let persisted = false; let alreadySealed = false; await params.persistSessionEntry({ @@ -212,6 +228,9 @@ export function createCronRunContinuationSession(params: { return { ...current, ...source, + ...(!current + ? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } }) + : {}), ...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}), cronRunContinuation: { ...continuation, diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index a71199638228..b599f8e2230a 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -423,7 +423,10 @@ describe("method scope resolution", () => { ["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }], ["inheritedToolAllow", { key: "agent:main:ios-1", inheritedToolAllow: ["exec"] }], ["inheritedToolPolicyVersion", { key: "agent:main:ios-1", inheritedToolPolicyVersion: 1 }], - ["spawnedBy", { key: "agent:main:ios-1", spawnedBy: "agent:main:main" }], + [ + "completionOwnerSessionKey", + { key: "agent:main:ios-1", completionOwnerSessionKey: "agent:main:main" }, + ], ["mixed with safe fields", { key: "agent:main:ios-1", label: "x", execHost: "node-1" }], ["unknown fields", { key: "agent:main:ios-1", futureField: true }], ])("keeps sessions.patch admin-only when params include %s", (_name, params) => { diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 88c64ba4d90f..ddc98ae22a1c 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -15,6 +15,7 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-re import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; import { normalizeAgentPlanSteps } from "../channels/streaming.js"; import { getRuntimeConfig } from "../config/io.js"; +import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; import { type AgentEventPayload, type AgentEventRuntimePayload, @@ -550,7 +551,7 @@ export function createAgentEventHandler({ spawnedBy: row?.spawnedBy, spawnedWorkspaceDir: row?.spawnedWorkspaceDir, spawnedCwd: row?.spawnedCwd, - forkedFromParent: row?.forkedFromParent, + forkedFromParent: sessionEntryForkedFromParent(row ?? undefined) ? true : undefined, spawnDepth: row?.spawnDepth, subagentRole: row?.subagentRole, subagentControlScope: row?.subagentControlScope, diff --git a/src/gateway/server-methods/agent-reset-phase.ts b/src/gateway/server-methods/agent-reset-phase.ts index b5c03a782acc..c708ba46b284 100644 --- a/src/gateway/server-methods/agent-reset-phase.ts +++ b/src/gateway/server-methods/agent-reset-phase.ts @@ -21,8 +21,8 @@ import { resolveBareSessionResetResult, runSessionResetFromAgent, } from "./agent-session-reset.js"; -import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { emitSessionsChanged } from "./session-change-event.js"; +import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; export type CommittedResetCompletion = { @@ -97,7 +97,7 @@ export async function runAgentResetPhase(params: { ? { agentId: params.agentId } : {}), reason: resetReason, - createdBy: gatewayClientSessionCreator(params.client), + creation: resolveAgentRunSessionCreation(params.client), assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration), onCommitted: (commit) => { params.setCommittedResetCompletion({ diff --git a/src/gateway/server-methods/agent-run-handler.ts b/src/gateway/server-methods/agent-run-handler.ts index f0a97892ff4a..1ec6de269b77 100644 --- a/src/gateway/server-methods/agent-run-handler.ts +++ b/src/gateway/server-methods/agent-run-handler.ts @@ -23,7 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js"; import { buildAgentSessionPatch } from "./agent-session-patch.js"; import { persistAgentSessionPhase } from "./agent-session-persist.js"; import { prepareAgentSession } from "./agent-session-prepare.js"; -import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; +import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js"; import type { GatewayRequestHandlers } from "./types.js"; export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ @@ -298,7 +298,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ freshEntry === undefined ? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId) : undefined, - createdBy: gatewayClientSessionCreator(client), expectedExistingSessionId, hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined, resetPolicy, @@ -338,6 +337,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ canonicalSessionKey, sessionAgentId, mainSessionKey, + creation: resolveAgentRunSessionCreation(client), lifecycleGeneration, isRestartRecoveryResumeRun, runId, diff --git a/src/gateway/server-methods/agent-session-patch.test.ts b/src/gateway/server-methods/agent-session-patch.test.ts index a1ed06abc0c2..1c8c2a0d5d44 100644 --- a/src/gateway/server-methods/agent-session-patch.test.ts +++ b/src/gateway/server-methods/agent-session-patch.test.ts @@ -31,59 +31,6 @@ function buildPatch(touchInteraction: boolean) { } describe("agent session patch", () => { - it("stamps a creator only when minting a new session", () => { - const patch = buildAgentSessionPatch({ - freshEntry: undefined, - initialEntry: undefined, - cfg: {}, - sessionAgentId: "main", - canonicalSessionKey: "agent:main:new", - storePath: "/tmp/openclaw-agent-creator-test.json", - normalizedSpawned: {}, - requestDeliveryHint: undefined, - createdBy: { id: "profile-ada", label: "Ada" }, - hasRestoredCronContinuation: false, - resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }), - now: 1_000, - isSystemGatewayRun: false, - visibleRequest: true, - fallbackSessionId: "new-session", - touchInteraction: true, - failedSessionTranscriptMissing: () => false, - }).patch; - - expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" }); - }); - - it("clears a previous creator on an ownerless implicit rotation", () => { - const entry: SessionEntry = { - createdBy: { id: "profile-ada", label: "Ada" }, - sessionId: "old-session", - updatedAt: 1, - }; - const patch = buildAgentSessionPatch({ - freshEntry: entry, - initialEntry: entry, - cfg: {}, - sessionAgentId: "main", - canonicalSessionKey: "agent:main:main", - storePath: "/tmp/openclaw-agent-creator-rotation.json", - normalizedSpawned: {}, - requestDeliveryHint: undefined, - hasRestoredCronContinuation: false, - resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }), - now: 2, - isSystemGatewayRun: false, - visibleRequest: true, - fallbackSessionId: "new-session", - touchInteraction: true, - failedSessionTranscriptMissing: () => true, - }).patch; - - expect(Object.hasOwn(patch, "createdBy")).toBe(true); - expect(patch.createdBy).toBeUndefined(); - }); - it("clears agent status at the next human interaction boundary", () => { const patch = buildPatch(true); expect(Object.hasOwn(patch, "agentStatus")).toBe(true); diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts index 7ba3de5c0fd5..f828dd80d6bb 100644 --- a/src/gateway/server-methods/agent-session-patch.ts +++ b/src/gateway/server-methods/agent-session-patch.ts @@ -50,7 +50,6 @@ export function buildAgentSessionPatch(params: { requestLabel?: string; recipientChannel?: string; pluginOwnerId?: string; - createdBy?: SessionEntry["createdBy"]; expectedExistingSessionId?: string; hasRestoredCronContinuation: boolean; resetPolicy: ReturnType; @@ -210,9 +209,6 @@ export function buildAgentSessionPatch(params: { sessionId: patchSessionId, updatedAt: params.now, ...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}), - ...(freshIsNewSession && !freshSessionRotatedSinceLoad - ? { createdBy: params.createdBy ? { ...params.createdBy } : undefined } - : {}), ...(params.touchInteraction ? { lastInteractionAt: params.now, diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index e7d1a64a3722..9e53ff7646fd 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -18,9 +18,11 @@ import { patchSessionEntryTarget, type SessionEntryPatchOptions, } from "../../config/sessions/session-accessor.js"; +import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js"; import { formatForLog } from "../ws-log.js"; import { @@ -35,6 +37,7 @@ import { } from "./agent-handler-helpers.js"; import type { AgentRunRequest } from "./agent-request-types.js"; import type { AgentSessionPatchBuild } from "./agent-session-patch.js"; +import type { TrustedSessionCreation } from "./session-creation-provenance.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; export type CronContinuationClaim = { @@ -75,6 +78,7 @@ export async function persistAgentSessionPhase(params: { canonicalSessionKey: string; sessionAgentId: string; mainSessionKey: string; + creation: TrustedSessionCreation; lifecycleGeneration: string; isRestartRecoveryResumeRun: boolean; runId: string; @@ -117,6 +121,7 @@ export async function persistAgentSessionPhase(params: { let restoredCronContinuation: RestoredCronContinuation | undefined; let mainRestartRecoveryOwnerLease: MainSessionRecoveryOwnerLease | undefined; let skipAgentInitialSessionTouch = false; + let createdNewEntry = false; const recoveredSessionStartedAt = !patchBuild.isNewSession && params.entry !== undefined && @@ -246,12 +251,25 @@ export async function persistAgentSessionPhase(params: { }); } patchBuild = params.buildSessionPatch(entryForPatch); - const effectivePatch = + const lifecyclePatch = recoveredSessionStartedAt !== undefined && entryForPatch?.sessionStartedAt === undefined && entryForPatch?.sessionId === params.entry?.sessionId ? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt } : patchBuild.patch; + const previousSessionId = normalizeOptionalString(freshEntry?.sessionId); + const nextSessionId = normalizeOptionalString(lifecyclePatch.sessionId); + const rotationLineage = + previousSessionId && nextSessionId && previousSessionId !== nextSessionId + ? { previousSessionId } + : {}; + const effectivePatch = freshEntry + ? { ...lifecyclePatch, ...rotationLineage } + : { + ...lifecyclePatch, + ...buildSessionCreationStamp(params.creation), + }; + createdNewEntry = freshEntry === undefined; const merged = withSqliteSessionFileMarker({ agentId: params.sessionAgentId, entry: mergeSessionEntry(entryForPatch, effectivePatch), @@ -416,6 +434,13 @@ export async function persistAgentSessionPhase(params: { const rotatedSessionId = patchBuild.rotatedSessionId; const usableRequestedSessionId = patchBuild.usableRequestedSessionId; const freshness = patchBuild.freshness; + if (createdNewEntry && sessionEntry) { + recordSessionCreated({ + sessionKey: params.canonicalSessionKey, + agentId: params.sessionAgentId, + entry: sessionEntry, + }); + } if (isNewSession && params.entry?.sessionId && resolvedSessionId !== params.entry.sessionId) { supersededSessionId = params.entry.sessionId; } diff --git a/src/gateway/server-methods/agent-session-reset.ts b/src/gateway/server-methods/agent-session-reset.ts index 1c8a67694b48..9577ff0e22e1 100644 --- a/src/gateway/server-methods/agent-session-reset.ts +++ b/src/gateway/server-methods/agent-session-reset.ts @@ -1,5 +1,4 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { AgentCommandOpts } from "../../agents/command/types.js"; import { agentCommandFromIngress } from "../../commands/agent.js"; @@ -14,13 +13,14 @@ import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { performGatewaySessionReset } from "../session-reset-service.js"; import { loadSessionEntry } from "../session-utils.js"; +import type { TrustedSessionCreation } from "./session-creation-provenance.js"; import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; export async function runSessionResetFromAgent(params: { key: string; agentId?: string; reason: "new" | "reset"; - createdBy?: SessionCreatorIdentity; + creation: TrustedSessionCreation; assertCurrent?: () => void; onCommitted?: (commit: { key: string; sessionId: string }) => void; }) { @@ -29,7 +29,7 @@ export async function runSessionResetFromAgent(params: { ...(params.agentId ? { agentId: params.agentId } : {}), reason: params.reason, commandSource: "gateway:agent", - createdBy: params.createdBy, + creation: params.creation, assertCurrent: params.assertCurrent, onCommitted: params.onCommitted, }); diff --git a/src/gateway/server-methods/chat-send-user-turn.test.ts b/src/gateway/server-methods/chat-send-user-turn.test.ts index 1ee695781e7b..39e5d2b7c2b0 100644 --- a/src/gateway/server-methods/chat-send-user-turn.test.ts +++ b/src/gateway/server-methods/chat-send-user-turn.test.ts @@ -163,6 +163,12 @@ describe("prepareChatSendUserTurn", () => { }), client: { connId: "conn-1", + authenticatedUserProfile: { + profileId: "profile-ada", + displayName: "Ada", + hasAvatar: false, + updatedAt: 1, + }, connect: { device: { id: "device-1" }, scopes: ["operator.admin"], @@ -190,6 +196,10 @@ describe("prepareChatSendUserTurn", () => { MediaStaged: true, GatewayClientScopes: ["operator.admin"], GatewayClientCaps: ["tool-events"], + SessionCreation: { + via: "operator", + actor: { type: "human", id: "profile-ada" }, + }, }); expect(prepared.ctx).not.toHaveProperty("SenderId"); expect(prepared.queuedFollowupOwnerKey).toBe("device:device-1"); diff --git a/src/gateway/server-methods/chat-send-user-turn.ts b/src/gateway/server-methods/chat-send-user-turn.ts index af26276e4d4c..74d421e037f2 100644 --- a/src/gateway/server-methods/chat-send-user-turn.ts +++ b/src/gateway/server-methods/chat-send-user-turn.ts @@ -17,7 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js"; import type { NormalizedChatSendRequest } from "./chat-send-request.js"; import type { PreparedChatSendSession } from "./chat-send-session.js"; import { normalizeOptionalChatText } from "./chat-text-normalization.js"; -import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; +import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js"; type PreparedChatSendAttachments = Extract< @@ -180,9 +180,7 @@ function buildChatSendMessageContext(params: { body: commandBody, }, MessageSid: params.clientRunId, - ...(gatewayClientSessionCreator(params.client) - ? { SessionCreator: gatewayClientSessionCreator(params.client) } - : {}), + SessionCreation: resolveOperatorSessionCreation(params.client), ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId, ...(!isOperatorUiClient(params.clientInfo) ? { diff --git a/src/gateway/server-methods/gateway-client-identity.ts b/src/gateway/server-methods/gateway-client-identity.ts index 35747ef5241c..85f7028a683a 100644 --- a/src/gateway/server-methods/gateway-client-identity.ts +++ b/src/gateway/server-methods/gateway-client-identity.ts @@ -17,8 +17,3 @@ export function gatewayClientSenderFields(client: GatewayClient | null): { } return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {}; } - -/** Returns the trusted creator identity captured during connection admission. */ -export function gatewayClientSessionCreator(client: GatewayClient | null) { - return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined; -} diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 91f5234cc7ca..c871b1203023 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -4,15 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js" import { createPluginRuntime } from "../../plugins/runtime/index.js"; import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; -type CatalogSessionEntryLoader = ( +type CatalogSessionRowLoader = ( sessionKey: string, options?: { agentId?: string; clone?: boolean }, -) => { entry: { createdBy?: { id: string; label?: string } } | undefined }; +) => { createdActor?: { type: "human" | "agent" | "system"; id?: string; label?: string } } | null; const hoisted = vi.hoisted(() => ({ activeRegistry: { sessionCatalogs: [] as unknown[] }, pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined, - loadSessionEntryReadOnly: vi.fn(() => ({ entry: undefined })), + loadGatewaySessionRow: vi.fn(() => null), recordSessionStateEvent: vi.fn(), upsertSessionUpstreamLink: vi.fn(), })); @@ -41,7 +41,7 @@ vi.mock("../../plugins/session-conversation-binding.js", () => ({ })); vi.mock("../session-utils.js", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly }; + return { ...actual, loadGatewaySessionRow: hoisted.loadGatewaySessionRow }; }); const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } = @@ -81,8 +81,8 @@ describe("session catalog Gateway methods", () => { beforeEach(() => { hoisted.activeRegistry.sessionCatalogs = []; hoisted.pinnedSessionExtensionRegistry = undefined; - hoisted.loadSessionEntryReadOnly.mockReset(); - hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined }); + hoisted.loadGatewaySessionRow.mockReset(); + hoisted.loadGatewaySessionRow.mockReturnValue(null); hoisted.recordSessionStateEvent.mockClear(); hoisted.upsertSessionUpstreamLink.mockClear(); conversationBindingMocks.bindPluginSessionConversation.mockClear(); @@ -169,7 +169,7 @@ describe("session catalog Gateway methods", () => { status: "stored", archived: false, sessionKey: "agent:main:owned", - createdBy: { id: "provider-spoof" }, + createdActor: { type: "human" as const, id: "provider-spoof" }, canContinue: true, canArchive: false, }, @@ -178,7 +178,7 @@ describe("session catalog Gateway methods", () => { status: "stored", archived: false, sessionKey: "agent:main:missing", - createdBy: { id: "provider-spoof" }, + createdActor: { type: "human" as const, id: "provider-spoof" }, canContinue: true, canArchive: false, }, @@ -186,18 +186,17 @@ describe("session catalog Gateway methods", () => { threadId: "external-thread", status: "stored", archived: false, - createdBy: { id: "provider-spoof" }, + createdActor: { type: "human" as const, id: "provider-spoof" }, canContinue: true, canArchive: false, }, ], }; - hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({ - entry: - sessionKey === "agent:main:owned" - ? { createdBy: { id: "profile-ada", label: "Ada" } } - : undefined, - })); + hoisted.loadGatewaySessionRow.mockImplementation((sessionKey: string) => + sessionKey === "agent:main:owned" + ? { createdActor: { type: "human", id: "profile-ada", label: "Ada" } } + : null, + ); hoisted.activeRegistry.sessionCatalogs = [ { provider: provider("claude", { @@ -219,10 +218,10 @@ describe("session catalog Gateway methods", () => { const projectedSessions = [ expect.objectContaining({ threadId: "owned-thread", - createdBy: { id: "profile-ada", label: "Ada" }, + createdActor: { type: "human", id: "profile-ada", label: "Ada" }, }), - expect.not.objectContaining({ createdBy: expect.anything() }), - expect.not.objectContaining({ createdBy: expect.anything() }), + expect.not.objectContaining({ createdActor: expect.anything() }), + expect.not.objectContaining({ createdActor: expect.anything() }), ]; expect(broadcastToConnIds).toHaveBeenCalledWith( @@ -242,10 +241,10 @@ describe("session catalog Gateway methods", () => { }), ], }); - expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", { + expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledWith("agent:main:owned", { agentId: "main", }); - expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2); + expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledTimes(2); }); it("uses the pinned Gateway catalog runtime after active registry churn", async () => { diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index bd2f50f03451..763be985dfff 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -24,7 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { recordSessionStateEvent } from "../../sessions/session-state-events.js"; import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js"; -import { loadSessionEntryReadOnly } from "../session-utils.js"; +import { loadGatewaySessionRow } from "../session-utils.js"; import { resolveAgentIdOrRespondError } from "./agent-id-shared.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -158,28 +158,28 @@ function catalogResult( return result; } -function projectCatalogHostCreators( +function projectCatalogHostCreatedActors( host: SessionCatalogHost, agentId: string, - creatorBySessionKey: Map, + actorBySessionKey: Map, ): SessionCatalogHost { return { ...host, - sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => { + sessions: host.sessions.map(({ createdActor: _providerCreatedActor, ...session }) => { // Catalog providers do not own creator identity; the persisted session entry does. const sessionKey = session.sessionKey; - let createdBy: SessionCatalogSession["createdBy"]; - if (sessionKey && creatorBySessionKey.has(sessionKey)) { - createdBy = creatorBySessionKey.get(sessionKey); + let createdActor: SessionCatalogSession["createdActor"]; + if (sessionKey && actorBySessionKey.has(sessionKey)) { + createdActor = actorBySessionKey.get(sessionKey); } else { - createdBy = sessionKey - ? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy + createdActor = sessionKey + ? loadGatewaySessionRow(sessionKey, { agentId })?.createdActor : undefined; if (sessionKey) { - creatorBySessionKey.set(sessionKey, createdBy); + actorBySessionKey.set(sessionKey, createdActor); } } - return createdBy ? { ...session, createdBy: { ...createdBy } } : session; + return createdActor ? { ...session, createdActor: { ...createdActor } } : session; }), }; } @@ -228,7 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { const search = normalizeSessionCatalogSearch(request.search); const progressId = request.progressId; const progressConnId = progressId && client?.connId ? client.connId : undefined; - const creatorBySessionKey = new Map(); + const actorBySessionKey = new Map(); const catalogList = await Promise.all( selected.map(async (provider): Promise => { const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId); @@ -244,7 +244,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { agentId: resolvedAgent.agentId, catalog: catalogResult( provider, - [projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)], + [ + projectCatalogHostCreatedActors( + host, + resolvedAgent.agentId, + actorBySessionKey, + ), + ], undefined, createSession, ), @@ -265,7 +271,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = { return catalogResult( provider, hosts.map((host) => - projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey), + projectCatalogHostCreatedActors(host, resolvedAgent.agentId, actorBySessionKey), ), undefined, createSession, diff --git a/src/gateway/server-methods/session-creation-provenance.test.ts b/src/gateway/server-methods/session-creation-provenance.test.ts new file mode 100644 index 000000000000..cc8dbf777563 --- /dev/null +++ b/src/gateway/server-methods/session-creation-provenance.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js"; + +describe("agent run session creation provenance", () => { + it("uses a proven Gateway profile id", () => { + expect( + resolveAgentRunSessionCreation({ + authenticatedUserProfile: { profileId: "profile-ada" }, + }), + ).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } }); + }); + + it("does not infer an actor for a profile-less wire client", () => { + expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" }); + }); +}); diff --git a/src/gateway/server-methods/session-creation-provenance.ts b/src/gateway/server-methods/session-creation-provenance.ts new file mode 100644 index 000000000000..a58066bc5748 --- /dev/null +++ b/src/gateway/server-methods/session-creation-provenance.ts @@ -0,0 +1,42 @@ +import type { + SessionCreatedActor, + SessionCreatedVia, +} from "../../config/sessions/session-entry-provenance.js"; + +export type TrustedSessionCreation = { + via: SessionCreatedVia; + actor?: SessionCreatedActor; +}; + +/** + * Structural subset of GatewayClient; a leaf contract so shared-types.ts can + * import TrustedSessionCreation without a type cycle back through this module. + */ +type SessionCreationClient = { + authenticatedUserProfile?: { profileId?: string } | null; + internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation }; +}; + +export function resolveOperatorSessionCreation( + client: SessionCreationClient | null | undefined, + options: { allowTrustedHint?: boolean } = {}, +): TrustedSessionCreation { + if (options.allowTrustedHint && client?.internal?.sessionCreation) { + return client.internal.sessionCreation; + } + const profileId = client?.authenticatedUserProfile?.profileId; + // Actor only when proven: a profile-less wire connection may be an agent-tool + // client on a remote topology, so claiming a human actor would misattribute + // agent-caused creations. Absent actor means unknown, never inferred. + return { + via: "operator", + ...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}), + }; +} + +export function resolveAgentRunSessionCreation( + client: SessionCreationClient | null | undefined, +): TrustedSessionCreation { + const actor = resolveOperatorSessionCreation(client).actor; + return { via: "run", ...(actor ? { actor } : {}) }; +} diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index cec4042ab5a7..1b57ae8961cc 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status import { insideGitCheckout } from "../../agents/worktrees/git.js"; import { managedWorktrees } from "../../agents/worktrees/service.js"; import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js"; +import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { isPathInside } from "../../infra/path-guards.js"; import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js"; @@ -28,13 +29,13 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js"; import { readSessionMessageCountAsync } from "../session-transcript-readers.js"; import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js"; import { chatHandlers } from "./chat.js"; -import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { resolveSessionCatalogCreateTarget } from "./session-catalog.js"; import { emitSessionsChanged } from "./session-change-event.js"; import { resolveSessionCreateInitialTurn, shouldAttachPendingMessageSeq, } from "./session-create-initial-turn.js"; +import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import { sessionLog } from "./sessions-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -329,7 +330,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ).allowed; const created = await createGatewaySession({ cfg, - createdBy: gatewayClientSessionCreator(client), key: sessionKey, agentId: sessionAgentId, label: p.label, @@ -356,6 +356,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { emitCommandHooks: p.emitCommandHooks, resetMainWhenUnspecified: !hasInitialTurn, commandSource: "webchat", + creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }), loadGatewayModelCatalog: context.loadGatewayModelCatalog, afterCreate: hasInitialTurn ? async ({ key, agentId, entry, storePath }) => { @@ -432,6 +433,9 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { branch: sessionWorktree.branch, } : undefined; + const responseEntry = sessionEntryForkedFromParent(created.entry) + ? { ...created.entry, forkedFromParent: true as const } + : created.entry; if (created.resetExisting) { respond( true, @@ -439,7 +443,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ok: true, key: created.key, sessionId: created.entry.sessionId, - entry: created.entry, + entry: responseEntry, resolved: created.resolved, runStarted: false, ...(createdWorktree ? { worktree: createdWorktree } : {}), @@ -467,7 +471,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = { ok: true, key: created.key, sessionId: created.entry.sessionId, - entry: created.entry, + entry: responseEntry, runStarted, ...(runPayload ? runPayload : {}), ...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}), diff --git a/src/gateway/server-methods/sessions-mutations.ts b/src/gateway/server-methods/sessions-mutations.ts index 139fca10d60d..3571a7c80321 100644 --- a/src/gateway/server-methods/sessions-mutations.ts +++ b/src/gateway/server-methods/sessions-mutations.ts @@ -34,9 +34,9 @@ import { type SessionsPatchResult, } from "../session-utils.js"; import { projectSessionsPatchEntry } from "../sessions-patch.js"; -import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; +import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import { isAgentMainSessionKey, loadSessionsRuntimeModule, @@ -400,7 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = { ...(p.agentId ? { agentId: p.agentId } : {}), reason, commandSource: "gateway:sessions.reset", - createdBy: gatewayClientSessionCreator(client), + creation: resolveOperatorSessionCreation(client), }); if (!result.ok) { respond(false, undefined, result.error); diff --git a/src/gateway/server-methods/sessions-rewind.test.ts b/src/gateway/server-methods/sessions-rewind.test.ts index 376bcbb893d7..25605279d055 100644 --- a/src/gateway/server-methods/sessions-rewind.test.ts +++ b/src/gateway/server-methods/sessions-rewind.test.ts @@ -58,9 +58,12 @@ import { appendTranscriptEvent, appendTranscriptMessage, listSessionEntries, + loadSessionEntry, upsertSessionEntry, } from "../../config/sessions/session-accessor.js"; +import { listSessionStateEventsSince } from "../../sessions/session-state-events.js"; import { sessionsHandlers } from "./sessions.js"; +import type { GatewayClient } from "./types.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); const sessionKey = "agent:main:rewind-handler"; @@ -140,8 +143,12 @@ type MessageCutMethod = | "sessions.fork" | "sessions.rewind"; -async function invoke(method: MessageCutMethod, entryId?: string) { - const respond = vi.fn() as unknown as RespondFn; +async function invoke( + method: MessageCutMethod, + entryId?: string, + client: GatewayClient | null = null, +) { + const respond = vi.fn(); await expectDefined( sessionsHandlers[method], `${method} handler`, @@ -155,9 +162,9 @@ async function invoke(method: MessageCutMethod, entryId?: string) { ? {} : { entryId }), }, - respond, + respond: respond as unknown as RespondFn, context: context(), - client: null, + client, isWebchatConnect: () => false, }); return respond; @@ -209,12 +216,36 @@ describe("session message-cut methods", () => { }); it("returns editor text for rewind and a new key for fork", async () => { - const fork = await invoke("sessions.fork", "user-entry"); + const profileId = "profile-fork-creator"; + const fork = await invoke("sessions.fork", "user-entry", { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId, + displayName: "Fork Operator", + hasAvatar: false, + updatedAt: 1, + }, + } as GatewayClient); expect(fork).toHaveBeenCalledWith( true, expect.objectContaining({ editorText: "edit me", sessionKey: expect.any(String) }), undefined, ); + const forkKey = (fork.mock.calls[0]?.[1] as { sessionKey?: string } | undefined)?.sessionKey; + expect(forkKey).toBeTruthy(); + const forkEntry = loadSessionEntry({ agentId: "main", sessionKey: forkKey ?? "" }); + expect(forkEntry).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: profileId }, + createdAt: expect.any(Number), + }); + expect(listSessionStateEventsSince(forkKey ?? "", "main", 0, 20).events).toContainEqual( + expect.objectContaining({ + kind: "created", + actorType: "human", + actorId: profileId, + }), + ); const rewind = await invoke("sessions.rewind", "user-entry"); expect(rewind).toHaveBeenCalledWith(true, { editorText: "edit me" }, undefined); diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 643ebd0cb650..d8b8d37eec48 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -22,6 +22,7 @@ import { isCompetingSessionWorkAdmissionActive, runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; +import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { readSessionUpstreamLink, type SessionUpstreamLink, @@ -33,6 +34,7 @@ import { import { asWorkerInferenceControl } from "../worker-environments/inference-control.js"; import { hasVisibleActiveSessionRun } from "./session-active-runs.js"; import { emitSessionsChanged } from "./session-change-event.js"; +import { resolveOperatorSessionCreation } from "./session-creation-provenance.js"; import { loadAccessorSessionEntryForGatewayTarget, resolveSessionWorkerPlacementMutationError, @@ -155,7 +157,7 @@ async function mutateSessionAtMessage( options: GatewayRequestHandlerOptions, action: MessageCutAction, ): Promise { - const { params, respond, context } = options; + const { params, respond, context, client } = options; const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : ""; const entryId = action === "switch" @@ -385,6 +387,7 @@ async function mutateSessionAtMessage( sessionStoreKey: current.sessionStoreKey, storePath: current.storePath, targetKey, + creation: resolveOperatorSessionCreation(client), }) : action === "rewind" ? rewindSessionToMessage({ @@ -415,6 +418,12 @@ async function mutateSessionAtMessage( } if (action !== "fork") { clearSessionQueues(lifecycleIdentities); + } else { + recordSessionCreated({ + sessionKey: result.key, + agentId: current.target.agentId, + entry: result.entry, + }); } respond( true, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 5cc7245267c1..af8275173128 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -1,5 +1,4 @@ import type { - SessionCreatorIdentity, SessionApprovalReplay, SystemAgentChatQuestion, } from "../../../packages/gateway-protocol/src/index.js"; @@ -58,6 +57,7 @@ import type { WorkerEnvironmentServiceContract, WorkerPlacementDispatchContract, } from "../worker-environments/service-contract.js"; +import type { TrustedSessionCreation } from "./session-creation-provenance.js"; /** * Shared gateway request types used by every server-method module. @@ -78,8 +78,6 @@ export type GatewayClient = { hasAvatar: boolean; updatedAt: number; }; - /** Trusted operator identity resolved once during connection admission. */ - operatorIdentity?: SessionCreatorIdentity; pluginSurfaceUrls?: Record; pluginNodeCapabilitySurfaces?: Record; pluginNodeCapabilities?: Record; @@ -89,6 +87,10 @@ export type GatewayClient = { /** Signed shared-auth session admitted only to approve its own upgrade pairing. */ isControlUiDeviceAuthMigration?: boolean; internal?: { + /** Marks the server-constructed client used by trusted in-process dispatch. */ + syntheticClient?: true; + /** Trusted session creation provenance; never accepted from Gateway wire params. */ + sessionCreation?: TrustedSessionCreation; allowModelOverride?: boolean; approvalRuntime?: boolean; cronRunContinuation?: boolean; diff --git a/src/gateway/server-plugin-runtime-client.ts b/src/gateway/server-plugin-runtime-client.ts index a2b2b2619291..75242b19b6b5 100644 --- a/src/gateway/server-plugin-runtime-client.ts +++ b/src/gateway/server-plugin-runtime-client.ts @@ -10,6 +10,7 @@ import { normalizeToolName } from "../agents/tool-policy.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js"; +import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js"; import type { GatewayRequestOptions } from "./server-methods/types.js"; export function createSyntheticPluginRuntimeClient(params?: { @@ -21,6 +22,7 @@ export function createSyntheticPluginRuntimeClient(params?: { pluginRuntimeOwnerId?: string; runtimePluginToolGrant?: RuntimePluginToolGrant; delegatedToolPolicyHandoff?: boolean; + sessionCreation?: TrustedSessionCreation; scopes?: string[]; }): NonNullable { const pluginRuntimeOwnerId = @@ -41,6 +43,8 @@ export function createSyntheticPluginRuntimeClient(params?: { scopes: params?.scopes ?? [WRITE_SCOPE], }, internal: { + syntheticClient: true, + ...(params?.sessionCreation ? { sessionCreation: params.sessionCreation } : {}), allowModelOverride: params?.allowModelOverride === true, ...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}), ...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}), diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index bb9a3320c895..50d89d94f0a6 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -28,6 +28,7 @@ import { type GatewayMethodDispatchResponse, unwrapGatewayMethodDispatchResponse, } from "./server-in-process-dispatch.js"; +import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js"; import type { GatewayRequestContext, GatewayRequestHandler, @@ -247,6 +248,7 @@ type DispatchGatewayMethodInProcessOptions = { pluginRuntimeOwnerId?: string; runtimePluginToolGrant?: RuntimePluginToolGrant; delegatedToolPolicyHandoff?: boolean; + sessionCreation?: TrustedSessionCreation; requireScopedClient?: boolean; syntheticScopes?: string[]; timeoutMs?: number; @@ -288,6 +290,7 @@ export async function dispatchGatewayMethodInProcessRaw( ? { runtimePluginToolGrant: options.runtimePluginToolGrant } : {}), delegatedToolPolicyHandoff: options?.delegatedToolPolicyHandoff === true, + ...(options?.sessionCreation ? { sessionCreation: options.sessionCreation } : {}), scopes: options?.syntheticScopes, }); const scopedClient = mergePluginRuntimeClientInternal( diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts index 74dba1cdede5..ccbfa48a10f3 100644 --- a/src/gateway/server-session-events.ts +++ b/src/gateway/server-session-events.ts @@ -93,7 +93,7 @@ function buildGatewaySessionSnapshot(params: { const session = params.includeSession ? { ...buildGatewaySessionEventRow(sessionRow), - createdBy: sessionRow.createdBy ?? null, + createdActor: sessionRow.createdActor ?? null, thinkingLevel: sessionRow.thinkingLevel ?? null, } : undefined; diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.test.ts index 9cd121baf496..da59bc7881d8 100644 --- a/src/gateway/server.agent.gateway-server-agent-a.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-a.test.ts @@ -367,6 +367,53 @@ describe("gateway server agent", () => { expect(persisted?.spawnedBy).toBe("agent:main:main"); }); + test("agent stamps create-on-run session rows without guessing an actor", async () => { + await setTestSessionStore({ entries: {} }); + const sessionKey = "agent:main:dashboard:create-on-run"; + const res = await rpcReq(gatewaySuite.ws, "agent", { + message: "hi", + sessionKey, + idempotencyKey: "idem-agent-create-on-run", + }); + expect(res.ok).toBe(true); + await waitForAgentCommandCall("idem-agent-create-on-run"); + + expect( + loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath }), + ).toMatchObject({ + createdVia: "run", + createdAt: expect.any(Number), + }); + expect( + loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath })?.createdActor, + ).toBeUndefined(); + }); + + test("agent links the previous generation when a missing transcript rotates the session", async () => { + const sessionKey = "agent:main:dashboard:rotate-missing-transcript"; + await setTestSessionStore({ + entries: { + [sessionKey]: { + sessionId: "missing-transcript-generation", + status: "failed", + updatedAt: Date.now(), + }, + }, + }); + + const res = await rpcReq(gatewaySuite.ws, "agent", { + message: "continue", + sessionKey, + idempotencyKey: "idem-agent-rotate-missing-transcript", + }); + expect(res.ok).toBe(true); + await waitForAgentCommandCall("idem-agent-rotate-missing-transcript"); + + const persisted = loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath }); + expect(persisted?.sessionId).not.toBe("missing-transcript-generation"); + expect(persisted?.previousSessionId).toBe("missing-transcript-generation"); + }); + test("agent derives sessionKey from agentId", async () => { testState.agentsConfig = { list: [{ id: "ops" }] }; await setTestSessionStore({ diff --git a/src/gateway/server.agent.subagent-delivery-context.test.ts b/src/gateway/server.agent.subagent-delivery-context.test.ts index 45d32fe7a5f2..ab3a82ca2d5f 100644 --- a/src/gateway/server.agent.subagent-delivery-context.test.ts +++ b/src/gateway/server.agent.subagent-delivery-context.test.ts @@ -224,10 +224,10 @@ describe("subagent session deliveryContext from spawn request params", () => { }); }); - test("pre-patched subagent session (via sessions.patch) inherits deliveryContext from agent request", async () => { - // Simulates the real subagent spawn flow: spawnSubagentDirect calls sessions.patch - // first (to set spawnDepth, spawnedBy, etc.), then calls callSubagentGateway({method: "agent"}). - // The sessions.patch creates a partial entry without deliveryContext. + test("pre-created subagent session inherits deliveryContext from agent request", async () => { + // Simulates the real subagent spawn flow: the trusted session accessor persists lineage + // before callSubagentGateway({method: "agent"}) seeds the delivery context. + // The direct lineage write creates a partial entry without deliveryContext. // The agent handler must seed deliveryContext from the request params. await prepareSessionStore({ "agent:main:subagent:pre-patched": { diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index 50565079c71c..bfca611219b4 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -15,6 +15,7 @@ import { managedWorktrees } from "../agents/worktrees/service.js"; import { loadSessionEntry, loadTranscriptEvents } from "../config/sessions/session-accessor.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { listSessionStateEventsSince } from "../sessions/session-state-events.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { agentCommand, @@ -73,51 +74,6 @@ function requireNonEmptyString(value: string | undefined, label: string): string return value; } -test("sessions.create stamps the trusted creator and preserves it until reset", async () => { - await createSessionStoreDir(); - const adaClient = { - operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" }, - connect: { scopes: ["operator.admin"] }, - } as never; - const bobClient = { - operatorIdentity: { id: "profile-bob", label: "Bob Hopper" }, - connect: { scopes: ["operator.admin"] }, - } as never; - - const created = await directSessionReq<{ - key: string; - entry: { createdBy?: { id: string; label?: string } }; - }>("sessions.create", { agentId: "main" }, { client: adaClient }); - expect(created.ok).toBe(true); - expect(created.payload?.entry.createdBy).toEqual({ - id: "profile-ada", - label: "Ada Lovelace", - }); - const key = requireNonEmptyString(created.payload?.key, "created session key"); - - const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>( - "sessions.create", - { agentId: "main", key }, - { client: bobClient }, - ); - expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada"); - - const listed = await directSessionReq<{ - sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>; - }>("sessions.list", { agentId: "main" }); - expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({ - id: "profile-ada", - label: "Ada Lovelace", - }); - - const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>( - "sessions.reset", - { agentId: "main", key }, - { client: bobClient }, - ); - expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" }); -}); - test("sessions.create provisions and reuses a session worktree for later runs", async () => { const root = await fs.mkdtemp( path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"), @@ -1488,6 +1444,190 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat }); }); +test("sessions.create stamps trusted operator provenance and records created", async () => { + await createSessionStoreDir(); + const profileId = "profile-session-creator"; + const created = await directSessionReq<{ + key?: string; + entry?: { + createdVia?: string; + createdActor?: { type: string; id?: string }; + createdAt?: number; + }; + }>( + "sessions.create", + { agentId: "main" }, + { + client: { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId, + displayName: "Test Operator", + hasAvatar: false, + updatedAt: 1, + }, + } as never, + }, + ); + + expect(created.ok).toBe(true); + expect(created.payload?.entry).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: profileId }, + createdAt: expect.any(Number), + }); + const key = requireNonEmptyString(created.payload?.key, "created session key"); + expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual( + expect.objectContaining({ + kind: "created", + actorType: "human", + actorId: profileId, + summary: "session created", + }), + ); + + const synthetic = await directSessionReq<{ + entry?: { createdVia?: string; createdActor?: unknown; createdAt?: number }; + }>( + "sessions.create", + { agentId: "main" }, + { + client: { + connect: { scopes: ["operator.write"] }, + internal: { syntheticClient: true }, + } as never, + }, + ); + expect(synthetic.payload?.entry).toMatchObject({ + createdVia: "operator", + createdAt: expect.any(Number), + }); + expect(synthetic.payload?.entry?.createdActor).toBeUndefined(); + + const hinted = await directSessionReq<{ + entry?: { createdVia?: string; createdActor?: unknown }; + }>( + "sessions.create", + { agentId: "main" }, + { + client: { + connect: { scopes: ["operator.write"] }, + internal: { + syntheticClient: true, + sessionCreation: { + via: "spawn", + actor: { type: "agent", id: "agent:main:main" }, + }, + }, + } as never, + }, + ); + expect(hinted.payload?.entry).toMatchObject({ + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + }); +}); + +test("sessions.create reset-in-place preserves the node creation stamp", async () => { + testState.sessionConfig = { dmScope: "main" }; + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: { + main: sessionStoreEntry("existing-main", { + createdVia: "channel", + createdActor: { type: "human", id: "telegram:42" }, + createdAt: 1234, + }), + }, + }); + + const reset = await directSessionReq<{ entry?: Record }>( + "sessions.create", + { agentId: "main", parentSessionKey: "main", emitCommandHooks: true }, + { + client: { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId: "profile-resetter", + displayName: null, + hasAvatar: false, + updatedAt: 1, + }, + } as never, + }, + ); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry).toMatchObject({ + createdVia: "channel", + createdActor: { type: "human", id: "telegram:42" }, + createdAt: 1234, + }); + expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({ + createdVia: "channel", + createdActor: { type: "human", id: "telegram:42" }, + createdAt: 1234, + }); +}); + +test("sessions.create adopting an existing key does not restamp node provenance", async () => { + const { storePath } = await createSessionStoreDir(); + await writeSessionStore({ + entries: { + "agent:main:dashboard:adopted": sessionStoreEntry("existing-adopted", { + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 4321, + }), + }, + }); + const { chatHandlers } = await import("./server-methods/chat.js"); + const chatSend = vi.spyOn(chatHandlers, "chat.send").mockImplementation(async ({ respond }) => { + respond(true, { runId: "adopted-run", status: "started" }); + }); + + try { + const adopted = await directSessionReq<{ + entry?: Record; + runStarted?: boolean; + }>( + "sessions.create", + { key: "agent:main:dashboard:adopted", agentId: "main", message: "adopted follow-up" }, + { + client: { + connect: { scopes: ["operator.write"] }, + authenticatedUserProfile: { + profileId: "profile-adopter", + displayName: null, + hasAvatar: false, + updatedAt: 1, + }, + } as never, + }, + ); + + expect(adopted.ok).toBe(true); + // Post-create work (the nested initial chat.send) still runs on adoption. + expect(adopted.payload?.runStarted).toBe(true); + expect(chatSend).toHaveBeenCalledTimes(1); + expect( + loadSessionEntry({ sessionKey: "agent:main:dashboard:adopted", storePath }), + ).toMatchObject({ + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 4321, + }); + // Adoption is not a node creation: no `created` event may enter the journal. + expect( + listSessionStateEventsSince("agent:main:dashboard:adopted", "main", 0, 20).events.filter( + (event) => event.kind === "created", + ), + ).toEqual([]); + } finally { + chatSend.mockRestore(); + } +}); + test("sessions.create scopes the main alias to the requested agent", async () => { const { storePath } = await createSessionStoreDir(); @@ -1875,6 +2015,7 @@ test("sessions.create forks the parent transcript into the new session", async ( entry?: { sessionFile?: string; parentSessionKey?: string; + forkSource?: { sessionKey: string; sessionId: string }; forkedFromParent?: boolean; totalTokens?: number; totalTokensFresh?: boolean; @@ -1887,6 +2028,10 @@ test("sessions.create forks the parent transcript into the new session", async ( expect(created.ok, JSON.stringify(created.error)).toBe(true); expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main"); + expect(created.payload?.entry?.forkSource).toEqual({ + sessionKey: "agent:main:main", + sessionId: parent.sessionId, + }); expect(created.payload?.entry?.forkedFromParent).toBe(true); expect(created.payload?.entry?.totalTokens).toBeUndefined(); expect(created.payload?.entry?.totalTokensFresh).toBe(false); @@ -1932,8 +2077,16 @@ test("sessions.create forks the parent transcript into the new session", async ( expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({ sessionId: created.payload?.sessionId, sessionFile: forkedSessionFile, - forkedFromParent: true, + forkSource: { + sessionKey: "agent:main:main", + sessionId: parent.sessionId, + }, }); + expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("forkedFromParent"); + const listed = await directSessionReq<{ + sessions?: Array<{ key: string; forkedFromParent?: boolean }>; + }>("sessions.list", {}); + expect(listed.payload?.sessions?.find((row) => row.key === key)?.forkedFromParent).toBe(true); testState.sessionConfig = undefined; }); @@ -2159,6 +2312,7 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a entry?: { parentSessionKey?: string; sessionFile?: string; + forkSource?: { sessionKey: string; sessionId: string }; forkedFromParent?: boolean; }; }>("sessions.create", { @@ -2169,6 +2323,10 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a expect(created.ok, JSON.stringify(created.error)).toBe(true); expect(created.payload?.key).toMatch(/^agent:main:dashboard:/); expect(created.payload?.entry?.parentSessionKey).toBe("agent:work:main"); + expect(created.payload?.entry?.forkSource).toEqual({ + sessionKey: "agent:work:main", + sessionId: parent.sessionId, + }); expect(created.payload?.entry?.forkedFromParent).toBe(true); const forkedSessionFile = requireNonEmptyString( created.payload?.entry?.sessionFile, diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index 0274a551587d..ef5b726c52f0 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -1191,12 +1191,22 @@ test("sessions.changed mutation events include subagent ownership metadata", asy entries: { "subagent:child": sessionStoreEntry("sess-child", { spawnedBy: "agent:main:main", + parentSessionKey: "agent:main:dashboard:navigation-parent", spawnedWorkspaceDir: "/tmp/subagent-workspace", spawnedCwd: "/tmp/task-repo", forkedFromParent: true, spawnDepth: 2, subagentRole: "orchestrator", subagentControlScope: "children", + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_000, + forkSource: { + sessionKey: "agent:main:main", + sessionId: "sess-source", + entryId: "entry-source", + }, + previousSessionId: "sess-previous", }), }, }); @@ -1211,12 +1221,23 @@ test("sessions.changed mutation events include subagent ownership metadata", asy sessionKey: "agent:main:subagent:child", reason: "patch", spawnedBy: "agent:main:main", + controlOwnerSessionKey: "agent:main:main", + parentSessionKey: "agent:main:dashboard:navigation-parent", spawnedWorkspaceDir: "/tmp/subagent-workspace", spawnedCwd: "/tmp/task-repo", forkedFromParent: true, spawnDepth: 2, subagentRole: "orchestrator", subagentControlScope: "children", + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_000, + forkSource: { + sessionKey: "agent:main:main", + sessionId: "sess-source", + entryId: "entry-source", + }, + previousSessionId: "sess-previous", }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index 72aaaff43473..09970096a54c 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { expect, test } from "vitest"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js"; +import { listSessionStateEventsSince } from "../sessions/session-state-events.js"; import { testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, @@ -28,6 +29,11 @@ type ResetSessionEntry = { spawnedWorkspaceDir?: string; spawnedCwd?: string; parentSessionKey?: string; + createdVia?: string; + createdActor?: { type: string; id?: string }; + createdAt?: number; + forkSource?: { sessionKey: string; sessionId: string; entryId?: string }; + previousSessionId?: string; forkedFromParent?: boolean; spawnDepth?: number; subagentRole?: string; @@ -84,6 +90,35 @@ type ModelResetEntry = Pick< type ResolvedSessionModel = { modelProvider: string; model: string }; type SessionEntryOverrides = NonNullable[1]>; +test("sessions.reset stamps provenance when it materializes a missing row", async () => { + await createSessionStoreDir(); + const reset = await directSessionReq<{ entry: ResetSessionEntry }>( + "sessions.reset", + { key: "agent:main:subagent:missing" }, + { + client: { + authenticatedUserProfile: { profileId: "profile-reset-creator" }, + } as never, + }, + ); + + expect(reset.ok).toBe(true); + expect(reset.payload?.entry).toMatchObject({ + createdVia: "operator", + createdActor: { type: "human", id: "profile-reset-creator" }, + createdAt: expect.any(Number), + }); + expect( + listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events, + ).toContainEqual( + expect.objectContaining({ + kind: "created", + actorType: "human", + actorId: "profile-reset-creator", + }), + ); +}); + const ownedChildMetadata = { chatType: "group", channel: "discord", @@ -523,6 +558,15 @@ test("sessions.reset preserves spawned session ownership metadata", async () => "subagent:child": sessionStoreEntry("sess-owned-child", { sessionFile: customSessionFile, ...ownedChildMetadata, + forkedFromParent: undefined, + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_000, + forkSource: { + sessionKey: "agent:main:root", + sessionId: "root-session", + entryId: "root-entry", + }, }), }, }); @@ -535,9 +579,31 @@ test("sessions.reset preserves spawned session ownership metadata", async () => expect(reset.ok).toBe(true); expectOwnedChildMetadata(reset.payload?.entry); + expect(reset.payload?.entry).toMatchObject({ + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_000, + forkSource: { + sessionKey: "agent:main:root", + sessionId: "root-session", + entryId: "root-entry", + }, + previousSessionId: "sess-owned-child", + }); const stored = loadSessionEntry({ sessionKey: "agent:main:subagent:child", storePath }) as | ResetSessionEntry | undefined; expectOwnedChildMetadata(stored); + expect(stored).toMatchObject({ + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: 1_000, + forkSource: { + sessionKey: "agent:main:root", + sessionId: "root-session", + entryId: "root-entry", + }, + previousSessionId: "sess-owned-child", + }); }); diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index 30e86705f105..9ae8c919537d 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -439,33 +439,24 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(spawnedOnly.ok).toBe(true); expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]); - const spawnedPatched = await directSessionReq<{ - ok: true; - entry: { spawnedBy?: string }; - }>("sessions.patch", { - key: "agent:main:subagent:two", - spawnedBy: "agent:main:main", - }); - expect(spawnedPatched.ok).toBe(true); - expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); - - const acpPatched = await directSessionReq<{ - ok: true; - entry: { spawnedBy?: string; spawnDepth?: number }; - }>("sessions.patch", { - key: "agent:main:acp:child", + for (const [field, value] of Object.entries({ spawnedBy: "agent:main:main", + spawnedWorkspaceDir: "/tmp/subagent-workspace", + spawnedCwd: "/tmp/task-repo", spawnDepth: 1, - }); - expect(acpPatched.ok).toBe(true); - expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main"); - expect(acpPatched.payload?.entry.spawnDepth).toBe(1); - - const spawnedPatchedInvalidKey = await directSessionReq("sessions.patch", { - key: "agent:main:main", - spawnedBy: "agent:main:main", - }); - expect(spawnedPatchedInvalidKey.ok).toBe(false); + subagentRole: "leaf", + subagentControlScope: "none", + })) { + const rejected = await directSessionReq("sessions.patch", { + key: "agent:main:subagent:two", + [field]: value, + }); + expect(rejected.ok, field).toBe(false); + expect(rejected.error, field).toMatchObject({ + code: "INVALID_REQUEST", + message: expect.stringContaining(`unexpected property '${field}'`), + }); + } const cleaned = await directSessionReq<{ applied: true; diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index f81ec1b91ae2..b983231ada70 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -9,7 +9,6 @@ import { import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js"; import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js"; import { getRuntimeConfig } from "../../../config/io.js"; -import { getPairedDevice } from "../../../infra/device-pairing.js"; import { captureAuthenticatedNodePairingState, type NodePairingGeneration, @@ -214,33 +213,6 @@ export async function attachAuthenticatedGatewayConnect( ); } } - let pairedDeviceLabel: string | undefined; - if (device?.id) { - try { - const pairedDevice = await getPairedDevice(device.id); - pairedDeviceLabel = - normalizeOptionalString(pairedDevice?.operatorLabel) ?? - normalizeOptionalString(pairedDevice?.displayName); - } catch (error) { - // Pairing metadata is attribution-only and must not turn into a login dependency. - logWsControl.warn( - `paired device label resolution failed conn=${connId}: ${formatForLog(error)}`, - ); - } - } - // SSO identity wins over device labeling so one person keeps the same creator - // across browsers; paired-device labels cover gateways without trusted proxy auth. - const operatorIdentity = authenticatedUserProfile - ? { - id: authenticatedUserId, - label: authenticatedUserProfile.displayName ?? authenticatedUserId, - } - : authenticatedUserId - ? { id: authenticatedUserId, label: authenticatedUserId } - : device?.id && pairedDeviceLabel - ? { id: device.id, label: pairedDeviceLabel } - : undefined; - const pluginSurfaceUrls: Record = {}; const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities); const pendingPluginNodeCapabilities: Array<{ @@ -342,7 +314,6 @@ export async function attachAuthenticatedGatewayConnect( presenceKey, ...(authenticatedUserId ? { authenticatedUserId } : {}), ...(authenticatedUserProfile ? { authenticatedUserProfile } : {}), - ...(operatorIdentity ? { operatorIdentity } : {}), clientIp: reportedClientIp, ...(internal ? { internal } : {}), ...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}), diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index a79491a40bb6..eee08f93c826 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -628,7 +628,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { displayName: "alice", hasAvatar: false, }, - operatorIdentity: { id: "alice@example.com", label: "alice" }, }); expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true); @@ -665,10 +664,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => { }), ); }); - expect(harness.client).toMatchObject({ - authenticatedUserId: "alice@example.com", - operatorIdentity: { id: "alice@example.com", label: "alice@example.com" }, - }); + expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" }); expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() }); expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1); expect(harness.logWsControl.warn).toHaveBeenCalledWith( diff --git a/src/gateway/session-create-fork-entry.test.ts b/src/gateway/session-create-fork-entry.test.ts new file mode 100644 index 000000000000..d9f648a53970 --- /dev/null +++ b/src/gateway/session-create-fork-entry.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import type { SessionEntry } from "../config/sessions.js"; +import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js"; + +describe("buildForkedGatewaySessionEntry", () => { + it("preserves adopted node ancestry and links the replaced generation", () => { + const previous: SessionEntry = { + sessionId: "adopted-generation", + updatedAt: 1, + forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" }, + }; + + const forked = buildForkedGatewaySessionEntry( + previous, + { sessionId: "next-generation", sessionFile: "/tmp/next-generation.jsonl" }, + { sessionKey: "agent:main:new-parent", sessionId: "new-parent-generation" }, + previous, + ); + + expect(forked).toMatchObject({ + sessionId: "next-generation", + previousSessionId: "adopted-generation", + forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" }, + }); + }); + + it("uses the requested ancestry for a genuinely new node", () => { + const entry: SessionEntry = { sessionId: "provisional", updatedAt: 1 }; + const forked = buildForkedGatewaySessionEntry( + entry, + { sessionId: "forked", sessionFile: "/tmp/forked.jsonl" }, + { sessionKey: "agent:main:parent", sessionId: "parent-generation" }, + ); + + expect(forked.forkSource).toEqual({ + sessionKey: "agent:main:parent", + sessionId: "parent-generation", + }); + expect(forked.previousSessionId).toBeUndefined(); + }); +}); diff --git a/src/gateway/session-create-fork-entry.ts b/src/gateway/session-create-fork-entry.ts index f1ae66c4b674..c6800bbde5da 100644 --- a/src/gateway/session-create-fork-entry.ts +++ b/src/gateway/session-create-fork-entry.ts @@ -4,6 +4,8 @@ import type { SessionEntry } from "../config/sessions.js"; export function buildForkedGatewaySessionEntry( entry: SessionEntry, fork: { sessionId: string; sessionFile: string }, + forkSource: NonNullable, + previousEntry?: SessionEntry, ): SessionEntry { // Replacing the transcript identity also replaces the recovery episode owned by the old row. return { @@ -11,7 +13,10 @@ export function buildForkedGatewaySessionEntry( ...buildMainSessionRecoveryClearPatch(entry), sessionId: fork.sessionId, sessionFile: fork.sessionFile, - forkedFromParent: true, + forkSource: previousEntry?.forkSource ?? forkSource, + ...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId + ? { previousSessionId: previousEntry.sessionId } + : {}), totalTokens: undefined, totalTokensFresh: false, }; diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index 3057259b267a..5ae1129d0962 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -6,7 +6,6 @@ import { import { ErrorCodes, type ErrorShape, - type SessionCreatorIdentity, errorShape, missingScopeErrorShape, } from "../../packages/gateway-protocol/src/index.js"; @@ -34,6 +33,11 @@ import { createSessionEntryWithTranscript, resolveSessionEntryAccessTarget, } from "../config/sessions/session-accessor.js"; +import { + buildSessionCreationStamp, + type SessionCreatedActor, + type SessionCreatedVia, +} from "../config/sessions/session-entry-provenance.js"; import { inheritSessionSelection } from "../config/sessions/session-entry-selection.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { @@ -58,6 +62,7 @@ import { isSessionWorkAdmissionActive, runExclusiveSessionLifecycleMutation, } from "../sessions/session-lifecycle-admission.js"; +import { recordSessionCreated } from "../sessions/session-state-events.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { ADMIN_SCOPE } from "./operator-scopes.js"; import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js"; @@ -261,7 +266,6 @@ export async function createGatewaySession(params: { thinkingLevel?: string; /** Trusted catalog-owned model/runtime pair, persisted and locked together. */ catalogTarget?: TrustedCatalogSessionTarget; - createdBy?: SessionCreatorIdentity; parentSessionKey?: string; /** * Spawn-lineage depth declared by spawn-owned creations (visible subagent @@ -293,6 +297,8 @@ export async function createGatewaySession(params: { initialEntry?: TrustedInitialSessionEntry; /** Public callers need admin before reconfiguring an adopted keyed session. */ allowExistingModelSelection?: boolean; + /** Trusted in-process creation provenance; never populated from public Gateway params. */ + creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor }; /** Exact harness namespace authorized by the scoped plugin runtime. */ authorizedAgentHarnessId?: string; /** Exact plugin namespace authorized by the scoped plugin runtime. */ @@ -537,7 +543,7 @@ export async function createGatewaySession(params: { : {}), reason: "new", commandSource: params.commandSource, - createdBy: params.createdBy, + ...(params.creation ? { creation: params.creation } : {}), ...(spawnedCwd ? { spawnedCwd } : {}), ...(params.worktree ? { worktree: params.worktree } : {}), ...(params.execNode ? { execNode: params.execNode } : {}), @@ -560,6 +566,7 @@ export async function createGatewaySession(params: { } let createdContext: CreatedGatewaySession | undefined; + let createdNewEntry = false; const createChildSession = async (): Promise => { let currentParentSessionEntry = parentSessionEntry; if ( @@ -687,6 +694,9 @@ export async function createGatewaySession(params: { ), }; } + // Adoption of an existing key must not stamp provenance or emit a + // `created` event; only a genuinely new row is a node creation. + createdNewEntry = existingEntry === undefined; const requestedModel = normalizeOptionalString(params.model); const requestedThinkingLevel = normalizeOptionalString(params.thinkingLevel); if (existingEntry?.sessionId && params.allowExistingModelSelection !== true) { @@ -771,9 +781,10 @@ export async function createGatewaySession(params: { : undefined; const initializedEntry: SessionEntry = { ...patched.entry, - ...(existingEntry === undefined && params.createdBy - ? { createdBy: { ...params.createdBy } } - : {}), + // Stamp provenance only for genuinely new rows: adopting an existing key + // must not restamp write-once node facts (this direct store write bypasses + // the merge-level write-once guard), and legacy rows stay "unknown". + ...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}), ...(catalogResolvedModel && catalogAgentRuntime ? { providerOverride: catalogResolvedModel.provider, @@ -880,7 +891,15 @@ export async function createGatewaySession(params: { } return { ...initialized, - entry: buildForkedGatewaySessionEntry(entry, fork), + entry: buildForkedGatewaySessionEntry( + entry, + fork, + { + sessionKey: forkParentSessionKey, + sessionId: currentParentSessionEntry.sessionId, + }, + existingEntry, + ), }; }, params.initialEntry @@ -967,12 +986,28 @@ export async function createGatewaySession(params: { run: createChildSession, }); if (result.ok && !result.resetExisting && createdContext) { + // Adoption still runs post-create work (initial chat.send, plugin hooks); + // only the created journal event is reserved for genuinely new rows. + if (createdNewEntry) { + recordSessionCreated({ + sessionKey: createdContext.key, + agentId: createdContext.agentId, + entry: createdContext.entry, + }); + } await params.afterCreate?.(createdContext); } return result; } const result = await createChildSession(); if (result.ok && !result.resetExisting && createdContext) { + if (createdNewEntry) { + recordSessionCreated({ + sessionKey: createdContext.key, + agentId: createdContext.agentId, + entry: createdContext.entry, + }); + } await params.afterCreate?.(createdContext); } return result; diff --git a/src/gateway/session-event-payload.test.ts b/src/gateway/session-event-payload.test.ts index 10179b23d1b8..86507186fd44 100644 --- a/src/gateway/session-event-payload.test.ts +++ b/src/gateway/session-event-payload.test.ts @@ -1,21 +1,21 @@ import { expect, it } from "vitest"; import { buildGatewaySessionEventFields } from "./session-event-payload.js"; -it("projects creator identity and explicitly clears it for ownerless generations", () => { +it("projects the created actor and explicitly clears it for actorless sessions", () => { expect( buildGatewaySessionEventFields({ sessionRow: { key: "agent:main:owned", kind: "direct", updatedAt: 1, - createdBy: { id: "profile-ada", label: "Ada" }, + createdActor: { type: "human", id: "profile-ada", label: "Ada" }, }, - }).createdBy, - ).toEqual({ id: "profile-ada", label: "Ada" }); + }).createdActor, + ).toEqual({ type: "human", id: "profile-ada", label: "Ada" }); expect( buildGatewaySessionEventFields({ sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 }, - }).createdBy, + }).createdActor, ).toBeNull(); }); diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts index 3db640fe5d14..60332094cdc3 100644 --- a/src/gateway/session-event-payload.ts +++ b/src/gateway/session-event-payload.ts @@ -1,3 +1,4 @@ +import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; import type { GatewaySessionRow } from "./session-utils.js"; /** @@ -27,7 +28,7 @@ export function buildGatewaySessionEventFields(params: { return { updatedAt: sessionRow.updatedAt ?? undefined, sessionId: sessionRow.sessionId, - createdBy: sessionRow.createdBy ?? null, + createdActor: sessionRow.createdActor ?? null, kind: sessionRow.kind, channel: sessionRow.channel, subject: sessionRow.subject, @@ -46,13 +47,18 @@ export function buildGatewaySessionEventFields(params: { observerDigest: sessionRow.observerDigest ?? null, lastActivityAt: sessionRow.lastActivityAt, spawnedBy: sessionRow.spawnedBy, + controlOwnerSessionKey: sessionRow.controlOwnerSessionKey ?? null, swarmGroupId: sessionRow.swarmGroupId, spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir, spawnedCwd: sessionRow.spawnedCwd, - forkedFromParent: sessionRow.forkedFromParent, + forkedFromParent: sessionEntryForkedFromParent(sessionRow) ? true : undefined, spawnDepth: sessionRow.spawnDepth, subagentRole: sessionRow.subagentRole, subagentControlScope: sessionRow.subagentControlScope, + createdVia: sessionRow.createdVia, + createdAt: sessionRow.createdAt, + forkSource: sessionRow.forkSource, + previousSessionId: sessionRow.previousSessionId, label: params.label ?? sessionRow.label ?? null, // Explicit null so subscribed clients drop a cleared category during merge-reconcile. category: sessionRow.category ?? null, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index c92d072c8f40..76f4bcf7df24 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -1,11 +1,7 @@ // Gateway session reset/delete service. // Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks. import { randomUUID } from "node:crypto"; -import { - ErrorCodes, - errorShape, - type SessionCreatorIdentity, -} from "../../packages/gateway-protocol/src/index.js"; +import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { @@ -39,6 +35,12 @@ import { } from "../config/sessions.js"; import { rebindCliSessionReseedReceiptsForReset } from "../config/sessions/cli-session-binding.js"; import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js"; +import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; +import { + buildSessionCreationStamp, + type SessionCreatedActor, + type SessionCreatedVia, +} from "../config/sessions/session-entry-provenance.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import type { SessionAcpMeta } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -66,7 +68,10 @@ import { runExclusiveSessionLifecycleMutation, SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS, } from "../sessions/session-lifecycle-admission.js"; -import { handleSessionStateSessionReset } from "../sessions/session-state-events.js"; +import { + handleSessionStateSessionReset, + recordSessionCreated, +} from "../sessions/session-state-events.js"; import { forgetActiveSessionForShutdown, listActiveSessionsForShutdown, @@ -912,7 +917,8 @@ export async function performGatewaySessionReset(params: { clearSpawnedCwd?: boolean; reason: "new" | "reset"; commandSource: string; - createdBy?: SessionCreatorIdentity; + /** Trusted provenance for a reset that materializes a previously missing row. */ + creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor }; assertCurrent?: () => void; onCommitted?: (commit: { key: string; sessionId: string }) => void; }): Promise< @@ -1166,6 +1172,7 @@ export async function performGatewaySessionReset(params: { }) : undefined; + let createdNewEntry = false; const lifecycle = await resetSessionEntryLifecycle({ agentId: target.agentId, storePath, @@ -1174,6 +1181,7 @@ export async function performGatewaySessionReset(params: { storeKeys: target.storeKeys, }, buildNextEntry: ({ currentEntry, primaryKey }) => { + createdNewEntry = currentEntry === undefined; if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) { // A newer owner already replaced or removed the session while cleanup // targeted the old id. Preserve that newer state instead of resetting it. @@ -1193,9 +1201,17 @@ export async function performGatewaySessionReset(params: { sessionId: nextSessionId, storePath, }); + const creationStamp = currentEntry + ? { + createdVia: currentEntry.createdVia, + createdActor: currentEntry.createdActor, + createdAt: currentEntry.createdAt, + } + : params.creation + ? buildSessionCreationStamp(params.creation) + : {}; const nextEntry: SessionEntry = { sessionId: nextSessionId, - ...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}), sessionFile, updatedAt: now, systemSent: false, @@ -1249,7 +1265,10 @@ export async function performGatewaySessionReset(params: { ? undefined : (params.worktree ?? currentEntry?.worktree), parentSessionKey: currentEntry?.parentSessionKey, - forkedFromParent: currentEntry?.forkedFromParent, + ...creationStamp, + forkSource: currentEntry?.forkSource, + previousSessionId: currentEntry?.sessionId, + forkedFromParent: sessionEntryForkedFromParent(currentEntry) ? true : undefined, spawnDepth: currentEntry?.spawnDepth, subagentRole: currentEntry?.subagentRole, subagentControlScope: currentEntry?.subagentControlScope, @@ -1295,6 +1314,13 @@ export async function performGatewaySessionReset(params: { return nextEntry; }, afterEntryMutation: async (mutation) => { + if (createdNewEntry) { + recordSessionCreated({ + sessionKey: target.canonicalKey ?? params.key, + agentId, + entry: mutation.nextEntry, + }); + } let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined; if (deferredAcpResetState) { const identity = deferredAcpResetState.meta.identity; diff --git a/src/gateway/session-utils-creators.test.ts b/src/gateway/session-utils-creators.test.ts index a250f0db7ab8..1993b2fbd2ca 100644 --- a/src/gateway/session-utils-creators.test.ts +++ b/src/gateway/session-utils-creators.test.ts @@ -1,17 +1,27 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const getUserProfileListItem = vi.hoisted(() => + vi.fn((profileId: string) => ({ + id: profileId, + displayName: profileId === "profile-ada" ? "Ada" : "Bob", + })), +); + +vi.mock("../state/user-profiles.js", () => ({ getUserProfileListItem })); + import { listSessionsFromStore } from "./session-utils.js"; it("returns the complete deterministic creator facet independently of pagination", () => { const store: Record = { "agent:main:ada": { - createdBy: { id: "profile-ada", label: "Ada" }, + createdActor: { type: "human", id: "profile-ada" }, sessionId: "session-ada", updatedAt: 2, }, "agent:main:bob": { - createdBy: { id: "profile-bob", label: "Bob" }, + createdActor: { type: "human", id: "profile-bob" }, sessionId: "session-bob", updatedAt: 1, }, @@ -30,6 +40,12 @@ it("returns the complete deterministic creator facet independently of pagination { id: "profile-ada", label: "Ada" }, { id: "profile-bob", label: "Bob" }, ]); + expect(result.sessions[0]?.createdActor).toEqual({ + type: "human", + id: "profile-ada", + label: "Ada", + }); + expect(getUserProfileListItem).toHaveBeenCalledTimes(2); const filtered = listSessionsFromStore({ cfg: {} as OpenClawConfig, diff --git a/src/gateway/session-utils.subagent.test.ts b/src/gateway/session-utils.subagent.test.ts index 9a6851e96ec5..40ff935c9aee 100644 --- a/src/gateway/session-utils.subagent.test.ts +++ b/src/gateway/session-utils.subagent.test.ts @@ -111,6 +111,59 @@ describe("listSessionsFromStore subagent metadata", () => { } }); + test("keeps persisted navigation lineage separate from live registry control", () => { + const now = Date.now(); + const childSessionKey = "agent:main:subagent:controlled-child"; + const entry = { + sessionId: "sess-controlled-child", + updatedAt: now, + spawnedBy: "agent:main:subagent:persisted-spawner", + parentSessionKey: "agent:main:dashboard:navigation-parent", + createdVia: "spawn", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: now - 10_000, + forkSource: { + sessionKey: "agent:main:main", + sessionId: "sess-source", + entryId: "entry-source", + }, + previousSessionId: "sess-previous", + } satisfies SessionEntry; + + addSubagentRunForTests({ + runId: "run-controlled-child", + childSessionKey, + controllerSessionKey: "agent:main:subagent:runtime-controller", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "controlled child", + cleanup: "keep", + createdAt: now - 5_000, + startedAt: now - 4_000, + }); + + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { [childSessionKey]: entry }, + opts: {}, + }); + const row = expectDefined(result.sessions[0], "controlled child row"); + + expect(row.spawnedBy).toBe("agent:main:subagent:runtime-controller"); + expect(row.controlOwnerSessionKey).toBe("agent:main:subagent:runtime-controller"); + expect(row.parentSessionKey).toBe("agent:main:dashboard:navigation-parent"); + expect(row.createdVia).toBe("spawn"); + expect(row.createdActor).toEqual({ type: "agent", id: "agent:main:main" }); + expect(row.createdAt).toBe(now - 10_000); + expect(row.forkSource).toEqual({ + sessionKey: "agent:main:main", + sessionId: "sess-source", + entryId: "entry-source", + }); + expect(row.previousSessionId).toBe("sess-previous"); + }); + test("includes subagent status timing and direct child session keys", () => { const now = Date.now(); const store: Record = { @@ -576,7 +629,7 @@ describe("listSessionsFromStore subagent metadata", () => { expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-owner"); }); - test("reports the newest parentSessionKey for moved child session rows", () => { + test("keeps the persisted parentSessionKey while reporting the newest runtime controller", () => { const now = Date.now(); const childSessionKey = "agent:main:subagent:shared-child-parent"; const store: Record = { @@ -621,7 +674,11 @@ describe("listSessionsFromStore subagent metadata", () => { expect(result.sessions).toHaveLength(1); expect(result.sessions[0]?.key).toBe(childSessionKey); - expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:new-parent-parent"); + expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:old-parent-parent"); + expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-parent"); + expect(result.sessions[0]?.controlOwnerSessionKey).toBe( + "agent:main:subagent:new-parent-parent", + ); }); test("preserves original session timing across follow-up replacement runs", () => { diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index fa8fbb175bde..93b9ea1ea3fa 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -227,6 +227,18 @@ describe("gateway session utils", () => { ); }); + test("emits a tombstone when a session has no current control owner", () => { + const row = buildGatewaySessionRow({ + cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }), + storePath: "", + store: {}, + key: "agent:main:child-without-owner", + entry: {} as SessionEntry, + }); + + expect(buildGatewaySessionEventFields({ sessionRow: row }).controlOwnerSessionKey).toBeNull(); + }); + test("projects only unexpired agent status", () => { const entry = { sessionId: "session", diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 8f8ed75ca847..af1a900774e1 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -8,7 +8,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import type { - SessionCreatorIdentity, + SessionCreatedActor, SessionsListParams, } from "../../packages/gateway-protocol/src/index.js"; import { @@ -96,6 +96,7 @@ import { listSessionEntries as listAccessorSessionEntries, listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly, } from "../config/sessions/session-accessor.js"; +import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js"; import { withPinnedActivePluginRegistryWorkspaceDir } from "../plugins/runtime-workspace-state.js"; @@ -107,6 +108,7 @@ import { import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js"; import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js"; import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; +import { getUserProfileListItem } from "../state/user-profiles.js"; import { truncateUtf16Safe } from "../utils.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; @@ -408,6 +410,7 @@ type SessionListRowContext = { >; displayModelIdentityByKey: Map; modelCostConfigByModelRef: Map; + userProfileLabelById: Map; }; type SessionListRowContextProvider = () => SessionListRowContext; @@ -661,6 +664,7 @@ function buildSessionListRowContextFromParts(params: { thinkingMetadataByModelRef: new Map(), displayModelIdentityByKey: new Map(), modelCostConfigByModelRef: new Map(), + userProfileLabelById: new Map(), }; } @@ -1919,6 +1923,31 @@ export function resolveSessionDisplayModelIdentityRef(params: { }; } +/** Adds the current human profile label without persisting rename-prone display data. */ +function projectSessionCreatedActor( + actor: SessionEntry["createdActor"], + userProfileLabelById: Map = new Map(), +): SessionCreatedActor | undefined { + if (!actor) { + return undefined; + } + const id = normalizeOptionalString(actor.id); + if (actor.type !== "human" || !id) { + return { type: actor.type, ...(id ? { id } : {}) }; + } + let label = userProfileLabelById.get(id); + if (!userProfileLabelById.has(id)) { + try { + label = normalizeOptionalString(getUserProfileListItem(id).displayName); + } catch { + // Human actors can also be channel sender ids; only profile ids resolve here. + label = undefined; + } + userProfileLabelById.set(id, label); + } + return { type: actor.type, id, ...(label ? { label } : {}) }; +} + export function buildGatewaySessionRow(params: { cfg: OpenClawConfig; storePath: string; @@ -2225,18 +2254,24 @@ export function buildGatewaySessionRow(params: { return { key, - createdBy: entry?.createdBy, spawnedBy: subagentOwner || entry?.spawnedBy, + // The live registry controller takes precedence over the persisted spawner. + controlOwnerSessionKey: subagentOwner || entry?.spawnedBy, swarmGroupId: entry?.swarmGroupId, spawnedWorkspaceDir: entry?.spawnedWorkspaceDir, spawnedCwd: entry?.spawnedCwd, worktree: entry?.worktree, execNode: entry?.execNode, execCwd: entry?.execCwd, - forkedFromParent: entry?.forkedFromParent, + forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined, spawnDepth: entry?.spawnDepth, subagentRole: entry?.subagentRole, subagentControlScope: entry?.subagentControlScope, + createdVia: entry?.createdVia, + createdActor: projectSessionCreatedActor(entry?.createdActor, rowContext?.userProfileLabelById), + createdAt: entry?.createdAt, + forkSource: entry?.forkSource, + previousSessionId: entry?.previousSessionId, kind: classifySessionKey(key, entry), label: entry?.label, category: entry?.category, @@ -2299,7 +2334,8 @@ export function buildGatewaySessionRow(params: { startedAt: subagentRun ? subagentStartedAt : entry?.startedAt, endedAt: subagentRun ? subagentEndedAt : entry?.endedAt, runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs, - parentSessionKey: subagentOwner || entry?.parentSessionKey, + // Navigation lineage is persisted; runtime control is exposed separately above. + parentSessionKey: entry?.parentSessionKey, childSessions, responseUsage: entry?.responseUsage, effectiveResponseUsage: resolveEffectiveResponseUsage( @@ -2720,7 +2756,7 @@ function selectSessionEntries(params: { const creatorEntries = filterSessionEntries(params); const creatorId = normalizeOptionalString(params.opts.creatorId); const filtered = creatorId - ? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId) + ? creatorEntries.filter(([, entry]) => entry.createdActor?.id === creatorId) : creatorEntries; const limit = resolveSessionsListLimit(params.opts, params.defaultLimit); const offset = resolveSessionsListOffset(params.opts); @@ -2743,14 +2779,16 @@ function selectSessionEntries(params: { function listSessionCreatorIdentities( entries: readonly SessionEntryPair[], -): SessionCreatorIdentity[] { - const creators = new Map(); + userProfileLabelById: Map, +): Array<{ id: string; label?: string }> { + const creators = new Map(); for (const [, entry] of entries) { - const id = normalizeOptionalString(entry.createdBy?.id); + const actor = projectSessionCreatedActor(entry.createdActor, userProfileLabelById); + const id = normalizeOptionalString(actor?.id); if (!id) { continue; } - const label = normalizeOptionalString(entry.createdBy?.label); + const label = normalizeOptionalString(actor?.label); const existing = creators.get(id); if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { creators.set(id, { id, ...(label ? { label } : {}) }); @@ -2849,7 +2887,10 @@ export function listSessionsFromStore(params: { offset: offset > 0 ? offset : undefined, nextOffset, hasMore, - creators: listSessionCreatorIdentities(creatorEntries), + creators: listSessionCreatorIdentities( + creatorEntries, + sharedRowContext?.userProfileLabelById ?? new Map(), + ), defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }), sessions, }; @@ -2902,7 +2943,8 @@ export async function listSessionsFromStoreAsync(params: { : undefined, defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT, }); - const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection; + const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } = + selection; const fullRowContext = rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE ? getRowContext() @@ -2981,6 +3023,10 @@ export async function listSessionsFromStoreAsync(params: { offset: offset > 0 ? offset : undefined, nextOffset, hasMore, + creators: listSessionCreatorIdentities( + creatorEntries, + sharedRowContext?.userProfileLabelById ?? new Map(), + ), defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }), sessions, }; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index d2e87f4e6fd3..2488c442adbd 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -1,8 +1,11 @@ // Shared Gateway session projection types. // Keeps server methods and Control UI payloads aligned. import type { FastMode } from "@openclaw/normalization-core/string-coerce"; -import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js"; -import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js"; +import type { + SessionCreatedActor, + SessionPlacement, + SessionRow, +} from "../../packages/gateway-protocol/src/index.js"; import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js"; import type { QueueMode } from "../auto-reply/reply/queue/types.js"; import type { ChatType } from "../channels/chat-type.js"; @@ -46,8 +49,9 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; - createdBy?: SessionCreatorIdentity; spawnedBy?: string; + /** Current runtime controller, falling back to the durable spawning session. */ + controlOwnerSessionKey?: string; /** Collector swarm group that owns this child session, when applicable. */ swarmGroupId?: string; spawnedWorkspaceDir?: string; @@ -62,6 +66,11 @@ export type GatewaySessionRow = { spawnDepth?: number; subagentRole?: SessionEntry["subagentRole"]; subagentControlScope?: SessionEntry["subagentControlScope"]; + createdVia?: SessionEntry["createdVia"]; + createdActor?: SessionCreatedActor; + createdAt?: SessionEntry["createdAt"]; + forkSource?: SessionEntry["forkSource"]; + previousSessionId?: SessionEntry["previousSessionId"]; kind: "direct" | "group" | "global" | "unknown"; label?: string; /** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */ @@ -151,6 +160,16 @@ export type GatewaySessionRow = { pluginExtensions?: PluginSessionExtensionProjection[]; }; +/** + * Compile-time drift guard: fails typecheck when the Gateway projection stops + * matching the protocol schema's documented row fields. Value-level so the + * unused-export scan sees a consumer. + */ +const sessionRowSchemaDriftGuard: Pick extends SessionRow + ? true + : false = true; +void sessionRowSchemaDriftGuard; + export type GatewayAgentRow = SharedGatewayAgentRow; export type SessionPreviewItem = { diff --git a/src/gateway/sessions-patch-subagent-policy.ts b/src/gateway/sessions-patch-subagent-policy.ts new file mode 100644 index 000000000000..b3a3565f82a1 --- /dev/null +++ b/src/gateway/sessions-patch-subagent-policy.ts @@ -0,0 +1,99 @@ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { SessionsPatchParams } from "../../packages/gateway-protocol/src/index.js"; +import { + normalizeInheritedToolAllowlist, + normalizeInheritedToolDenylist, +} from "../agents/inherited-tool-deny.js"; +import type { SessionEntry } from "../config/sessions.js"; +import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js"; + +function supportsSpawnPolicy(storeKey: string): boolean { + return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); +} + +function unsupportedField(field: string, storeKey: string): string | undefined { + return supportsSpawnPolicy(storeKey) + ? undefined + : `${field} is only supported for subagent:* or acp:* sessions`; +} + +/** Applies the remaining public child-policy fields after lineage became creation-only. */ +export function applySessionsPatchSubagentPolicy(params: { + existing?: SessionEntry; + next: SessionEntry; + patch: SessionsPatchParams; + storeKey: string; +}): string | undefined { + const { existing, next, patch, storeKey } = params; + if ("completionOwnerSessionKey" in patch) { + const raw = patch.completionOwnerSessionKey; + if (raw === null && existing?.completionOwnerSessionKey) { + return "completionOwnerSessionKey cannot be cleared once set"; + } + if (raw !== null && raw !== undefined) { + const unsupported = unsupportedField("completionOwnerSessionKey", storeKey); + if (unsupported) { + return unsupported; + } + const normalized = normalizeOptionalString(raw); + if (!normalized) { + return "invalid completionOwnerSessionKey: empty"; + } + if ( + existing?.completionOwnerSessionKey && + existing.completionOwnerSessionKey !== normalized + ) { + return "completionOwnerSessionKey cannot be changed once set"; + } + next.completionOwnerSessionKey = normalized; + } + } + + if ("inheritedToolPolicyVersion" in patch) { + const raw = patch.inheritedToolPolicyVersion; + if (raw === null && existing?.inheritedToolPolicyVersion !== undefined) { + return "inheritedToolPolicyVersion cannot be cleared once set"; + } + if (raw !== null && raw !== undefined) { + const unsupported = unsupportedField("inheritedToolPolicyVersion", storeKey); + if (unsupported) { + return unsupported; + } + if (raw !== 1) { + return "invalid inheritedToolPolicyVersion (expected 1)"; + } + next.inheritedToolPolicyVersion = 1; + } + } + + for (const field of ["inheritedToolDeny", "inheritedToolAllow"] as const) { + if (!(field in patch)) { + continue; + } + const raw = patch[field]; + if (raw === null) { + delete next[field]; + continue; + } + if (raw === undefined) { + continue; + } + if (!Array.isArray(raw)) { + return `invalid ${field} (use an array of tool names)`; + } + const unsupported = unsupportedField(field, storeKey); + if (unsupported) { + return unsupported; + } + const normalized = + field === "inheritedToolDeny" + ? normalizeInheritedToolDenylist(raw) + : normalizeInheritedToolAllowlist(raw); + if (normalized.length > 0) { + next[field] = normalized; + } else { + delete next[field]; + } + } + return undefined; +} diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts index e877ef7f58ad..a5667f3d65e9 100644 --- a/src/gateway/sessions-patch.test.ts +++ b/src/gateway/sessions-patch.test.ts @@ -1012,16 +1012,6 @@ describe("gateway sessions patch", () => { expect(entry.modelOverrideSource).toBe("user"); }); - test("sets spawnDepth for subagent sessions", async () => { - const entry = expectPatchOk( - await runPatch({ - storeKey: "agent:main:subagent:child", - patch: { key: "agent:main:subagent:child", spawnDepth: 2 }, - }), - ); - expect(entry.spawnDepth).toBe(2); - }); - test("validates thinking patches with live catalog reasoning metadata", async () => { const registry = createEmptyPluginRegistry(); registry.providers.push({ @@ -1279,19 +1269,6 @@ describe("gateway sessions patch", () => { expect(entry).toMatchObject({ label: "new label", thinkingLevel: "max" }); }); - test("sets spawnedBy for ACP sessions", async () => { - const entry = expectPatchOk( - await runPatch({ - storeKey: "agent:main:acp:child", - patch: { - key: "agent:main:acp:child", - spawnedBy: "agent:main:main", - }, - }), - ); - expect(entry.spawnedBy).toBe("agent:main:main"); - }); - test("sets an immutable completion owner for ACP sessions", async () => { const entry = expectPatchOk( await runPatch({ @@ -1315,29 +1292,6 @@ describe("gateway sessions patch", () => { expectPatchError(result, "completionOwnerSessionKey cannot be changed once set"); }); - test("sets spawnedWorkspaceDir for subagent sessions", async () => { - const entry = expectPatchOk( - await runPatch({ - storeKey: "agent:main:subagent:child", - patch: { - key: "agent:main:subagent:child", - spawnedWorkspaceDir: "/tmp/subagent-workspace", - }, - }), - ); - expect(entry.spawnedWorkspaceDir).toBe("/tmp/subagent-workspace"); - }); - - test("sets spawnDepth for ACP sessions", async () => { - const entry = expectPatchOk( - await runPatch({ - storeKey: "agent:main:acp:child", - patch: { key: "agent:main:acp:child", spawnDepth: 2 }, - }), - ); - expect(entry.spawnDepth).toBe(2); - }); - test("sets an immutable requester policy snapshot version for ACP sessions", async () => { const entry = expectPatchOk( await runPatch({ @@ -1393,20 +1347,6 @@ describe("gateway sessions patch", () => { expect(entry.inheritedToolDeny?.at(-1)).toBe("exec"); }); - test("rejects spawnDepth on non-subagent sessions", async () => { - const result = await runPatch({ - patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 }, - }); - expectPatchError(result, "spawnDepth is only supported"); - }); - - test("rejects spawnedWorkspaceDir on non-subagent sessions", async () => { - const result = await runPatch({ - patch: { key: MAIN_SESSION_KEY, spawnedWorkspaceDir: "/tmp/nope" }, - }); - expectPatchError(result, "spawnedWorkspaceDir is only supported"); - }); - test("rejects inheritedToolDeny on non-subagent sessions", async () => { const result = await runPatch({ patch: { key: MAIN_SESSION_KEY, inheritedToolDeny: ["exec"] }, diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 4b6a16f053d9..286e7b2f8491 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -13,10 +13,6 @@ import { } from "../../packages/gateway-protocol/src/index.js"; import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { - normalizeInheritedToolAllowlist, - normalizeInheritedToolDenylist, -} from "../agents/inherited-tool-deny.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js"; import { @@ -40,7 +36,6 @@ import type { SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeExecTarget } from "../infra/exec-approvals.js"; import { - isAcpSessionKey, isSubagentSessionKey, normalizeAgentId, parseAgentSessionKey, @@ -74,6 +69,7 @@ import { shouldPreserveSessionAuthProfileOverride, snapshotAgentModelFallback, } from "./session-model-patch-origin.js"; +import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js"; function invalid(message: string): { ok: false; error: ErrorShape } { return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) }; @@ -127,26 +123,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined return undefined; } -function supportsSpawnLineage(storeKey: string): boolean { - return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey); -} - -function normalizeSubagentRole(raw: string): "orchestrator" | "leaf" | undefined { - const normalized = normalizeOptionalLowercaseString(raw); - if (normalized === "orchestrator" || normalized === "leaf") { - return normalized; - } - return undefined; -} - -function normalizeSubagentControlScope(raw: string): "children" | "none" | undefined { - const normalized = normalizeOptionalLowercaseString(raw); - if (normalized === "children" || normalized === "none") { - return normalized; - } - return undefined; -} - type SessionPatchProjectionEntry = { entry: SessionEntry; sessionKey: string; @@ -238,185 +214,14 @@ export async function projectSessionsPatchEntry(params: { delete next.displayName; } - type PatchError = ReturnType | null; - const checkSpawnLineage = (field: string): PatchError => - supportsSpawnLineage(storeKey) - ? null - : invalid(`${field} is only supported for subagent:* or acp:* sessions`); - const applyImmutableString = ( - field: "spawnedBy" | "completionOwnerSessionKey" | "spawnedWorkspaceDir" | "spawnedCwd", - checkLineageBeforeEmpty: boolean, - ): PatchError => { - if (!(field in patch)) { - return null; - } - const raw = patch[field]; - if (raw === null) { - return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null; - } - if (raw === undefined) { - return null; - } - const earlyLineage = checkLineageBeforeEmpty ? checkSpawnLineage(field) : null; - if (earlyLineage) { - return earlyLineage; - } - const trimmed = normalizeOptionalString(raw) ?? ""; - if (!trimmed) { - return invalid(`invalid ${field}: empty`); - } - const lateLineage = checkLineageBeforeEmpty ? null : checkSpawnLineage(field); - if (lateLineage) { - return lateLineage; - } - if (existing?.[field] && existing[field] !== trimmed) { - return invalid(`${field} cannot be changed once set`); - } - next[field] = trimmed; - return null; - }; - const applyImmutableNormalized = ( - field: T, - normalize: (raw: string) => NonNullable | undefined, - invalidMessage: string, - ): PatchError => { - if (!(field in patch)) { - return null; - } - const raw = patch[field]; - if (raw === null) { - return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null; - } - if (raw === undefined) { - return null; - } - const lineage = checkSpawnLineage(field); - if (lineage) { - return lineage; - } - const normalized = normalize(raw); - if (!normalized) { - return invalid(invalidMessage); - } - if (existing?.[field] && existing[field] !== normalized) { - return invalid(`${field} cannot be changed once set`); - } - next[field] = normalized; - return null; - }; - - for (const fieldParams of [ - { field: "spawnedBy" as const, checkLineageBeforeEmpty: false }, - { field: "completionOwnerSessionKey" as const, checkLineageBeforeEmpty: false }, - { field: "spawnedWorkspaceDir" as const, checkLineageBeforeEmpty: true }, - { field: "spawnedCwd" as const, checkLineageBeforeEmpty: true }, - ]) { - const result = applyImmutableString(fieldParams.field, fieldParams.checkLineageBeforeEmpty); - if (result) { - return result; - } - } - - if ("spawnDepth" in patch) { - const raw = patch.spawnDepth; - if (raw === null) { - if (typeof existing?.spawnDepth === "number") { - return invalid("spawnDepth cannot be cleared once set"); - } - } else if (raw !== undefined) { - if (!supportsSpawnLineage(storeKey)) { - return invalid("spawnDepth is only supported for subagent:* or acp:* sessions"); - } - const numeric = raw; - if (!Number.isInteger(numeric) || numeric < 0) { - return invalid("invalid spawnDepth (use an integer >= 0)"); - } - const normalized = numeric; - if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) { - return invalid("spawnDepth cannot be changed once set"); - } - next.spawnDepth = normalized; - } - } - - for (const fieldParams of [ - { - field: "subagentRole" as const, - normalize: normalizeSubagentRole, - invalidMessage: 'invalid subagentRole (use "orchestrator" or "leaf")', - }, - { - field: "subagentControlScope" as const, - normalize: normalizeSubagentControlScope, - invalidMessage: 'invalid subagentControlScope (use "children" or "none")', - }, - ]) { - const result = applyImmutableNormalized( - fieldParams.field, - fieldParams.normalize, - fieldParams.invalidMessage, - ); - if (result) { - return result; - } - } - - if ("inheritedToolPolicyVersion" in patch) { - const raw = patch.inheritedToolPolicyVersion; - if (raw === null) { - if (existing?.inheritedToolPolicyVersion !== undefined) { - return invalid("inheritedToolPolicyVersion cannot be cleared once set"); - } - } else if (raw !== undefined) { - const lineage = checkSpawnLineage("inheritedToolPolicyVersion"); - if (lineage) { - return lineage; - } - if (raw !== 1) { - return invalid("invalid inheritedToolPolicyVersion (expected 1)"); - } - next.inheritedToolPolicyVersion = 1; - } - } - - if ("inheritedToolDeny" in patch) { - const raw = patch.inheritedToolDeny; - if (raw === null) { - delete next.inheritedToolDeny; - } else if (raw !== undefined) { - if (!Array.isArray(raw)) { - return invalid("invalid inheritedToolDeny (use an array of tool names)"); - } - if (!supportsSpawnLineage(storeKey)) { - return invalid("inheritedToolDeny is only supported for subagent:* or acp:* sessions"); - } - const inheritedToolDeny = normalizeInheritedToolDenylist(raw); - if (inheritedToolDeny.length > 0) { - next.inheritedToolDeny = inheritedToolDeny; - } else { - delete next.inheritedToolDeny; - } - } - } - - if ("inheritedToolAllow" in patch) { - const raw = patch.inheritedToolAllow; - if (raw === null) { - delete next.inheritedToolAllow; - } else if (raw !== undefined) { - if (!Array.isArray(raw)) { - return invalid("invalid inheritedToolAllow (use an array of tool names)"); - } - if (!supportsSpawnLineage(storeKey)) { - return invalid("inheritedToolAllow is only supported for subagent:* or acp:* sessions"); - } - const inheritedToolAllow = normalizeInheritedToolAllowlist(raw); - if (inheritedToolAllow.length > 0) { - next.inheritedToolAllow = inheritedToolAllow; - } else { - delete next.inheritedToolAllow; - } - } + const subagentPolicyError = applySessionsPatchSubagentPolicy({ + existing, + next, + patch, + storeKey, + }); + if (subagentPolicyError) { + return invalid(subagentPolicyError); } if ("label" in patch) { @@ -866,4 +671,3 @@ export async function applySessionsPatchToStore(params: { } return projected; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/runtime/runtime-agent.test.ts b/src/plugins/runtime/runtime-agent.test.ts index 2728461a29db..1a670e4f836c 100644 --- a/src/plugins/runtime/runtime-agent.test.ts +++ b/src/plugins/runtime/runtime-agent.test.ts @@ -145,6 +145,9 @@ describe("plugin runtime session creation", () => { }, }); expect(created.entry).toMatchObject({ + createdVia: "plugin", + createdActor: { type: "system", id: "anthropic" }, + createdAt: expect.any(Number), pluginOwnerId: "anthropic", providerOverride: "claude-cli", modelOverride: "claude-opus-4-8", diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index 6acdde6dae5d..949c3b0e44f0 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -303,6 +303,13 @@ async function createSessionEntry( }, ...(harnessInitial ? { authorizedAgentHarnessId: harnessInitial.agentHarnessId } : {}), ...(cliInitial?.pluginOwnerId ? { authorizedPluginId: cliInitial.pluginOwnerId } : {}), + creation: { + via: "plugin", + actor: { + type: "system", + ...(cliInitial?.pluginOwnerId ? { id: cliInitial.pluginOwnerId } : {}), + }, + }, commandSource: "plugin-runtime", ...(afterCreate ? { afterCreate: runAfterCreate } : {}), }); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 30e5f9a2e3e2..d62c404a2d6a 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -14,7 +14,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "pluginExtensionSlotKeys", "pluginNextTurnInjections", "sessionId", - "createdBy", "lifecycleRevision", "updatedAt", "archivedAt", @@ -32,6 +31,11 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "spawnedCwd", "worktree", "parentSessionKey", + "createdVia", + "createdActor", + "createdAt", + "forkSource", + "previousSessionId", "forkedFromParent", "spawnDepth", "swarmGroupId", diff --git a/src/sessions/session-state-event-kinds.ts b/src/sessions/session-state-event-kinds.ts index 1187743c5070..aa7caa4a3215 100644 --- a/src/sessions/session-state-event-kinds.ts +++ b/src/sessions/session-state-event-kinds.ts @@ -1,6 +1,7 @@ export type SessionStateActorType = "human" | "agent" | "system"; export type SessionStateEventKind = + | "created" | "human_direct_message" | "adopted" | "run_completed" @@ -12,6 +13,7 @@ export type SessionStateEventKind = // Future utility-model materiality belongs at this deterministic seam; no config until then. export const NOTIFY_BY_SESSION_STATE_EVENT_KIND: Record = { + created: false, human_direct_message: true, upstream_missing: true, adopted: false, diff --git a/src/sessions/session-state-events.test.ts b/src/sessions/session-state-events.test.ts index e7de515813c8..3c25b46dad51 100644 --- a/src/sessions/session-state-events.test.ts +++ b/src/sessions/session-state-events.test.ts @@ -22,6 +22,7 @@ import { listAmbientGroupWatchTargets, listSessionStateEventsSince, recordSessionCompacted, + recordSessionCreated, recordSessionGoalChanged, recordSessionHumanDirectMessage, recordSessionStateEvent, @@ -776,6 +777,17 @@ describe("session state events", () => { it("projects spawn, terminal, goal, and compaction producer helpers", () => { const database = createDatabaseOptions(); + recordSessionCreated({ + sessionKey: child, + agentId: "main", + entry: { + sessionId: "session-child", + updatedAt: Date.now(), + createdVia: "spawn", + createdActor: { type: "agent", id: watcher }, + createdAt: Date.now(), + }, + }); recordSubagentSpawned({ childSessionKey: child, childRunId: "run-child", @@ -823,13 +835,19 @@ describe("session state events", () => { const events = listSessionStateEventsSince(child, "main", 0, 200, database).events; expect(events.map((event) => event.kind)).toEqual([ + "created", "child_spawned", "run_completed", "run_failed", "goal_changed", "compacted", ]); - expect(events[2]).toMatchObject({ + expect(events[0]).toMatchObject({ + actorType: "agent", + actorId: watcher, + summary: "session created", + }); + expect(events[3]).toMatchObject({ runId: "run-child-cancelled", summary: "child run cancelled", payload: { outcome: "cancelled" }, diff --git a/src/sessions/session-state-events.ts b/src/sessions/session-state-events.ts index 884641f1cacd..0783fb6166ef 100644 --- a/src/sessions/session-state-events.ts +++ b/src/sessions/session-state-events.ts @@ -790,6 +790,28 @@ export function recordSessionGoalChanged(params: { }); } +/** Record the child's own creation fact when its durable stamp identifies an actor. */ +export function recordSessionCreated(params: { + sessionKey: string; + entry: SessionEntry; + agentId?: string; +}): void { + const actor = params.entry.createdActor; + if (!actor) { + return; + } + recordSessionStateEvent({ + sessionKey: params.sessionKey, + sessionId: params.entry.sessionId, + agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey), + kind: "created", + actorType: actor.type, + ...(actor.id ? { actorId: actor.id } : {}), + dedupeKey: `created:${params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey)}:${params.sessionKey}:${params.entry.sessionId}`, + summary: "session created", + }); +} + /** True when any seeded or explicitly registered watcher cursor targets this session. */ function hasSessionStateWatchers( targetSessionKey: string, diff --git a/src/shared/session-types.ts b/src/shared/session-types.ts index 63bc2513831b..68aadbab4800 100644 --- a/src/shared/session-types.ts +++ b/src/shared/session-types.ts @@ -1,5 +1,3 @@ -import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js"; - /** Agent identity fields returned by gateway session listing APIs. */ type GatewayAgentIdentity = { name?: string; @@ -64,7 +62,7 @@ export type SessionsListResultBase = { nextOffset?: number | null; hasMore?: boolean; /** Complete creator facet for the filtered result, independent of pagination. */ - creators?: SessionCreatorIdentity[]; + creators?: Array<{ id: string; label?: string }>; defaults: TDefaults; sessions: TRow[]; }; diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index e3487bbba3d9..461532e4aa7e 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -252,15 +252,6 @@ function migrateOpenClawAgentSchema(db: DatabaseSync): void { backfillTranscriptMutationWatermarks(db); } -function ensureAdditiveSessionEntryColumns(db: DatabaseSync): void { - const columns = readSqliteTableColumns(db, "session_entries"); - if (columns && !columns.has("created_by_json")) { - // This nullable projection is safe for older readers and intentionally - // stays outside the schema-version migration ladder. - db.exec("ALTER TABLE session_entries ADD COLUMN created_by_json TEXT;"); - } -} - /** Backfill one generation token without copying or rewriting transcript rows. */ function migrateSessionTranscriptGenerations(db: DatabaseSync, previousVersion: number): void { if (previousVersion >= 13) { @@ -522,7 +513,6 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string): dropLegacySessionTranscriptSearchSchema(db); migrateMemoryIndexSourcesIdentity(db); migrateOpenClawAgentSchema(db); - ensureAdditiveSessionEntryColumns(db); db.exec( previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION ? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index c8e4a758eae3..d125b098dade 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -186,7 +186,6 @@ export interface SessionConversations { } export interface SessionEntries { - created_by_json: string | null; entry_json: string; session_id: string; session_key: string; diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index d845c1de94b3..609d3266dfd6 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -1820,30 +1820,6 @@ describe("openclaw agent database", () => { expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal"); }); - it("lazy-ensures the additive session creator column without a version bump", () => { - const stateDir = createTempStateDir(); - const env = { OPENCLAW_STATE_DIR: stateDir }; - const database = openOpenClawAgentDatabase({ agentId: "worker-1", env }); - const databasePath = database.path; - const schemaVersion = readSqliteNumberPragma(database.db, "user_version"); - closeOpenClawAgentDatabasesForTest(); - - const { DatabaseSync } = requireNodeSqlite(); - const legacy = new DatabaseSync(databasePath); - try { - legacy.exec("ALTER TABLE session_entries DROP COLUMN created_by_json;"); - } finally { - legacy.close(); - } - - const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env }); - const columns = reopened.db.prepare("PRAGMA table_info(session_entries)").all() as Array<{ - name: string; - }>; - expect(columns.map((column) => column.name)).toContain("created_by_json"); - expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(schemaVersion); - }); - it("backfills per-entry status while migrating a v6 agent database", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts index 24ab105185a3..254f47f65712 100644 --- a/src/state/openclaw-agent-schema.generated.ts +++ b/src/state/openclaw-agent-schema.generated.ts @@ -169,7 +169,6 @@ CREATE TABLE IF NOT EXISTS session_entries ( entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL, status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')), - created_by_json TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ) STRICT; diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index ab50e44ea749..afdf06a78c27 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -164,7 +164,6 @@ CREATE TABLE IF NOT EXISTS session_entries ( entry_json TEXT NOT NULL, updated_at INTEGER NOT NULL, status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')), - created_by_json TEXT, FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE ) STRICT; diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index 3e26d6661bb3..557a832e110e 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -49,6 +49,9 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) { { sessionId?: string; updatedAt?: number; + createdVia?: SessionEntry["createdVia"]; + createdActor?: SessionEntry["createdActor"]; + createdAt?: number; archivedAt?: number; sessionFile?: string; spawnedBy?: string; @@ -239,7 +242,14 @@ describe("realtime voice agent consult runtime", () => { if (!voiceSession) { throw new Error("Expected voice consult session entry"); } - expect(Object.keys(voiceSession).toSorted()).toStrictEqual(["sessionId", "updatedAt"]); + expect(Object.keys(voiceSession).toSorted()).toStrictEqual([ + "createdAt", + "createdVia", + "sessionId", + "updatedAt", + ]); + expect(voiceSession.createdVia).toBe("talk"); + expectPositiveTimestamp(voiceSession.createdAt); expectNonEmptyString(voiceSession.sessionId); expectPositiveTimestamp(voiceSession.updatedAt); const call = requireEmbeddedAgentCall(runEmbeddedAgent); @@ -551,6 +561,9 @@ describe("realtime voice agent consult runtime", () => { sessionFile: testTempPath("forked.jsonl"), spawnedBy: "agent:main:main", forkedFromParent: true, + createdVia: "talk", + createdActor: { type: "agent", id: "agent:main:main" }, + createdAt: forkedEntry.createdAt, updatedAt: forkedEntry.updatedAt, }); expectPositiveTimestamp(forkedEntry.updatedAt); @@ -668,6 +681,9 @@ describe("realtime voice agent consult runtime", () => { expect(voiceEntry).toStrictEqual({ sessionId: voiceEntry.sessionId, spawnedBy: "agent:main:discord:channel:123", + createdVia: "talk", + createdActor: { type: "agent", id: "agent:main:discord:channel:123" }, + createdAt: voiceEntry.createdAt, deliveryContext: { channel: "discord", to: "channel:123", diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index fb6f4ef8ba9f..f8343fec9bdd 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -4,6 +4,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { RunEmbeddedAgentParams } from "../agents/embedded-agent-runner/run/params.js"; import { forkSessionEntryFromParent } from "../auto-reply/reply/session-fork.js"; import { resolveSessionWorkStartError } from "../config/sessions/lifecycle.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; import { parseSessionThreadInfoFast } from "../config/sessions/thread-info.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -165,6 +166,10 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { const now = Date.now(); const deliveryFields = resolveDeliverySessionFields(params.deliveryContext); const requesterSessionKey = params.spawnedBy?.trim(); + const creationStamp = buildSessionCreationStamp({ + via: "talk", + ...(requesterSessionKey ? { actor: { type: "agent" as const, id: requesterSessionKey } } : {}), + }); const requesterAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId; const shouldFork = params.contextMode === "fork" && @@ -181,6 +186,7 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { config: params.cfg, sessionKey: params.sessionKey, fallbackEntry: { + ...creationStamp, sessionId: "", updatedAt: now, }, @@ -206,6 +212,7 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: { storePath: params.storePath, sessionKey: params.sessionKey, fallbackEntry: { + ...creationStamp, sessionId: "", updatedAt: now, }, diff --git a/src/talk/client-voice-session.test.ts b/src/talk/client-voice-session.test.ts index 7222779a5aeb..2d3d2137c599 100644 --- a/src/talk/client-voice-session.test.ts +++ b/src/talk/client-voice-session.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; +import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/session-accessor.js"; import { emitTrustedDiagnosticEvent, waitForDiagnosticEventsDrained, @@ -14,6 +14,7 @@ import { closeClientVoiceSession, closeStaleClientVoiceSessions, createOrResumeClientVoiceSession, + ensureClientVoiceAgentSessionEntry, isClientVoiceSessionConfirmable, registerClientVoiceConsultRun, resolveClientVoiceRunBinding, @@ -150,6 +151,35 @@ describe("client voice session", () => { ).toThrow("already closed"); }); + it("stamps the agent session row when Talk creates it", async () => { + const sessionKey = "agent:main:talk:new"; + await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey }); + + expect(loadSessionEntry({ agentId: "main", sessionKey })).toMatchObject({ + createdVia: "talk", + createdActor: { type: "human" }, + createdAt: expect.any(Number), + }); + + await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey }); + expect(loadSessionEntry({ agentId: "main", sessionKey })?.createdVia).toBe("talk"); + }); + + it("repairs an incomplete existing row without claiming its creation actor", async () => { + const sessionKey = "agent:main:talk:incomplete"; + await replaceSessionEntry( + { agentId: "main", sessionKey }, + { sessionId: "", updatedAt: 1, createdVia: "internal", createdAt: 1 }, + ); + + await ensureClientVoiceAgentSessionEntry({ agentId: "main", sessionKey }); + + const repaired = loadSessionEntry({ agentId: "main", sessionKey }); + expect(repaired?.sessionId).toBeTruthy(); + expect(repaired).toMatchObject({ createdVia: "internal", createdAt: 1 }); + expect(repaired?.createdActor).toBeUndefined(); + }); + it("marks confirmability by declared capability, relay origin, or observed transcript", () => { const capable = createOrResumeClientVoiceSession({ agentId: "main", diff --git a/src/talk/client-voice-session.ts b/src/talk/client-voice-session.ts index 049a9146b061..1a0e350ef52f 100644 --- a/src/talk/client-voice-session.ts +++ b/src/talk/client-voice-session.ts @@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto"; import { appendTranscriptMessage, - loadSessionEntry, loadSessionEntryReadOnly, - upsertSessionEntry, + patchSessionEntry, } from "../config/sessions/session-accessor.js"; +import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js"; +import { mergeSessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { onTrustedInternalDiagnosticEvent, @@ -220,11 +221,19 @@ export async function ensureClientVoiceAgentSessionEntry(params: { agentId: string; sessionKey: string; }): Promise { - const existing = loadSessionEntry(params); - if (existing?.sessionId) { - return; - } - const created = await upsertSessionEntry(params, {}); + const created = await patchSessionEntry( + params, + (_entry, context) => { + if (context.existingEntry?.sessionId) { + return null; + } + if (context.existingEntry) { + return { sessionId: randomUUID() }; + } + return buildSessionCreationStamp({ via: "talk", actor: { type: "human" } }); + }, + { fallbackEntry: mergeSessionEntry(undefined, {}) }, + ); if (!created?.sessionId) { throw new Error(`agent session could not be initialized (${params.sessionKey})`); } diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index e41489a0434d..3af403a4fa29 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -787,6 +787,7 @@ export class EmbeddedTuiBackend implements TuiBackend { const result = await createGatewaySession({ cfg, ...opts, + creation: { via: "operator", actor: { type: "human" } }, emitCommandHooks: Boolean(opts.parentSessionKey), commandSource: "tui:embedded", loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg), diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 40e4d9123286..3bb7cfedb5fa 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -9,6 +9,7 @@ import { symlinkSync, writeFileSync, } from "node:fs"; +import { resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { parse } from "yaml"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; @@ -43,6 +44,11 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml"; const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml"; const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml"; const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml"; +const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd(); +const RELEASE_MAINTAINER_SKILL = resolve( + REPO_ROOT, + ".agents/skills/release-openclaw-maintainer/SKILL.md", +); const QA_LIVE_TRANSPORTS_WORKFLOW = ".github/workflows/qa-live-transports-convex.yml"; const UPDATE_MIGRATION_WORKFLOW = ".github/workflows/update-migration.yml"; const CI_CHECK_TESTBOX_WORKFLOW = ".github/workflows/ci-check-testbox.yml"; @@ -3547,10 +3553,7 @@ describe("package artifact reuse", () => { it("keeps release publish creation compatible with gh api and prerelease notes", () => { const workflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8"); const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8"); - const maintainerSkill = readFileSync( - ".agents/skills/release-openclaw-maintainer/SKILL.md", - "utf8", - ); + const maintainerSkill = readFileSync(RELEASE_MAINTAINER_SKILL, "utf8"); const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const resolveJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "resolve_release_target"); const publishJob = workflowJob(RELEASE_PUBLISH_WORKFLOW, "publish"); @@ -3760,10 +3763,7 @@ describe("package artifact reuse", () => { const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8"); const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8"); const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8"); - const releaseSkill = readFileSync( - ".agents/skills/release-openclaw-maintainer/SKILL.md", - "utf8", - ); + const releaseSkill = readFileSync(RELEASE_MAINTAINER_SKILL, "utf8"); expect(releaseWorkflow).toContain( "Stable OpenClaw publish requires an explicit windows_node_tag.", diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index e71aaedcf9f7..1f8b9c133781 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -492,8 +492,8 @@ type SessionCompactionCheckpointPreview = Pick< export type GatewaySessionRow = { key: string; - createdBy?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity; spawnedBy?: string; + controlOwnerSessionKey?: string; /** Collector swarm group that owns this child session, when applicable. */ swarmGroupId?: string; parentSessionKey?: string; @@ -504,8 +504,17 @@ export type GatewaySessionRow = { spawnedWorkspaceDir?: string; spawnedCwd?: string; execCwd?: string; + forkedFromParent?: boolean; + spawnDepth?: number; + subagentRole?: "orchestrator" | "leaf"; + subagentControlScope?: "children" | "none"; + createdVia?: "operator" | "spawn" | "channel" | "cron" | "talk" | "run" | "plugin" | "internal"; + createdActor?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatedActor; + createdAt?: number; + forkSource?: { sessionKey: string; sessionId: string; entryId?: string }; + previousSessionId?: string; placement?: import("../../../packages/gateway-protocol/src/index.js").SessionPlacement; - kind: "cron" | "direct" | "group" | "global" | "unknown"; + kind: "direct" | "group" | "global" | "unknown"; label?: string; /** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */ category?: string; diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index 43607e1f852a..be894ad9d6fa 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -171,7 +171,7 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) { const visibleHosts: SessionCatalogHost[] = []; for (const host of hosts) { const sessions = host.sessions.filter( - (session) => !params.creatorId || session.createdBy?.id === params.creatorId, + (session) => !params.creatorId || session.createdActor?.id === params.creatorId, ); if (sessions.length > 0) { visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions }); diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 6ca6f3dc71fe..4b14e98d8c4e 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -157,7 +157,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi } return { key: row.key, - createdBy: row.createdBy, + createdActor: row.createdActor, // The sidebar's zone structure already says what forked from what; // a "Subagent:" prefix on named threads is noise (other surfaces keep it). label: resolveSessionDisplayName(row.key, row, { @@ -612,7 +612,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi mainSessionKeys.has(parentKey) && !scopedRootKeys.has(row.key) && !row.archived && - (this.sessionsShowCron || (row.kind !== "cron" && !isCronSessionKey(row.key))) + (this.sessionsShowCron || !isCronSessionKey(row.key)) ); }, ); diff --git a/ui/src/components/app-sidebar-session-ownership.ts b/ui/src/components/app-sidebar-session-ownership.ts index 541de8d94dc4..a6a9d02ec7fa 100644 --- a/ui/src/components/app-sidebar-session-ownership.ts +++ b/ui/src/components/app-sidebar-session-ownership.ts @@ -1,13 +1,17 @@ import { state } from "lit/decorators.js"; import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts"; import type { SidebarRecentSession } from "./app-sidebar-session-types.ts"; -import { listSessionCreators, type SessionCreatedBy } from "./session-owner-chip.ts"; +import { + listSessionCreators, + type SessionCreatedActor, + type SessionCreatorOption, +} from "./session-owner-chip.ts"; /** Creator attribution, solo dormancy, and filtering shared by sidebar session surfaces. */ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessionProjectionElement { @state() protected sessionCreatorFilterId: string | null = null; - protected sessionCreatorOptions: readonly SessionCreatedBy[] = []; + protected sessionCreatorOptions: readonly SessionCreatorOption[] = []; protected activeSessionCreatorId: string | null = null; protected sessionCreatorFilterActive = false; sessionOwnershipVisible = false; @@ -28,8 +32,8 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio protected applySessionCreatorFilter( projected: readonly SidebarRecentSession[], - creatorRows: readonly { createdBy?: SessionCreatedBy }[] = [], - creatorFacet?: readonly SessionCreatedBy[], + creatorRows: readonly { createdActor?: SessionCreatedActor }[] = [], + creatorFacet?: readonly { id: string; label?: string }[], ): SidebarRecentSession[] { const flattened: SidebarRecentSession[] = []; const pending = [...projected]; @@ -42,7 +46,9 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio } const completeFacet = creatorFacet ?? this.sessionsResult?.creators; this.sessionCreatorOptions = listSessionCreators([ - ...(completeFacet ?? []).map((createdBy) => ({ createdBy })), + ...(completeFacet ?? []).map((creator) => ({ + createdActor: { type: "human" as const, ...creator }, + })), ...flattened, ...creatorRows, ]); @@ -61,7 +67,7 @@ export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessio const filtered: SidebarRecentSession[] = []; for (const row of treeRows) { const children = filterTree(row.children); - if (row.createdBy?.id === creatorId) { + if (row.createdActor?.id === creatorId) { filtered.push({ ...row, children }); } else { for (const child of children) { diff --git a/ui/src/components/app-sidebar-session-row-render.ts b/ui/src/components/app-sidebar-session-row-render.ts index 8097496fe47c..730a91620c8c 100644 --- a/ui/src/components/app-sidebar-session-row-render.ts +++ b/ui/src/components/app-sidebar-session-row-render.ts @@ -207,7 +207,7 @@ export function renderRecentSession(params: { @click=${(event: MouseEvent) => host.handleSessionRowClick(event, session)} > ${leadingIndicator}${renderSessionOwnerChip( - host.sessionOwnershipVisible ? session.createdBy : undefined, + host.sessionOwnershipVisible ? session.createdActor : undefined, "row", )} diff --git a/ui/src/components/app-sidebar-session-types.ts b/ui/src/components/app-sidebar-session-types.ts index 3cc5ce7b0dfc..0a862f737a04 100644 --- a/ui/src/components/app-sidebar-session-types.ts +++ b/ui/src/components/app-sidebar-session-types.ts @@ -1,6 +1,6 @@ import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway-protocol/src/schema/sessions-catalog.js"; import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; -import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js"; +import type { SessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { SessionRunStatus } from "../api/types.ts"; @@ -51,7 +51,7 @@ export function sidebarSessionAttentionPriority(attention: SidebarSessionAttenti export type SidebarRecentSession = { key: string; - createdBy?: SessionCreatorIdentity; + createdActor?: SessionCreatedActor; label: string; meta: string; /** Compact repo/branch/node line for work sessions. */ diff --git a/ui/src/components/session-owner-chip.ts b/ui/src/components/session-owner-chip.ts index 69e6eea0fe5b..01add40c6406 100644 --- a/ui/src/components/session-owner-chip.ts +++ b/ui/src/components/session-owner-chip.ts @@ -1,24 +1,29 @@ import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; -import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js"; +import type { SessionCreatedActor as ProtocolSessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import { t } from "../i18n/index.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; -export type SessionCreatedBy = SessionCreatorIdentity; +export type SessionCreatedActor = ProtocolSessionCreatedActor; +export type SessionCreatorOption = SessionCreatedActor & { id: string }; export function listSessionCreators( - sessions: readonly { createdBy?: SessionCreatedBy }[], -): SessionCreatedBy[] { - const creators = new Map(); + sessions: readonly { createdActor?: SessionCreatedActor }[], +): SessionCreatorOption[] { + const creators = new Map(); for (const session of sessions) { - const id = session.createdBy?.id.trim(); + const id = session.createdActor?.id?.trim(); if (!id) { continue; } - const label = session.createdBy?.label?.trim(); + const label = session.createdActor?.label?.trim(); const existing = creators.get(id); if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) { - creators.set(id, { id, ...(label ? { label } : {}) }); + creators.set(id, { + type: session.createdActor?.type ?? "human", + id, + ...(label ? { label } : {}), + }); } } return [...creators.values()].toSorted((a, b) => { @@ -28,19 +33,19 @@ export function listSessionCreators( } export function renderSessionOwnerChip( - createdBy: SessionCreatedBy | null | undefined, + createdActor: SessionCreatedActor | null | undefined, size: "row" | "header", ) { - return createdBy + return createdActor?.id ? html`` : nothing; } export function renderSessionCreatorFilter(params: { - creators: readonly SessionCreatedBy[]; + creators: readonly SessionCreatorOption[]; selectedId: string | null; onChange: (creatorId: string | null) => void; }) { @@ -63,8 +68,8 @@ export function renderSessionCreatorFilter(params: { `; } -function ownerInitials(createdBy: SessionCreatedBy): string { - const source = createdBy.label?.trim() || createdBy.id.trim(); +function ownerInitials(createdActor: SessionCreatedActor): string { + const source = createdActor.label?.trim() || createdActor.id?.trim() || ""; if (!source) { return ""; } @@ -92,24 +97,24 @@ function ownerHue(id: string): number { * has 2+ distinct creator identities (solo mode shows no attribution chrome). */ class SessionOwnerChip extends OpenClawLightDomElement { - @property({ attribute: false }) createdBy: SessionCreatedBy | null = null; + @property({ attribute: false }) createdActor: SessionCreatedActor | null = null; @property({ type: String }) size: "row" | "header" = "row"; override render() { - const createdBy = this.createdBy; - if (!createdBy) { + const createdActor = this.createdActor; + if (!createdActor?.id) { return nothing; } - const initials = ownerInitials(createdBy); + const initials = ownerInitials(createdActor); if (!initials) { return nothing; } - const title = createdBy.label || createdBy.id; + const title = createdActor.label || createdActor.id; const accessibleLabel = t("sessionsView.createdBy", { name: title }); return html` { it("hides cron sessions unless showCron opts in", () => { const rows: GatewaySessionRow[] = [ { key: "agent:main:chat", kind: "direct", updatedAt: 300 }, - { key: "agent:main:cron:job", kind: "cron", updatedAt: 200 }, + { key: "agent:main:cron:job", kind: "cron" as never, updatedAt: 200 }, ]; const hidden = resolveSessionNavigation({ diff --git a/ui/src/lib/sessions/navigation.ts b/ui/src/lib/sessions/navigation.ts index cd534270547c..12afb74d8a1f 100644 --- a/ui/src/lib/sessions/navigation.ts +++ b/ui/src/lib/sessions/navigation.ts @@ -256,7 +256,8 @@ export function filterVisibleSessionRows( sessionMatchesArchivedFilter(row, options.archivedFilter) && row.kind !== "global" && row.kind !== "unknown" && - (options.showCron === true || (row.kind !== "cron" && !isCronSessionKey(row.key))) && + (options.showCron === true || + ((row.kind as string) !== "cron" && !isCronSessionKey(row.key))) && !isSubagentSessionKey(row.key) && !row.spawnedBy && (!options.filterByAgent || diff --git a/ui/src/lib/sessions/reconcile.test.ts b/ui/src/lib/sessions/reconcile.test.ts index 0f778588746e..2a1b45a2b671 100644 --- a/ui/src/lib/sessions/reconcile.test.ts +++ b/ui/src/lib/sessions/reconcile.test.ts @@ -50,7 +50,7 @@ test("sessions.changed invalidates the complete creator facet until canonical re key, kind: "global", updatedAt: 1, - createdBy: { id: "profile-ada", label: "Ada" }, + createdActor: { type: "human", id: "profile-ada", label: "Ada" }, }, ]); result.creators = [{ id: "profile-ada", label: "Ada" }]; @@ -59,27 +59,27 @@ test("sessions.changed invalidates the complete creator facet until canonical re sessionKey: key, reason: "reset", updatedAt: 2, - createdBy: { id: "profile-bob", label: "Bob" }, + createdActor: { type: "human", id: "profile-bob", label: "Bob" }, }); - expect(reconciled.result?.sessions[0]?.createdBy?.id).toBe("profile-bob"); + expect(reconciled.result?.sessions[0]?.createdActor?.id).toBe("profile-bob"); expect(reconciled.result?.creators).toBeUndefined(); }); test("sessions.changed preserves the creator facet when ownership is unchanged", () => { const key = "agent:main:main"; - const createdBy = { id: "profile-ada", label: "Ada" }; - const result = buildResult([{ key, kind: "global", updatedAt: 1, createdBy }]); - result.creators = [createdBy]; + const createdActor = { type: "human" as const, id: "profile-ada", label: "Ada" }; + const result = buildResult([{ key, kind: "global", updatedAt: 1, createdActor }]); + result.creators = [{ id: createdActor.id, label: createdActor.label }]; const reconciled = reconcileSessionChanged(result, { sessionKey: key, reason: "send", updatedAt: 2, - createdBy, + createdActor, }); - expect(reconciled.result?.creators).toEqual([createdBy]); + expect(reconciled.result?.creators).toEqual([{ id: createdActor.id, label: createdActor.label }]); }); describe("reconcileSessionChanged", () => { diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts index 9ae27be3db83..9fd432b9a0dd 100644 --- a/ui/src/lib/sessions/reconcile.ts +++ b/ui/src/lib/sessions/reconcile.ts @@ -378,8 +378,8 @@ export function reconcileSessionChanged( if (rowFields.displayName === null) { delete row.displayName; } - if (rowFields.createdBy === null) { - delete row.createdBy; + if (rowFields.createdActor === null) { + delete row.createdActor; } if (rowFields.thinkingLevel === null) { delete row.thinkingLevel; @@ -400,9 +400,10 @@ export function reconcileSessionChanged( const eventTs = typeof event.ts === "number" && Number.isFinite(event.ts) ? event.ts : null; const timestamped = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) }; const ownershipChanged = - Object.hasOwn(rowFields, "createdBy") && - (existing?.createdBy?.id !== row.createdBy?.id || - existing?.createdBy?.label !== row.createdBy?.label); + Object.hasOwn(rowFields, "createdActor") && + (existing?.createdActor?.type !== row.createdActor?.type || + existing?.createdActor?.id !== row.createdActor?.id || + existing?.createdActor?.label !== row.createdActor?.label); // The facet covers unloaded pages, so an ownership event invalidates it until // the session capability's canonical list refresh supplies a complete replacement. const reconciledResult = ownershipChanged ? { ...timestamped, creators: undefined } : timestamped; diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index 9919306cb991..3840ac720838 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -112,13 +112,13 @@ describe("chat pane header", () => { it("renders the permanent owner chip only when attribution chrome is enabled", () => { const shown = mount({ showOwnerChip: true, - session: row({ createdBy: { id: "profile-ada", label: "Ada" } }), + session: row({ createdActor: { type: "human", id: "profile-ada", label: "Ada" } }), }); expect(shown.container.querySelector("openclaw-session-owner-chip")).not.toBeNull(); const dormant = mount({ showOwnerChip: false, - session: row({ createdBy: { id: "profile-ada", label: "Ada" } }), + session: row({ createdActor: { type: "human", id: "profile-ada", label: "Ada" } }), }); expect(dormant.container.querySelector("openclaw-session-owner-chip")).toBeNull(); }); diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index cf869b6ad5e2..5b6041a4a72a 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -195,7 +195,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ${props.title} `} ${renderSessionOwnerChip( - props.showOwnerChip ? props.session?.createdBy : undefined, + props.showOwnerChip ? props.session?.createdActor : undefined, "header", )} ${!props.catalog && props.workspaceLabel diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts index c3000383eca5..0624c84b643f 100644 --- a/ui/src/pages/sessions/view.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -714,7 +714,7 @@ describe("sessions view", () => { buildProps( buildResult({ key: "agent:main:cron:daily-digest", - kind: "cron", + kind: "direct", updatedAt: Date.now(), }), ), @@ -1385,7 +1385,7 @@ describe("sessions view", () => { }, { key: "agent:main:idle", - kind: "cron", + kind: "direct", updatedAt: 1, unread: true, totalTokens: 300, @@ -1534,8 +1534,9 @@ describe("sessions view", () => { buildProps( buildMultiResult([ { - key: "agent:main:live", - kind: "cron", + // Cron display kind derives from the key shape, never the wire kind. + key: "agent:main:cron:live", + kind: "direct", updatedAt: 2, hasActiveRun: true, status: "running", diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index b2ebfb1c26c3..ff2ee532567d 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -34,6 +34,7 @@ import { parseSessionKeyParts, } from "../../lib/format.ts"; import { formatSessionTokens } from "../../lib/presenter.ts"; +import { isCronSessionKey } from "../../lib/session-display.ts"; import { formatGoalDetail, formatGoalSummary } from "../../lib/session-goal.ts"; import { sessionModelMatchesDefaults } from "../../lib/session-model-defaults.ts"; import { isSessionRunActive } from "../../lib/session-run-state.ts"; @@ -288,14 +289,21 @@ const SESSION_KIND_ICONS = { group: icons.users, global: icons.globe, unknown: icons.circle, -} satisfies Record; +} satisfies Record; + +// The server row kind never carries "cron" — cron is a key-shape fact, so the +// display kind derives it from the key for the avatar, badge class, and label. +function resolveSessionDisplayKind(row: GatewaySessionRow): GatewaySessionRow["kind"] | "cron" { + return isCronSessionKey(row.key) ? "cron" : row.kind; +} // Kind glyph anchors each row; the dot mirrors isSessionRunActive so run // state also reads at the identity anchor while scanning the key column. function renderSessionAvatar(row: GatewaySessionRow) { + const displayKind = resolveSessionDisplayKind(row); return html` -