diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index 9fc4c5d5a22..7554e416bdc 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -261,6 +261,9 @@ The remaining cleanup is mostly consolidation and deletion: `src/gateway/session-transcript-readers.ts` instead of the old `session-utils.fs` module name. The fallback retry history check is named for SQLite transcript content instead of session-file content. +- Bootstrap continuation detection now checks SQLite transcript locators through + `hasCompletedBootstrapTranscriptTurn`; it no longer exposes a + session-file-shaped helper name. - Memory indexing helpers now use SQLite transcript terminology end to end: host exports list/build session transcript entries, targeted sync queues `sessionTranscripts`, and QMD/builtin indexers no longer expose session-file diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index 5ba9a0a0abb..c6da6eee15a 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -12,7 +12,7 @@ import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { _resetBootstrapWarningCacheForTest, FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, - hasCompletedBootstrapTurn, + hasCompletedBootstrapTranscriptTurn, makeBootstrapWarn, resolveBootstrapContextForRun, resolveBootstrapFilesForRun, @@ -269,7 +269,7 @@ describe("resolveBootstrapContextForRun", () => { }); }); -describe("hasCompletedBootstrapTurn", () => { +describe("hasCompletedBootstrapTranscriptTurn", () => { let tmpDir: string; beforeEach(async () => { @@ -283,7 +283,7 @@ describe("hasCompletedBootstrapTurn", () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - function writeTranscript(sessionFile: string, events: unknown[]): void { + function writeTranscript(transcriptPath: string, events: unknown[]): void { const sessionId = events.find((event): event is { type: "session"; id: string } => Boolean( @@ -292,52 +292,54 @@ describe("hasCompletedBootstrapTurn", () => { (event as { type?: unknown }).type === "session" && typeof (event as { id?: unknown }).id === "string", ), - )?.id ?? path.basename(sessionFile, ".jsonl"); + )?.id ?? path.basename(transcriptPath, ".jsonl"); replaceSqliteSessionTranscriptEvents({ agentId: "main", sessionId, - transcriptPath: sessionFile, + transcriptPath, events, }); } - it("returns false when session file does not exist", async () => { - expect(await hasCompletedBootstrapTurn(path.join(tmpDir, "missing.jsonl"))).toBe(false); + it("returns false when transcript locator has no SQLite rows", async () => { + expect(await hasCompletedBootstrapTranscriptTurn(path.join(tmpDir, "missing.jsonl"))).toBe( + false, + ); }); - it("returns false for empty session files", async () => { - const sessionFile = path.join(tmpDir, "empty.jsonl"); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + it("returns false for empty transcript locators", async () => { + const transcriptPath = path.join(tmpDir, "empty.jsonl"); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(false); }); - it("returns false for header-only session files", async () => { - const sessionFile = path.join(tmpDir, "header-only.jsonl"); - writeTranscript(sessionFile, [{ type: "session", id: "s1" }]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + it("returns false for header-only transcript rows", async () => { + const transcriptPath = path.join(tmpDir, "header-only.jsonl"); + writeTranscript(transcriptPath, [{ type: "session", id: "s1" }]); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(false); }); it("returns false when no assistant turn has been flushed yet", async () => { - const sessionFile = path.join(tmpDir, "user-only.jsonl"); - writeTranscript(sessionFile, [ + const transcriptPath = path.join(tmpDir, "user-only.jsonl"); + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "message", message: { role: "user", content: "hello" } }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(false); }); it("returns false for assistant turns without a recorded full bootstrap marker", async () => { - const sessionFile = path.join(tmpDir, "assistant-no-marker.jsonl"); - writeTranscript(sessionFile, [ + const transcriptPath = path.join(tmpDir, "assistant-no-marker.jsonl"); + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "message", message: { role: "user", content: "hello" } }, { type: "message", message: { role: "assistant", content: "hi" } }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(false); }); it("returns true when a full bootstrap completion marker exists", async () => { - const sessionFile = path.join(tmpDir, "full-bootstrap.jsonl"); - writeTranscript(sessionFile, [ + const transcriptPath = path.join(tmpDir, "full-bootstrap.jsonl"); + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "message", message: { role: "assistant", content: "hi" } }, { @@ -346,12 +348,12 @@ describe("hasCompletedBootstrapTurn", () => { data: { timestamp: 1 }, }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(true); }); it("returns false when compaction happened after the last assistant turn", async () => { - const sessionFile = path.join(tmpDir, "post-compaction.jsonl"); - writeTranscript(sessionFile, [ + const transcriptPath = path.join(tmpDir, "post-compaction.jsonl"); + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "custom", @@ -360,12 +362,12 @@ describe("hasCompletedBootstrapTurn", () => { }, { type: "compaction", summary: "trimmed" }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(false); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(false); }); it("returns true when a later full bootstrap marker happens after compaction", async () => { - const sessionFile = path.join(tmpDir, "assistant-after-compaction.jsonl"); - writeTranscript(sessionFile, [ + const transcriptPath = path.join(tmpDir, "assistant-after-compaction.jsonl"); + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "custom", @@ -381,13 +383,13 @@ describe("hasCompletedBootstrapTurn", () => { data: { timestamp: 2 }, }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(true); }); it("finds a recent full bootstrap marker after large earlier content", async () => { - const sessionFile = path.join(tmpDir, "large-prefix.jsonl"); + const transcriptPath = path.join(tmpDir, "large-prefix.jsonl"); const hugePrefix = "x".repeat(300 * 1024); - writeTranscript(sessionFile, [ + writeTranscript(transcriptPath, [ { type: "session", id: "s1" }, { type: "message", message: { role: "user", content: hugePrefix } }, { @@ -396,15 +398,15 @@ describe("hasCompletedBootstrapTurn", () => { data: { timestamp: 1 }, }, ]); - expect(await hasCompletedBootstrapTurn(sessionFile)).toBe(true); + expect(await hasCompletedBootstrapTranscriptTurn(transcriptPath)).toBe(true); }); - it("returns false for symbolic links", async () => { + it("returns false for unimported symbolic-link locators", async () => { const realFile = path.join(tmpDir, "real.jsonl"); const linkFile = path.join(tmpDir, "link.jsonl"); await fs.writeFile(realFile, "", "utf8"); await fs.symlink(realFile, linkFile); - expect(await hasCompletedBootstrapTurn(linkFile)).toBe(false); + expect(await hasCompletedBootstrapTranscriptTurn(linkFile)).toBe(false); }); }); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 35e0c64681c..d692d0a20ad 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -57,8 +57,10 @@ export function resolveContextInjectionMode(config?: OpenClawConfig): AgentConte return config?.agents?.defaults?.contextInjection ?? "always"; } -export async function hasCompletedBootstrapTurn(sessionFile: string): Promise { - const scope = resolveSqliteSessionTranscriptScopeForPath({ transcriptPath: sessionFile }); +export async function hasCompletedBootstrapTranscriptTurn( + transcriptLocator: string, +): Promise { + const scope = resolveSqliteSessionTranscriptScopeForPath({ transcriptPath: transcriptLocator }); if (!scope) { return false; } diff --git a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts index 9637436426f..bc6d998b2de 100644 --- a/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.context-engine-helpers.ts @@ -24,7 +24,7 @@ export async function resolveAttemptBootstrapContext Promise; + hasCompletedBootstrapTranscriptTurn: (transcriptLocator: string) => Promise; resolveBootstrapContextForRun: () => Promise< AttemptBootstrapContext >; @@ -38,7 +38,7 @@ export async function resolveAttemptBootstrapContext Promise<{ bootstrapFiles: unknown[]; contextFiles: unknown[] }>; }) { - const hasCompletedBootstrapTurn = vi.fn(async () => params.completed ?? false); + const hasCompletedBootstrapTranscriptTurn = vi.fn(async () => params.completed ?? false); const resolveBootstrapContextForRun = params.resolver ?? vi.fn(async () => ({ @@ -33,11 +33,11 @@ async function resolveBootstrapContext(params: { bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default", bootstrapMode: params.bootstrapMode ?? "none", sessionFile: "/tmp/session.jsonl", - hasCompletedBootstrapTurn, + hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun, }); - return { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun }; + return { result, hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun }; } describe("embedded attempt context injection", () => { @@ -46,7 +46,7 @@ describe("embedded attempt context injection", () => { }); it("skips bootstrap reinjection on safe continuation turns when configured", async () => { - const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = + const { result, hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ contextInjectionMode: "continuation-skip", completed: true, @@ -55,7 +55,7 @@ describe("embedded attempt context injection", () => { expect(result.isContinuationTurn).toBe(true); expect(result.bootstrapFiles).toEqual([]); expect(result.contextFiles).toEqual([]); - expect(hasCompletedBootstrapTurn).toHaveBeenCalledWith("/tmp/session.jsonl"); + expect(hasCompletedBootstrapTranscriptTurn).toHaveBeenCalledWith("/tmp/session.jsonl"); expect(resolveBootstrapContextForRun).not.toHaveBeenCalled(); }); @@ -78,7 +78,7 @@ describe("embedded attempt context injection", () => { }); it("disables bootstrap injection without marking the turn as a continuation", async () => { - const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = + const { result, hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ contextInjectionMode: "never", bootstrapMode: "full", @@ -89,7 +89,7 @@ describe("embedded attempt context injection", () => { expect(result.shouldRecordCompletedBootstrapTurn).toBe(false); expect(result.bootstrapFiles).toEqual([]); expect(result.contextFiles).toEqual([]); - expect(hasCompletedBootstrapTurn).not.toHaveBeenCalled(); + expect(hasCompletedBootstrapTranscriptTurn).not.toHaveBeenCalled(); expect(resolveBootstrapContextForRun).not.toHaveBeenCalled(); }); @@ -99,7 +99,7 @@ describe("embedded attempt context injection", () => { contextFiles: [{ path: "BOOTSTRAP.md" }], })); - const { result, hasCompletedBootstrapTurn } = await resolveBootstrapContext({ + const { result, hasCompletedBootstrapTranscriptTurn } = await resolveBootstrapContext({ contextInjectionMode: "continuation-skip", bootstrapMode: "full", completed: true, @@ -109,7 +109,7 @@ describe("embedded attempt context injection", () => { expect(result.isContinuationTurn).toBe(false); expect(result.bootstrapFiles).toEqual([{ name: "BOOTSTRAP.md" }]); expect(result.contextFiles).toEqual([{ path: "BOOTSTRAP.md" }]); - expect(hasCompletedBootstrapTurn).not.toHaveBeenCalled(); + expect(hasCompletedBootstrapTranscriptTurn).not.toHaveBeenCalled(); expect(resolver).toHaveBeenCalledTimes(1); }); @@ -143,7 +143,7 @@ describe("embedded attempt context injection", () => { }); it("never skips heartbeat bootstrap filtering", async () => { - const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = + const { result, hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ contextInjectionMode: "continuation-skip", bootstrapContextMode: "lightweight", @@ -153,7 +153,7 @@ describe("embedded attempt context injection", () => { expect(result.isContinuationTurn).toBe(false); expect(result.shouldRecordCompletedBootstrapTurn).toBe(false); - expect(hasCompletedBootstrapTurn).not.toHaveBeenCalled(); + expect(hasCompletedBootstrapTranscriptTurn).not.toHaveBeenCalled(); expect(resolveBootstrapContextForRun).toHaveBeenCalledTimes(1); }); @@ -185,7 +185,7 @@ describe("embedded attempt context injection", () => { }); it("allows continuation skip again for limited bootstrap mode", async () => { - const { result, hasCompletedBootstrapTurn, resolveBootstrapContextForRun } = + const { result, hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun } = await resolveBootstrapContext({ contextInjectionMode: "continuation-skip", bootstrapMode: "limited", @@ -193,7 +193,7 @@ describe("embedded attempt context injection", () => { }); expect(result.isContinuationTurn).toBe(true); - expect(hasCompletedBootstrapTurn).toHaveBeenCalledWith("/tmp/session.jsonl"); + expect(hasCompletedBootstrapTranscriptTurn).toHaveBeenCalledWith("/tmp/session.jsonl"); expect(resolveBootstrapContextForRun).not.toHaveBeenCalled(); expect(result.shouldRecordCompletedBootstrapTurn).toBe(false); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index a05999b4547..f586b5551cb 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { expect, vi, type Mock } from "vitest"; +import { createSqliteSessionTranscriptLocator } from "../../../config/sessions/paths.js"; import type { AssembleResult, BootstrapResult, @@ -12,6 +13,7 @@ import type { IngestResult, } from "../../../context-engine/types.js"; import { formatErrorMessage } from "../../../infra/errors.js"; +import { DEFAULT_AGENT_ID, resolveAgentIdFromSessionKey } from "../../../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -71,7 +73,7 @@ type AttemptSpawnWorkspaceHoisted = { resolveBootstrapContextForRunMock: Mock<() => Promise>; isWorkspaceBootstrapPendingMock: Mock<(workspaceDir: string) => Promise>; resolveContextInjectionModeMock: Mock<() => "always" | "continuation-skip">; - hasCompletedBootstrapTurnMock: Mock<() => Promise>; + hasCompletedBootstrapTranscriptTurnMock: Mock<() => Promise>; resolveEmbeddedRunSkillEntriesMock: UnknownMock; resolveSkillsPromptForRunMock: UnknownMock; supportsModelToolsMock: Mock<(model?: unknown) => boolean>; @@ -150,7 +152,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const resolveContextInjectionModeMock = vi.fn<() => "always" | "continuation-skip">( () => "always", ); - const hasCompletedBootstrapTurnMock = vi.fn<() => Promise>(async () => false); + const hasCompletedBootstrapTranscriptTurnMock = vi.fn<() => Promise>(async () => false); const resolveEmbeddedRunSkillEntriesMock = vi.fn(() => ({ shouldLoadSkillEntries: false, skillEntries: undefined, @@ -202,7 +204,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { resolveBootstrapContextForRunMock, isWorkspaceBootstrapPendingMock, resolveContextInjectionModeMock, - hasCompletedBootstrapTurnMock, + hasCompletedBootstrapTranscriptTurnMock, resolveEmbeddedRunSkillEntriesMock, resolveSkillsPromptForRunMock, supportsModelToolsMock, @@ -306,7 +308,7 @@ vi.mock("../../bootstrap-files.js", async () => { resolveBootstrapFilesForRun: hoisted.resolveBootstrapFilesForRunMock, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, resolveContextInjectionMode: hoisted.resolveContextInjectionModeMock, - hasCompletedBootstrapTurn: hoisted.hasCompletedBootstrapTurnMock, + hasCompletedBootstrapTranscriptTurn: hoisted.hasCompletedBootstrapTranscriptTurnMock, }; }); @@ -826,7 +828,7 @@ export function resetEmbeddedAttemptHarness( }); hoisted.isWorkspaceBootstrapPendingMock.mockReset().mockResolvedValue(false); hoisted.resolveContextInjectionModeMock.mockReset().mockReturnValue("always"); - hoisted.hasCompletedBootstrapTurnMock.mockReset().mockResolvedValue(false); + hoisted.hasCompletedBootstrapTranscriptTurnMock.mockReset().mockResolvedValue(false); hoisted.resolveEmbeddedRunSkillEntriesMock.mockReset().mockReturnValue({ shouldLoadSkillEntries: false, skillEntries: undefined, @@ -1015,7 +1017,11 @@ export async function createContextEngineAttemptRunner(params: { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-workspace-")); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-agent-")); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ctx-engine-state-")); - const sessionFile = path.join(workspaceDir, "session.jsonl"); + const sessionId = "embedded-session"; + const sessionFile = createSqliteSessionTranscriptLocator({ + agentId: resolveAgentIdFromSessionKey(params.sessionKey) ?? DEFAULT_AGENT_ID, + sessionId, + }); params.tempPaths.push(workspaceDir, agentDir, stateDir); const seedMessages: AgentMessage[] = params.sessionMessages ?? ([{ role: "user", content: "seed", timestamp: 1 }] as AgentMessage[]); @@ -1055,7 +1061,7 @@ export async function createContextEngineAttemptRunner(params: { const result = await ( await loadRunEmbeddedAttempt() )({ - sessionId: "embedded-session", + sessionId, sessionKey: params.sessionKey, sessionFile, workspaceDir, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index c2c11e03cb7..dbe77663582 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -68,7 +68,7 @@ import { import { FULL_BOOTSTRAP_COMPLETED_CUSTOM_TYPE, buildBootstrapContextForFiles, - hasCompletedBootstrapTurn, + hasCompletedBootstrapTranscriptTurn, isWorkspaceBootstrapPending, makeBootstrapWarn, resolveBootstrapFilesForRun, @@ -970,7 +970,7 @@ export async function runEmbeddedAttempt( bootstrapContextRunKind: params.bootstrapContextRunKind ?? "default", bootstrapMode, sessionFile: params.sessionFile, - hasCompletedBootstrapTurn, + hasCompletedBootstrapTranscriptTurn, resolveBootstrapContextForRun: async () => { const bootstrapFiles = preloadedBootstrapFiles ??