feat(sdk): add session initiation mode and lazy session persistence (#12807)

* feat(sdk): add session initiation mode and lazy session persistence

- Introduce top-level `StartSessionInput.mode` (`user`, `automation`, `subagent`, `team`) alongside `source`, so persisted history records both the client surface and how the session began; missing mode defaults to `user`.
- Make root-session persistence lazy: starting a runtime allocates the session ID in memory without creating a database row, manifest, or messages artifact. The first accepted user turn persists that same ID, so closing a runtime before any user turn leaves no empty history entry, and persistence never allocates a replacement ID for unknown sessions.
- Require automation runtime adapters to explicitly persist `mode: "automation"` for every run.
- Document the provenance model in `sdk/ARCHITECTURE.md`, update the VS Code session factory comment, and add tests for the automation runtime handlers.

* fix(sdk): persist automation trigger source as session provenance

The runtime adapters stopped writing the cron request source into the
session row when source became the client surface, which silently
dropped the spec-defined trigger label. Record it as
sessionHistoryOrigin.trigger instead, surface it in the messages-file
origin, and sort the new history-origin import in HubRuntimeHost.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
This commit is contained in:
Bee
2026-08-04 13:29:36 -07:00
committed by GitHub
parent 400ba47387
commit accd7e5809
22 changed files with 998 additions and 169 deletions
+6 -6
View File
@@ -1025,12 +1025,12 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
/**
* Build the StartSessionInput for a new task.
*
* IMPORTANT: We pass `interactive: true` but NO `prompt`. This creates the
* session and returns immediately — the runtime host only executes a turn when
* a prompt is sent. The caller should then call `core.send({ sessionId, prompt })`
* to run the first turn. This cleanly separates session creation from
* inference, preventing the gRPC handler from blocking until the first
* agent turn completes.
* IMPORTANT: We pass `interactive: true` but NO `prompt`. This allocates the
* session in memory and returns immediately; no persisted session row or
* artifacts are created yet. The caller then uses
* `core.send({ sessionId, prompt })` for the first user turn, which persists
* that same session ID before inference. This keeps initialization responsive
* without leaving empty history entries when the user never sends a message.
*/
export function buildStartSessionInput(config: CoreSessionConfig, input: SessionConfigInput): ClineCoreStartInput {
return {
+20 -3
View File
@@ -149,6 +149,21 @@ event payload and `source` field.
8. Hub client adapters exported from `@cline/core/hub` (`NodeHubClient`, `HubSessionClient`, `HubUIClient`, `connectToHub`) translate command/reply and event streams into host-facing APIs.
9. Hub `session.get` records include both canonical root-session usage and explicit aggregate usage from the hub-owned `RuntimeHost`, so attached clients can intentionally render either root-only or root-plus-teammate costs without replaying event streams.
Session history provenance keeps the client surface and initiation mode separate.
`StartSessionInput.source` identifies the client (`vscode`, `desktop`, `cli`,
`core`, and so on), while top-level `StartSessionInput.mode` identifies how the
session began (`user`, `automation`, `subagent`, or `team`). The persisted messages
envelope records both values, along with client version and child-session
lineage. Missing initiation mode defaults to `user`; automation runtime adapters
must pass `mode: "automation"` explicitly.
Root-session persistence is lazy. Starting a runtime allocates its session ID
and keeps configuration or seeded history in memory, but does not create a
database row, manifest, or messages artifact. The first accepted user turn
persists that same ID and its artifacts. Closing a runtime before a user turn
therefore leaves no empty history entry, and persistence code never allocates a
replacement ID for an unknown session.
Workspace bootstrap is owned by the runtime that executes the session. Hub
clients preserve an omitted `cwd` and `workspaceRoot` across the transport so
the hub-side execution host can place the session in the shared chat
@@ -451,9 +466,11 @@ orchestrator used by core and hub layers.
renews the run claim while execution is active, writes a markdown report
per run, and transactionally updates status. File specs can constrain
tool availability, config extension loading (`rules`, `skills`,
`plugins`), session source, and a notes directory that is injected into
the system prompt. Event runs include the normalized trigger event context
in the prompt.
`plugins`), trigger source, and a notes directory that is injected into
the system prompt. The automation runtime adapters explicitly persist
`mode: "automation"` for every run and record the spec-defined trigger
source as `sessionHistoryOrigin.trigger` in session metadata. Event runs
include the normalized trigger event context in the prompt.
8. **Reports** (`cron/reports/cron-report-writer.ts`): writes
`.cline/cron/reports/<run-id>.md` with run frontmatter plus
`## Summary`, `## Usage`, `## Tool Calls`, and, for event runs,
@@ -0,0 +1,48 @@
import type { ChatStartSessionRequest } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import type { RuntimeHost } from "../runtime/host/runtime-host";
import { createClineCoreAutomationRuntimeHandlers } from "./automation";
function createRequest(): ChatStartSessionRequest {
return {
workspaceRoot: "/workspace",
provider: "anthropic",
model: "claude-sonnet-4-6",
source: "custom-trigger",
systemPrompt: "system",
mode: "act",
enableTools: true,
};
}
describe("createClineCoreAutomationRuntimeHandlers", () => {
it("starts every scheduled run with automation provenance", async () => {
const startSession = vi.fn().mockResolvedValue({
sessionId: "scheduled-session",
manifestPath: "/tmp/scheduled-session.manifest.json",
messagesPath: "/tmp/scheduled-session.messages.json",
});
const handlers = createClineCoreAutomationRuntimeHandlers({
host: { startSession } as unknown as RuntimeHost,
getExtensionContext: () => ({
client: { name: "VSCode Extension", version: "3.99.0" },
}),
});
await handlers.startSession(createRequest());
expect(startSession).toHaveBeenCalledWith(
expect.objectContaining({
source: "vscode",
mode: "automation",
sessionMetadata: {
sessionHistoryOrigin: {
mode: "automation",
trigger: "custom-trigger",
},
},
config: expect.objectContaining({ mode: "act" }),
}),
);
});
});
+17 -2
View File
@@ -14,6 +14,10 @@ import type { CronService } from "../cron/service/cron-service";
import type { HubScheduleRuntimeHandlers } from "../cron/service/schedule-service";
import type { RuntimeHost } from "../runtime/host/runtime-host";
import { normalizeProviderId } from "../services/llms/provider-settings";
import {
resolveClientSessionSource,
withSessionHistoryOriginMetadata,
} from "../session/history-origin";
import { SessionSource } from "../types/common";
import type {
ClineAutomationEventIngressResult,
@@ -101,8 +105,19 @@ export function createClineCoreAutomationRuntimeHandlers(
return {
async startSession(request) {
const cwd = (request.cwd?.trim() || request.workspaceRoot).trim();
const extensionContext = input.getExtensionContext();
const started = await host.startSession({
source: request.source?.trim() || SessionSource.CLI,
source:
resolveClientSessionSource(extensionContext?.client) ??
SessionSource.CORE,
mode: "automation",
// Record the spec-defined trigger source (e.g. "hub-schedule"
// or a custom label from spec frontmatter) as provenance; the
// top-level `source` is reserved for the client surface.
sessionMetadata: withSessionHistoryOriginMetadata(undefined, {
mode: "automation",
trigger: request.source,
}),
interactive: false,
config: {
providerId: normalizeProviderId(request.provider),
@@ -126,7 +141,7 @@ export function createClineCoreAutomationRuntimeHandlers(
},
},
localRuntime: {
extensionContext: input.getExtensionContext(),
extensionContext,
configExtensions: request.configExtensions,
},
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { normalizeClineCoreStartInput } from "./start-input";
import type { ClineCoreStartInput } from "./types";
function createInput(
overrides: Partial<ClineCoreStartInput> = {},
): ClineCoreStartInput {
return {
config: {
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
cwd: "/workspace",
systemPrompt: "",
enableTools: true,
enableSpawnAgent: true,
enableAgentTeams: true,
extensionContext: {
client: {
name: "VSCode Extension",
version: "3.99.0",
},
},
},
...overrides,
};
}
describe("normalizeClineCoreStartInput", () => {
it("captures the client surface, version, and default user mode", () => {
const normalized = normalizeClineCoreStartInput(createInput());
expect(normalized.source).toBe("vscode");
expect(normalized.sessionMetadata).toMatchObject({
sessionHistoryOrigin: {
mode: "user",
version: "3.99.0",
},
});
});
it("keeps an explicit session mode separate from the client", () => {
const normalized = normalizeClineCoreStartInput(
createInput({ mode: "automation" }),
);
expect(normalized.source).toBe("vscode");
expect(normalized.sessionMetadata).toMatchObject({
sessionHistoryOrigin: {
mode: "automation",
version: "3.99.0",
},
});
});
});
@@ -6,6 +6,11 @@ import type {
StartSessionInput,
} from "../runtime/host/runtime-host";
import { splitCoreSessionConfig } from "../runtime/host/runtime-host";
import {
resolveClientSessionSource,
withSessionHistoryOriginMetadata,
} from "../session/history-origin";
import { SessionSource } from "../types/common";
import type { ClineCoreStartConfig } from "../types/config";
import type { ClineCoreStartInput } from "./types";
@@ -45,9 +50,9 @@ export function normalizeClineCoreStartInput(
split.localRuntime,
input.localRuntime,
);
const extensionContext = options.withExtensionContext?.(
localRuntime?.extensionContext,
);
const extensionContext = options.withExtensionContext
? options.withExtensionContext(localRuntime?.extensionContext)
: localRuntime?.extensionContext;
if (extensionContext) {
localRuntime = {
...(localRuntime ?? {}),
@@ -57,6 +62,14 @@ export function normalizeClineCoreStartInput(
return {
...input,
...split,
source:
input.source ??
resolveClientSessionSource(extensionContext?.client) ??
SessionSource.CORE,
sessionMetadata: withSessionHistoryOriginMetadata(input.sessionMetadata, {
mode: input.mode,
version: extensionContext?.client?.version,
}),
...(localRuntime ? { localRuntime } : {}),
...(capabilities ? { capabilities } : {}),
};
@@ -12,6 +12,7 @@ import type {
import type { VerifySubmitExecutor } from "../../extensions/tools";
import { LocalRuntimeHost } from "../../runtime/host/local-runtime-host";
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
import { withSessionHistoryOriginMetadata } from "../../session/history-origin";
import { CoreSessionService } from "../../session/services/session-service";
import { SessionSource } from "../../types/common";
@@ -89,7 +90,15 @@ export function createLocalHubScheduleRuntimeHandlers(
async startSession(request) {
const cwd = (request.cwd?.trim() || request.workspaceRoot).trim();
const started = await sessionHost.startSession({
source: request.source?.trim() || SessionSource.CLI,
source: SessionSource.CORE,
mode: "automation",
// Record the spec-defined trigger source (e.g. "hub-schedule"
// or a custom label from spec frontmatter) as provenance; the
// top-level `source` is reserved for the client surface.
sessionMetadata: withSessionHistoryOriginMetadata(undefined, {
mode: "automation",
trigger: request.source,
}),
interactive: false,
config: {
providerId: normalizeProviderId(request.provider),
@@ -146,6 +146,10 @@ describe("HubRuntimeHost", () => {
source: SessionSource.CLI,
prompt: "Hey",
interactive: false,
sessionHistoryOrigin: {
mode: "user",
version: "3.0.38",
},
}),
runtimeOptions: {},
toolPolicies: undefined,
@@ -44,6 +44,7 @@ import type {
} from "../../runtime/host/runtime-host";
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
import { RuntimeHostEventBus } from "../../runtime/host/runtime-host-support";
import { withSessionHistoryOriginMetadata } from "../../session/history-origin";
import {
parseSessionCompactionState,
type SessionCompactionState,
@@ -161,6 +162,32 @@ function buildCommandSessionConfig(
return sessionConfig;
}
function buildSessionHistoryMetadata(
input: StartSessionInput,
): Record<string, unknown> {
return withSessionHistoryOriginMetadata(input.sessionMetadata, {
mode: input.mode,
version: input.localRuntime?.extensionContext?.client?.version,
});
}
function buildCommandSessionMetadata(
input: StartSessionInput,
): Record<string, unknown> {
return {
...buildSessionHistoryMetadata(input),
source: input.source ?? SessionSource.CORE,
provider: input.config.providerId,
model: input.config.modelId,
enableTools: input.config.enableTools,
enableSpawn: input.config.enableSpawnAgent,
enableTeams: input.config.enableAgentTeams,
teamName: input.config.teamName,
prompt: input.prompt,
interactive: input.interactive === true,
};
}
function parseToolContext(value: unknown): AgentToolContext {
const payload =
value && typeof value === "object" && !Array.isArray(value)
@@ -691,10 +718,7 @@ function buildManifest(
enable_spawn: input.config.enableSpawnAgent,
enable_teams: input.config.enableAgentTeams,
prompt: input.prompt?.trim() || undefined,
metadata:
input.sessionMetadata && Object.keys(input.sessionMetadata).length > 0
? input.sessionMetadata
: undefined,
metadata: buildSessionHistoryMetadata(input),
});
}
@@ -844,18 +868,7 @@ export class HubRuntimeHost implements RuntimeHost {
sessionConfig: toJsonRecord(
buildCommandSessionConfig(input, plannedSessionId),
),
metadata: {
...(input.sessionMetadata ?? {}),
source: input.source ?? SessionSource.CORE,
provider: input.config.providerId,
model: input.config.modelId,
enableTools: input.config.enableTools,
enableSpawn: input.config.enableSpawnAgent,
enableTeams: input.config.enableAgentTeams,
teamName: input.config.teamName,
prompt: input.prompt,
interactive: input.interactive === true,
},
metadata: buildCommandSessionMetadata(input),
runtimeOptions: {
...(clientContributions.manifest.length > 0
? { clientContributions: clientContributions.manifest }
@@ -991,18 +1004,7 @@ export class HubRuntimeHost implements RuntimeHost {
startConfig.config.cwd,
cwd: startConfig.config.cwd ?? input.cwd,
sessionConfig: toJsonRecord(startSessionConfig),
metadata: {
...(startConfig.sessionMetadata ?? {}),
source: startConfig.source ?? SessionSource.CORE,
provider: startConfig.config.providerId,
model: startConfig.config.modelId,
enableTools: startConfig.config.enableTools,
enableSpawn: startConfig.config.enableSpawnAgent,
enableTeams: startConfig.config.enableAgentTeams,
teamName: startConfig.config.teamName,
prompt: startConfig.prompt,
interactive: startConfig.interactive === true,
},
metadata: buildCommandSessionMetadata(startConfig),
runtimeOptions: {
...(clientContributions.manifest.length > 0
? { clientContributions: clientContributions.manifest }
@@ -1,4 +1,5 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
@@ -217,9 +218,10 @@ describe("LocalRuntimeHost", () => {
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const sessionsDir = join(isolatedHomeDir, "sessions");
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: new FileSessionService(join(isolatedHomeDir, "sessions")),
sessionService: new FileSessionService(sessionsDir),
runtimeBuilder: runtimeBuilder as never,
createAgent: () => agent as never,
});
@@ -245,6 +247,7 @@ describe("LocalRuntimeHost", () => {
expect(chatWorkspace).toBe(resolveChatWorkspacePath());
expect(isChatWorkspacePath(chatWorkspace)).toBe(true);
expect(result.manifest.workspace_root).toBe(chatWorkspace);
expect(existsSync(join(sessionsDir, result.sessionId))).toBe(false);
expect(runtimeBuilder.build).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
@@ -1103,13 +1106,18 @@ describe("LocalRuntimeHost", () => {
expect(started.manifest.source).toBe("kanban");
});
it("persists initial messages for idle resumed sessions", async () => {
it("keeps seeded history in memory until the next user turn", async () => {
const sessionId = "sess-fork-copy";
const manifest = createManifest(sessionId);
const initialMessages: MessageWithMetadata[] = [
{ role: "user" as const, content: "build a thing" },
{ role: "assistant" as const, content: "done" },
];
const continuedMessages: MessageWithMetadata[] = [
...initialMessages,
{ role: "user" as const, content: "continue" },
{ role: "assistant" as const, content: "continued" },
];
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
@@ -1136,7 +1144,11 @@ describe("LocalRuntimeHost", () => {
};
const agent = {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(
createResult({
messages: continuedMessages,
}),
),
getMessages: vi.fn().mockReturnValue(initialMessages),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
@@ -1161,22 +1173,34 @@ describe("LocalRuntimeHost", () => {
);
expect(agent.run).not.toHaveBeenCalled();
expect(sessionService.createRootSessionWithArtifacts).toHaveBeenCalledTimes(
1,
expect(
sessionService.createRootSessionWithArtifacts,
).not.toHaveBeenCalled();
expect(sessionService.persistSessionMessages).not.toHaveBeenCalled();
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
await expect(manager.readLiveSessionMessages(sessionId)).resolves.toEqual(
initialMessages,
);
await manager.runTurn({ sessionId, prompt: "continue" });
expect(agent.continue).toHaveBeenCalledTimes(1);
expect(sessionService.createRootSessionWithArtifacts).toHaveBeenCalledWith(
expect.objectContaining({
sessionId,
prompt: expect.stringContaining("continue"),
}),
);
expect(sessionService.persistSessionMessages).toHaveBeenCalledWith(
sessionId,
initialMessages,
expect.arrayContaining(
continuedMessages.map((message) => expect.objectContaining(message)),
),
"You are a test agent",
);
expect(sessionService.updateSessionStatus).toHaveBeenCalledWith(
sessionId,
"completed",
0,
);
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
sessionId,
status: "completed",
status: "idle",
});
});
@@ -1718,6 +1742,82 @@ describe("LocalRuntimeHost", () => {
);
});
it("observes iteration-end persist failures instead of leaking an unhandled rejection", async () => {
const sessionId = "sess-iteration-end-persist";
const manifest = createManifest(sessionId);
const persistError = new Error("row missing");
const persistSessionMessages = vi
.fn()
.mockRejectedValueOnce(persistError)
.mockResolvedValue(undefined);
const logger: BasicLogger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-iteration-end.json",
messagesPath: "/tmp/messages-iteration-end.json",
manifest,
}),
persistSessionMessages,
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const runtimeBuilder = {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn(),
}),
};
let subscribedHandler: ((event: AgentRuntimeEvent) => void) | undefined;
const run = vi.fn(async () => {
subscribedHandler?.({ type: "iteration_end", iteration: 1 } as never);
// Let the fire-and-forget persist settle before the run returns.
await new Promise((resolve) => setImmediate(resolve));
return createResult();
});
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder,
createAgent: () =>
({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockImplementation((handler) => {
subscribedHandler = handler as (event: AgentRuntimeEvent) => void;
return () => {};
}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue([]),
messages: [],
}) as never,
});
const started = await manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId, logger }),
prompt: "hello",
interactive: false,
}),
);
expect(started.result?.finishReason).toBe("completed");
expect(logger.error).toHaveBeenCalledWith(
"Failed to persist session messages from agent event",
{ sessionId, error: persistError },
);
});
it("does not fail a completed run when shutdown cleanup throws", async () => {
const sessionId = "sess-cleanup-errors";
const manifest = createManifest(sessionId);
@@ -4384,6 +4484,71 @@ describe("LocalRuntimeHost", () => {
expect(runtimeShutdown).toHaveBeenCalledTimes(1);
});
it("does not mask the run error when the failure-path transcript flush also fails", async () => {
const sessionId = "sess-fail-persist";
const manifest = createManifest(sessionId);
const persistError = new Error("persist failed");
const persistSessionMessages = vi.fn().mockRejectedValue(persistError);
const logger: BasicLogger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-fail-persist.json",
messagesPath: "/tmp/messages-fail-persist.json",
manifest,
}),
persistSessionMessages,
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const runtimeBuilder = {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn(),
}),
};
const run = vi.fn().mockRejectedValue(new Error("run failed"));
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder,
createAgent: () =>
({
run,
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue([]),
messages: [],
}) as never,
});
await expect(
manager.startSession(
normalizeStartInput({
config: createConfig({ sessionId, logger }),
prompt: "hello",
interactive: false,
}),
),
).rejects.toThrow("run failed");
expect(persistSessionMessages).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
"Failed to persist session messages after turn error",
{ sessionId, error: persistError },
);
});
it("marks a single-run error result as failed", async () => {
const sessionId = "sess-error-result";
const manifest = createManifest(sessionId);
@@ -4495,7 +4660,7 @@ describe("LocalRuntimeHost", () => {
sessionService.createRootSessionWithArtifacts,
).not.toHaveBeenCalled();
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
expect(agentShutdown).not.toHaveBeenCalled();
expect(agentShutdown).toHaveBeenCalledWith("session_stop");
expect(runtimeShutdown).toHaveBeenCalledTimes(1);
});
@@ -4817,10 +4982,18 @@ describe("LocalRuntimeHost", () => {
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const continuedMessages: MessageWithMetadata[] = [
...initialMessages,
{ role: "user", content: "continue" },
{ role: "assistant", content: "continued" },
];
const run = vi.fn().mockResolvedValue(createResult());
const continueRun = vi
.fn()
.mockResolvedValue(createResult({ messages: continuedMessages }));
const createAgent = vi.fn().mockReturnValue({
run,
continue: vi.fn(),
continue: continueRun,
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
@@ -4859,6 +5032,11 @@ describe("LocalRuntimeHost", () => {
}),
);
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
await manager.runTurn({ sessionId, prompt: "continue" });
expect(continueRun).toHaveBeenCalledTimes(1);
expect(sessionService.persistSessionCompactionState).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({
@@ -4898,10 +5076,18 @@ describe("LocalRuntimeHost", () => {
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const continuedMessages: MessageWithMetadata[] = [
...initialMessages,
{ role: "user", content: "follow-up" },
{ role: "assistant", content: "continued" },
];
const run = vi.fn().mockResolvedValue(createResult());
const continueRun = vi
.fn()
.mockResolvedValue(createResult({ messages: continuedMessages }));
const createAgent = vi.fn().mockReturnValue({
run,
continue: vi.fn(),
continue: continueRun,
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
@@ -4942,9 +5128,13 @@ describe("LocalRuntimeHost", () => {
const prepareTurn = createAgent.mock.calls[0]?.[0]?.prepareTurn;
expect(prepareTurn).toBeDefined();
// The initial sidecar is persisted alongside the initial messages and
// projected into the next turn's working context (compacted messages
// plus the canonical tail), without re-compacting.
// The initial sidecar stays in memory until a user continues the session.
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
await manager.runTurn({ sessionId, prompt: "follow-up" });
// The first new user turn persists the sidecar, which remains available
// for projection without enabling automatic re-compaction.
expect(sessionService.persistSessionCompactionState).toHaveBeenCalledWith(
sessionId,
expect.objectContaining({
@@ -58,6 +58,7 @@ import {
readGitWorkspaceState,
withSessionGitMetadata,
} from "../../services/workspace/workspace-manifest";
import { withSessionHistoryOriginMetadata } from "../../session/history-origin";
import {
projectSessionCompactionState,
type SessionCompactionState,
@@ -292,12 +293,36 @@ export class LocalRuntimeHost implements RuntimeHost {
aggregateUsageBySession: this.aggregateUsageBySession,
emit: (event) => this.emit(event),
persistMessages: (sid, messages, systemPrompt) => {
// Fire-and-forget: an unobserved rejection here would surface as
// an unhandledRejection, which is fatal in the hub daemon.
void this.invoke<void>(
"persistSessionMessages",
sid,
messages,
systemPrompt,
);
).catch((error) => {
const session = this.sessions.get(sid);
const logger = session?.config.logger ?? this.defaultLogger;
logger?.error?.(
"Failed to persist session messages from agent event",
{
sessionId: sid,
error,
},
);
captureSdkError(session?.config.telemetry ?? this.defaultTelemetry, {
component: "core",
operation: "session.persist_messages_on_agent_event",
error,
severity: "warn",
handled: true,
context: {
sessionId: sid,
providerId: session?.config.providerId,
modelId: session?.config.modelId,
},
});
});
},
enqueuePendingPrompt: (sid, entry) =>
this.pendingPromptsController.enqueue(sid, entry),
@@ -530,12 +555,18 @@ export class LocalRuntimeHost implements RuntimeHost {
await this.persistSessionMetadata(sessionId, () => metadata);
},
});
const initialSessionMetadata = withSessionGitMetadata(
const initialSessionMetadata = withSessionHistoryOriginMetadata(
withSessionGitMetadata(
{
...(resumedArtifacts?.manifest.metadata ?? {}),
...(startInput.sessionMetadata ?? {}),
},
bootstrap.gitState,
),
{
...(resumedArtifacts?.manifest.metadata ?? {}),
...(startInput.sessionMetadata ?? {}),
mode: startInput.mode,
version: bootstrap.config.extensionContext?.client?.version,
},
bootstrap.gitState,
);
if (!resumedArtifacts) manifest.metadata = initialSessionMetadata;
const runtime = await this.runtimeBuilder.build(
@@ -835,27 +866,6 @@ export class LocalRuntimeHost implements RuntimeHost {
await this.refreshActiveSessionGitMetadata(active, bootstrap.gitState);
}
this.emitStatus(sessionId, "running");
if (initialMessages.length > 0 && !resumedArtifacts) {
await this.ensureSessionPersisted(active);
await this.invoke<void>(
"persistSessionMessages",
active.sessionId,
initialMessages,
active.config.systemPrompt,
);
if (active.compactionState) {
const result = await this.persistActiveSessionCompactionState(
active,
active.compactionState,
);
if (!result.updated) {
active.compactionState = undefined;
}
}
if (!startInput.prompt?.trim()) {
await this.updateStatus(active, "completed", 0);
}
}
let result: AgentResult | undefined;
try {
@@ -1757,12 +1767,21 @@ export class LocalRuntimeHost implements RuntimeHost {
modelId: session.config.modelId,
},
});
await this.invoke<void>(
"persistSessionMessages",
session.sessionId,
session.agent.getMessages(),
session.config.systemPrompt,
);
try {
await this.invoke<void>(
"persistSessionMessages",
session.sessionId,
session.agent.getMessages(),
session.config.systemPrompt,
);
} catch (persistError) {
// Never let a failed transcript flush mask the error that
// actually killed the turn; that one is what callers must see.
session.config.logger?.error?.(
"Failed to persist session messages after turn error",
{ sessionId: session.sessionId, error: persistError },
);
}
throw error;
} finally {
session.turnUsageBaseline = undefined;
@@ -1882,6 +1901,15 @@ export class LocalRuntimeHost implements RuntimeHost {
metadata: session.sessionMetadata,
startedAt: session.startedAt,
})) as RootSessionArtifacts;
if (session.compactionState) {
const result = await this.persistActiveSessionCompactionState(
session,
session.compactionState,
);
if (!result.updated) {
session.compactionState = undefined;
}
}
}
private async markTurnRunning(session: ActiveSession): Promise<void> {
@@ -2057,11 +2085,11 @@ export class LocalRuntimeHost implements RuntimeHost {
} catch (error) {
recordCleanupError("update_status", error);
}
try {
await session.agent.shutdown(input.shutdownReason);
} catch (error) {
recordCleanupError("agent_shutdown", error);
}
}
try {
await session.agent.shutdown(input.shutdownReason);
} catch (error) {
recordCleanupError("agent_shutdown", error);
}
try {
await Promise.resolve(session.runtime.shutdown(input.shutdownReason));
@@ -150,7 +150,10 @@ export interface LocalRuntimeStartOptions {
export interface StartSessionInput {
config: StartSessionConfig;
/** The process/client that starts the session. E.g., "vscode", "cli". */
source?: SessionSource;
/** How the session was initiated, such as user, automation, or subagent. */
mode?: string;
prompt?: string;
interactive?: boolean;
sessionMetadata?: Record<string, unknown>;
@@ -2,8 +2,13 @@ import type { MessageWithMetadata } from "@cline/llms";
import type { AgentResult } from "@cline/shared";
import { formatModeSwitchNotice, formatUserInputBlock } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { withSessionHistoryOriginMetadata } from "../session/history-origin";
import { makeSubSessionId } from "../session/models/session-graph";
import type { SessionRow } from "../session/models/session-row";
import {
buildMessagesFilePayload,
deriveTitleFromPrompt,
resolveMessagesFileContext,
withLatestAssistantTurnMetadata,
} from "./session-data";
import { summarizeUsageFromMessages } from "./usage";
@@ -13,6 +18,42 @@ type LegacyStoredMessage = MessageWithMetadata & {
modelId?: string;
};
function createSessionRow(overrides: Partial<SessionRow> = {}): SessionRow {
return {
sessionId: "root-session",
source: "vscode",
pid: 123,
startedAt: "2026-07-31T00:00:00.000Z",
endedAt: null,
exitCode: null,
status: "running",
statusLock: 0,
interactive: true,
provider: "anthropic",
model: "claude-sonnet-4-6",
cwd: "/workspace",
workspaceRoot: "/workspace",
teamName: null,
enableTools: true,
enableSpawn: true,
enableTeams: true,
parentSessionId: null,
parentAgentId: null,
agentId: null,
conversationId: null,
isSubagent: false,
prompt: "test",
metadata: withSessionHistoryOriginMetadata(undefined, {
mode: "user",
version: "3.99.0",
}),
hookPath: "",
messagesPath: "/tmp/root.messages.json",
updatedAt: "2026-07-31T00:00:00.000Z",
...overrides,
};
}
function createResult(overrides: Partial<AgentResult> = {}): AgentResult {
return {
text: "ok",
@@ -59,6 +100,75 @@ describe("deriveTitleFromPrompt", () => {
});
});
describe("messages file provenance", () => {
it("writes root source, session mode, and client version", () => {
const row = createSessionRow();
const payload = buildMessagesFilePayload({
updatedAt: row.updatedAt,
context: resolveMessagesFileContext(row),
messages: [],
});
expect(payload).toMatchObject({
agent: "lead",
sessionId: "root-session",
origin: {
source: "vscode",
mode: "user",
sessionId: "root-session",
version: "3.99.0",
},
});
});
it("records the automation trigger for scheduled runs", () => {
const row = createSessionRow({
source: "core",
metadata: withSessionHistoryOriginMetadata(undefined, {
mode: "automation",
trigger: "hub-schedule",
}),
});
expect(resolveMessagesFileContext(row).origin).toEqual({
source: "core",
mode: "automation",
sessionId: "root-session",
trigger: "hub-schedule",
});
});
it("writes subagent lineage under the actual child session ID", () => {
const childSessionId = makeSubSessionId("root-session", "guardian");
const row = createSessionRow({
sessionId: childSessionId,
parentSessionId: "root-session",
parentAgentId: "lead",
agentId: "guardian",
conversationId: "conversation-1",
isSubagent: true,
metadata: withSessionHistoryOriginMetadata(undefined, {
mode: "subagent",
version: "3.99.0",
}),
});
expect(resolveMessagesFileContext(row)).toEqual({
agent: "subagent",
sessionId: childSessionId,
taskType: "subagent_task",
origin: {
source: "vscode",
mode: "subagent",
sessionId: childSessionId,
parentThreadId: "root-session",
subagent: "guardian",
version: "3.99.0",
},
});
});
});
describe("withLatestAssistantTurnMetadata", () => {
it("normalizes legacy stored provider/model fields into modelInfo", () => {
const messages = [
+31 -6
View File
@@ -4,6 +4,7 @@ import type * as LlmsProviders from "@cline/llms";
import type { AgentConfig, AgentEvent, AgentResult } from "@cline/shared";
import { normalizeUserInput, stripModeNotices } from "@cline/shared";
import { nanoid } from "nanoid";
import { readSessionHistoryOriginMetadata } from "../session/history-origin";
import {
parseSubSessionId,
parseTeamTaskSubSessionId,
@@ -274,30 +275,52 @@ export type MessagesFileContext = {
agent: "lead" | "subagent" | "teammate";
sessionId: string;
taskType?: string;
origin: {
source: string;
mode: string;
sessionId: string;
parentThreadId?: string;
subagent?: string;
version?: string;
trigger?: string;
};
};
export function resolveMessagesFileContext(
sessionId: string,
row: SessionRow,
): MessagesFileContext {
const teamTaskMatch = parseTeamTaskSubSessionId(sessionId);
const historyOrigin = readSessionHistoryOriginMetadata(row.metadata);
const origin = {
source: row.source,
mode: historyOrigin?.mode ?? "user",
sessionId: row.sessionId,
...(row.parentSessionId ? { parentThreadId: row.parentSessionId } : {}),
...(row.agentId ? { subagent: row.agentId } : {}),
...(historyOrigin?.version ? { version: historyOrigin.version } : {}),
...(historyOrigin?.trigger ? { trigger: historyOrigin.trigger } : {}),
};
const teamTaskMatch = parseTeamTaskSubSessionId(row.sessionId);
if (teamTaskMatch) {
return {
agent: "teammate",
sessionId: teamTaskMatch.rootSessionId,
sessionId: row.sessionId,
taskType: "team",
origin,
};
}
const subSessionMatch = parseSubSessionId(sessionId);
const subSessionMatch = parseSubSessionId(row.sessionId);
if (subSessionMatch) {
return {
agent: "subagent",
sessionId: subSessionMatch.rootSessionId,
sessionId: row.sessionId,
taskType: "subagent_task",
origin,
};
}
return {
agent: "lead",
sessionId,
sessionId: row.sessionId,
origin,
};
}
@@ -312,6 +335,7 @@ export function buildMessagesFilePayload(input: {
agent: "lead" | "subagent" | "teammate";
sessionId: string;
taskType?: string;
origin: MessagesFileContext["origin"];
messages: StoredMessageWithMetadata[];
system_prompt?: string;
} {
@@ -321,6 +345,7 @@ export function buildMessagesFilePayload(input: {
agent: input.context.agent,
sessionId: input.context.sessionId,
...(input.context.taskType ? { taskType: input.context.taskType } : {}),
origin: input.context.origin,
messages: normalizeStoredMessagesForPersistence(input.messages),
...(input.systemPrompt ? { system_prompt: input.systemPrompt } : {}),
};
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
readSessionHistoryOriginMetadata,
resolveClientSessionSource,
withSessionHistoryOriginMetadata,
} from "./history-origin";
describe("session history origin", () => {
it("keeps provenance in a typed metadata namespace", () => {
const metadata = withSessionHistoryOriginMetadata(
{ title: "Investigate the SDK" },
{ mode: "automation", version: "3.99.0" },
);
expect(metadata).toEqual({
title: "Investigate the SDK",
sessionHistoryOrigin: {
mode: "automation",
version: "3.99.0",
},
});
expect(readSessionHistoryOriginMetadata(metadata)).toEqual({
mode: "automation",
version: "3.99.0",
});
});
it("preserves stored values when a later boundary has no override", () => {
const metadata = withSessionHistoryOriginMetadata(undefined, {
mode: "automation",
version: "3.98.1",
trigger: "hub-schedule",
});
expect(withSessionHistoryOriginMetadata(metadata, {})).toEqual(metadata);
});
it("records the trigger that initiated an automation session", () => {
const metadata = withSessionHistoryOriginMetadata(undefined, {
mode: "automation",
trigger: "custom-trigger",
});
expect(readSessionHistoryOriginMetadata(metadata)).toEqual({
mode: "automation",
trigger: "custom-trigger",
});
});
it("falls back to user when no mode is provided", () => {
expect(withSessionHistoryOriginMetadata(undefined, {})).toEqual({
sessionHistoryOrigin: { mode: "user" },
});
});
it.each([
{ name: "VSCode Extension", source: "vscode" },
{ name: "Cline for JetBrains", source: "jetbrains" },
{ name: "Cline", source: "jetbrains", platform: "WebStorm" },
{ name: "cline-cli", source: "cli" },
{ name: "cline-acp", source: "cli" },
{ name: "cline-sdk", source: "core" },
{ name: "cline-kanban", source: "kanban" },
{ name: "Cline Desktop", source: "desktop" },
])("maps the $name client to the $source session source", (testCase) => {
expect(resolveClientSessionSource(testCase)).toBe(testCase.source);
});
});
@@ -0,0 +1,103 @@
import type { ClientContext } from "@cline/shared";
import {
SessionSource,
type SessionSource as SessionSourceValue,
} from "../types/common";
const SESSION_HISTORY_ORIGIN_METADATA_KEY = "sessionHistoryOrigin";
export interface SessionHistoryOriginMetadata {
mode: string;
version?: string;
/**
* The trigger that initiated the session, when it is not a direct user
* action. E.g., the `source` label of the automation spec that started a
* scheduled run.
*/
trigger?: string;
}
function trimNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function resolveClientSessionSource(
client: ClientContext | undefined,
): SessionSourceValue | undefined {
const identity = [client?.name, client?.platform]
.filter((value): value is string => typeof value === "string")
.join(" ")
.toLowerCase();
if (!identity) return undefined;
if (identity.includes("vscode") || identity.includes("visual studio code")) {
return SessionSource.VSCODE;
}
if (
identity.includes("jetbrains") ||
identity.includes("webstorm") ||
identity.includes("intellij") ||
identity.includes("pycharm") ||
identity.includes("goland") ||
identity.includes("clion") ||
identity.includes("rider") ||
identity.includes("android studio")
) {
return SessionSource.JETBRAINS;
}
if (identity.includes("neovim")) return SessionSource.NEOVIM;
if (identity.includes("kanban")) return SessionSource.KANBAN;
if (identity.includes("desktop")) return SessionSource.DESKTOP;
if (identity.includes("cline-platform")) {
return SessionSource.WEB;
}
if (identity.includes("cline-cli") || identity.includes("cline-acp")) {
return SessionSource.CLI;
}
if (identity.includes("cline-sdk") || identity.includes("cline-core")) {
return SessionSource.CORE;
}
return undefined;
}
export function readSessionHistoryOriginMetadata(
metadata: Record<string, unknown> | null | undefined,
): SessionHistoryOriginMetadata | undefined {
const value = metadata?.[SESSION_HISTORY_ORIGIN_METADATA_KEY];
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const record = value as Record<string, unknown>;
const mode = trimNonEmptyString(record.mode);
if (!mode) {
return undefined;
}
const version = trimNonEmptyString(record.version);
const trigger = trimNonEmptyString(record.trigger);
return {
mode,
...(version ? { version } : {}),
...(trigger ? { trigger } : {}),
};
}
export function withSessionHistoryOriginMetadata(
metadata: Record<string, unknown> | null | undefined,
origin: {
mode?: string;
version?: string;
trigger?: string;
},
): Record<string, unknown> {
const existing = readSessionHistoryOriginMetadata(metadata);
const mode = trimNonEmptyString(origin.mode) ?? existing?.mode ?? "user";
const version = trimNonEmptyString(origin.version) ?? existing?.version;
const trigger = trimNonEmptyString(origin.trigger) ?? existing?.trigger;
return {
...(metadata ?? {}),
[SESSION_HISTORY_ORIGIN_METADATA_KEY]: {
mode,
...(version ? { version } : {}),
...(trigger ? { trigger } : {}),
},
};
}
@@ -36,6 +36,8 @@ export interface SessionRow {
export interface CreateRootSessionInput {
sessionId: string;
source: SessionSource;
mode?: string;
version?: string;
pid: number;
startedAt: string;
interactive: boolean;
@@ -55,6 +57,8 @@ export interface CreateRootSessionInput {
export interface CreateRootSessionWithArtifactsInput {
sessionId: string;
source: SessionSource;
mode?: string;
version?: string;
pid: number;
interactive: boolean;
provider: string;
@@ -39,6 +39,69 @@ describe("UnifiedSessionPersistenceService", () => {
}
});
it("does not allocate a session while rejecting messages for an unknown id", async () => {
const sessionsDir = mkdtempSync(
join(tmpdir(), "unknown-session-messages-"),
);
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "not-allocated";
await expect(
service.persistSessionMessages(sessionId, [
{ role: "user", content: "do not orphan me" },
]),
).rejects.toThrow(
`Cannot persist messages for unknown session: ${sessionId}`,
);
expect(await service.listSessions()).toEqual([]);
expect(existsSync(join(sessionsDir, sessionId))).toBe(false);
});
sqliteIt(
"re-adopts the session row from the on-disk manifest when the DB row is missing",
async () => {
const dbDir = mkdtempSync(join(tmpdir(), "readopt-row-db-"));
const sessionsDir = mkdtempSync(join(tmpdir(), "readopt-row-"));
tempDirs.push(dbDir, sessionsDir);
const store = new SqliteSessionStore({ sessionsDir: dbDir });
stores.push(store);
const service = new CoreSessionService(store, {
sessionArtifactsDir: sessionsDir,
});
const sessionId = "resumed-session-without-row";
const artifacts = await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: true,
provider: "anthropic",
model: "claude-sonnet",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: false,
enableTeams: false,
prompt: "hello",
startedAt: "2026-01-01T00:00:00.000Z",
});
// Simulate a rebuilt session DB: artifacts on disk, row gone.
store.run("DELETE FROM sessions WHERE session_id = ?", [sessionId]);
await service.persistSessionMessages(sessionId, [
{ role: "user", content: "hello again" },
]);
const payload = JSON.parse(
readFileSync(artifacts.messagesPath, "utf8"),
) as { messages?: unknown[] };
expect(payload.messages).toHaveLength(1);
const rows = await service.listSessions();
expect(rows.map((row) => row.sessionId)).toContain(sessionId);
},
);
it("persists compaction state as a separate session artifact", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-artifact-"));
tempDirs.push(sessionsDir);
@@ -279,6 +342,8 @@ describe("UnifiedSessionPersistenceService", () => {
await service.createRootSessionWithArtifacts({
sessionId: rootSessionId,
source: SessionSource.CLI,
mode: "user",
version: "3.99.0",
pid: process.pid,
interactive: false,
provider: "anthropic",
@@ -354,14 +419,30 @@ describe("UnifiedSessionPersistenceService", () => {
agent?: string;
sessionId?: string;
taskType?: string;
origin?: {
source?: string;
mode?: string;
sessionId?: string;
parentThreadId?: string;
subagent?: string;
version?: string;
};
messages: Array<Record<string, unknown>>;
};
const user = payload.messages[0] as Record<string, unknown>;
const assistant = payload.messages[1] as Record<string, unknown>;
expect(payload.agent).toBe("teammate");
expect(payload.sessionId).toBe(rootSessionId);
expect(payload.sessionId).toBe(teammateSessionId);
expect(payload.taskType).toBe("team");
expect(payload.origin).toEqual({
source: "cli",
mode: "team",
sessionId: teammateSessionId,
parentThreadId: rootSessionId,
subagent: "java-haiku-agent",
version: "3.99.0",
});
expect(assistant.id).toEqual(expect.any(String));
expect(user.agent).toBeUndefined();
expect(user.sessionId).toBeUndefined();
@@ -588,10 +669,13 @@ describe("UnifiedSessionPersistenceService", () => {
contents: expect.stringContaining('"role": "user"'),
row: expect.objectContaining({
sessionId,
metadata: {
metadata: expect.objectContaining({
blobUpload: true,
sessionHistoryOrigin: {
mode: "user",
},
title: "hello",
},
}),
}),
}),
);
@@ -30,6 +30,7 @@ import type {
SessionPersistenceAdapter,
StoredMessageWithMetadata,
} from "../../types/session";
import { withSessionHistoryOriginMetadata } from "../history-origin";
import type { SessionCompactionState } from "../models/session-compaction";
import type { SessionRow } from "../models/session-row";
import { SessionManifestStore } from "../stores/session-manifest-store";
@@ -115,7 +116,10 @@ export class UnifiedSessionPersistenceService {
const manifestPath =
this.manifestStore.artifacts.sessionManifestPath(sessionId);
const metadata = resolveMetadataWithTitle({
metadata: input.metadata,
metadata: withSessionHistoryOriginMetadata(input.metadata, {
mode: input.mode,
version: input.version,
}),
prompt: input.prompt,
});
const manifest = {
@@ -139,7 +143,7 @@ export class UnifiedSessionPersistenceService {
messages_path: messagesPath,
};
await this.adapter.upsertSession({
const row: SessionRow = {
sessionId,
source: input.source,
pid: input.pid,
@@ -167,13 +171,10 @@ export class UnifiedSessionPersistenceService {
hookPath: "",
messagesPath,
updatedAt: nowIso(),
});
};
await this.adapter.upsertSession(row);
this.manifestStore.initializeMessagesFile(
sessionId,
messagesPath,
startedAt,
);
this.manifestStore.initializeMessagesFile(row, messagesPath, startedAt);
this.manifestStore.writeSessionManifest(manifestPath, manifest);
return { manifestPath, messagesPath, compactionPath, manifest };
}
@@ -119,7 +119,7 @@ class LocalSessionPersistenceAdapter implements SessionPersistenceAdapter {
`UPDATE sessions
SET status = 'running', ended_at = NULL, exit_code = NULL, updated_at = ?, status_lock = ?,
parent_session_id = ?, parent_agent_id = ?, agent_id = ?, conversation_id = ?, is_subagent = 1,
prompt = COALESCE(prompt, ?)
prompt = COALESCE(prompt, ?), metadata_json = ?
WHERE session_id = ? AND status_lock = ?`,
[
nowIso(),
@@ -129,6 +129,7 @@ class LocalSessionPersistenceAdapter implements SessionPersistenceAdapter {
input.agentId ?? null,
input.conversationId ?? null,
input.prompt ?? null,
stringifyMetadata(input.metadata),
input.sessionId,
input.expectedStatusLock,
],
@@ -30,6 +30,7 @@ import {
type SessionManifest,
SessionManifestSchema,
} from "../models/session-manifest";
import type { SessionRow } from "../models/session-row";
import { writeFileAtomic } from "./atomic-file";
function isNotFoundError(error: unknown): boolean {
@@ -41,6 +42,38 @@ function isNotFoundError(error: unknown): boolean {
);
}
function sessionRowFromManifest(manifest: SessionManifest): SessionRow {
return {
sessionId: manifest.session_id,
source: manifest.source,
pid: manifest.pid,
startedAt: manifest.started_at,
endedAt: manifest.ended_at ?? null,
exitCode: manifest.exit_code ?? null,
status: manifest.status,
statusLock: 0,
interactive: manifest.interactive,
provider: manifest.provider,
model: manifest.model,
cwd: manifest.cwd,
workspaceRoot: manifest.workspace_root,
teamName: manifest.team_name ?? null,
enableTools: manifest.enable_tools,
enableSpawn: manifest.enable_spawn,
enableTeams: manifest.enable_teams,
parentSessionId: null,
parentAgentId: null,
agentId: null,
conversationId: null,
isSubagent: false,
prompt: manifest.prompt ?? null,
metadata: manifest.metadata ?? null,
hookPath: "",
messagesPath: manifest.messages_path ?? null,
updatedAt: nowIso(),
};
}
export class SessionManifestStore {
readonly artifacts: SessionArtifacts;
@@ -57,15 +90,11 @@ export class SessionManifestStore {
}
initializeMessagesFile(
sessionId: string,
row: SessionRow,
path: string,
startedAt: string,
): void {
writeEmptyMessagesFile(
path,
startedAt,
resolveMessagesFileContext(sessionId),
);
writeEmptyMessagesFile(path, startedAt, resolveMessagesFileContext(row));
}
writeSessionManifest(manifestPath: string, manifest: SessionManifest): void {
@@ -142,16 +171,28 @@ export class SessionManifestStore {
}
}
async resolveArtifactPath(
sessionId: string,
kind: "messagesPath",
fallback: (id: string) => string,
): Promise<string> {
/**
* Resolve the session row backing a message write, re-adopting it from the
* on-disk manifest when the DB row is missing (session artifacts restored
* or copied while the session DB was rebuilt). Sessions with neither a row
* nor a manifest throw so message writes cannot silently recreate
* orphaned session files.
*/
private async resolveSessionRow(sessionId: string): Promise<SessionRow> {
const row = await this.adapter.getSession(sessionId);
const value = row?.[kind];
return typeof value === "string" && value.trim().length > 0
? value
: fallback(sessionId);
if (row) {
return row;
}
const { manifest } = this.readManifestFile(sessionId);
if (!manifest) {
throw new Error(
`Cannot persist messages for unknown session: ${sessionId}`,
);
}
const adopted = sessionRowFromManifest(manifest);
await this.adapter.upsertSession(adopted);
this.logger?.debug("Re-adopted session row from manifest", { sessionId });
return adopted;
}
async persistSessionMessages(
@@ -159,14 +200,14 @@ export class SessionManifestStore {
messages: LlmsProviders.Message[],
systemPrompt?: string,
): Promise<void> {
const path = await this.resolveArtifactPath(
sessionId,
"messagesPath",
(id) => this.artifacts.sessionMessagesPath(id),
);
const row = await this.resolveSessionRow(sessionId);
const path =
typeof row.messagesPath === "string" && row.messagesPath.trim().length > 0
? row.messagesPath
: this.artifacts.sessionMessagesPath(sessionId);
const payload = buildMessagesFilePayload({
updatedAt: nowIso(),
context: resolveMessagesFileContext(sessionId),
context: resolveMessagesFileContext(row),
messages: messages as StoredMessageWithMetadata[],
systemPrompt,
});
@@ -177,7 +218,6 @@ export class SessionManifestStore {
return;
}
try {
const row = await this.adapter.getSession(sessionId);
await this.messagesArtifactUploader.uploadMessagesFile({
sessionId,
path,
@@ -21,6 +21,10 @@ import type {
SessionPersistenceAdapter,
StoredMessageWithMetadata,
} from "../../types/session";
import {
readSessionHistoryOriginMetadata,
withSessionHistoryOriginMetadata,
} from "../history-origin";
import {
deriveSubsessionStatus,
makeSubSessionId,
@@ -29,7 +33,6 @@ import {
import type { SessionRow, UpsertSubagentInput } from "../models/session-row";
import type { SessionManifestStore } from "../stores/session-manifest-store";
const SUBSESSION_SOURCE = "subagent";
const SpawnAgentInputSchema = z.looseObject({
task: z.string().optional(),
systemPrompt: z.string().optional(),
@@ -72,6 +75,7 @@ export class TeamChildSessionManager {
root: SessionRow,
opts: {
sessionId: string;
mode: "subagent" | "team";
parentSessionId: string;
parentAgentId: string;
agentId: string;
@@ -81,9 +85,10 @@ export class TeamChildSessionManager {
messagesPath: string;
},
): SessionRow {
const rootHistoryOrigin = readSessionHistoryOriginMetadata(root.metadata);
return {
sessionId: opts.sessionId,
source: SUBSESSION_SOURCE,
source: root.source,
pid: process.ppid,
startedAt: opts.startedAt,
endedAt: null,
@@ -105,7 +110,13 @@ export class TeamChildSessionManager {
conversationId: opts.conversationId ?? null,
isSubagent: true,
prompt: opts.prompt,
metadata: resolveMetadataWithTitle({ prompt: opts.prompt }),
metadata: resolveMetadataWithTitle({
metadata: withSessionHistoryOriginMetadata(undefined, {
mode: opts.mode,
version: rootHistoryOrigin?.version,
}),
prompt: opts.prompt,
}),
hookPath: "",
messagesPath: opts.messagesPath,
updatedAt: opts.startedAt,
@@ -157,20 +168,20 @@ export class TeamChildSessionManager {
}
if (!existing) {
await this.adapter.upsertSession(
this.buildSubsessionRow(root, {
sessionId,
parentSessionId: rootSessionId,
parentAgentId: input.parentAgentId,
agentId: input.agentId,
conversationId: input.conversationId,
prompt,
startedAt,
...artifactPaths,
}),
);
this.manifestStore.initializeMessagesFile(
const row = this.buildSubsessionRow(root, {
sessionId,
mode: "subagent",
parentSessionId: rootSessionId,
parentAgentId: input.parentAgentId,
agentId: input.agentId,
conversationId: input.conversationId,
prompt,
startedAt,
...artifactPaths,
});
await this.adapter.upsertSession(row);
this.manifestStore.initializeMessagesFile(
row,
artifactPaths.messagesPath,
startedAt,
);
@@ -186,7 +197,10 @@ export class TeamChildSessionManager {
conversationId: input.conversationId,
prompt: existing.prompt ?? prompt ?? null,
metadata: resolveMetadataWithTitle({
metadata: existing.metadata ?? undefined,
metadata: withSessionHistoryOriginMetadata(existing.metadata, {
mode: "subagent",
version: readSessionHistoryOriginMetadata(root.metadata)?.version,
}),
prompt: existing.prompt ?? prompt ?? null,
}),
expectedStatusLock: existing.statusLock,
@@ -272,22 +286,18 @@ export class TeamChildSessionManager {
sessionId,
agentId,
);
await this.adapter.upsertSession(
this.buildSubsessionRow(root, {
sessionId,
parentSessionId: rootSessionId,
parentAgentId: "lead",
agentId,
prompt: message || `Team task for ${agentId}`,
startedAt,
messagesPath,
}),
);
this.manifestStore.initializeMessagesFile(
const row = this.buildSubsessionRow(root, {
sessionId,
messagesPath,
mode: "team",
parentSessionId: rootSessionId,
parentAgentId: "lead",
agentId,
prompt: message || `Team task for ${agentId}`,
startedAt,
);
messagesPath,
});
await this.adapter.upsertSession(row);
this.manifestStore.initializeMessagesFile(row, messagesPath, startedAt);
const key = this.teamTaskQueueKey(rootSessionId, agentId);
const queue = this.teamTaskSessionsByAgent.get(key) ?? [];
queue.push(sessionId);