refactor(sessions): move watch and version operations into workers (#163513)

* refactor(sessions): move watch and version operations into workers

* fix(sessions): preserve caller authority across worker watch operations
This commit is contained in:
Peter Steinberger 2026-10-02 08:51:58 -07:00 • committed by GitHub
parent f02d16e758
commit 7f232d4e64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2655 additions and 780 deletions

View file

@ -690,8 +690,23 @@ current session authority; upstream observations compare the captured source in
the committing transaction. Goal events and normalized child-run terminal outcomes
share that recording command. Child completion joins recording and rechecks its
current lifecycle or ACP actor authority at transaction and commit admission.
Synchronous creation, compaction, watch, reset, and deletion callbacks remain
separate migration work.
Watch registration and consumed-notice acknowledgment use that same writer. Group
turns keep an unchanged watch read-only; registration preserves explicit provenance
and seeds only a new physical watcher store. Completion callers supply source-bound
lineage and requester predicates: workers reread durable session facts at admission
and after the host grant, while the host checks live caller authority without querying SQLite.
Incognito callers use committed facts from their original in-memory store owner.
The unchanged-watch path retains the same fresh lineage check without writing.
Custom-store discovery prepares the
existing system-event owner's path cache through the session read worker. Acknowledgment
captures the consumed notices' store addresses before yielding, rechecks the host's current system-event store at transaction
and commit admission, and publishes interleaved follow-up notices after commit. It
advances only the frozen notification watermark. Version enrichment and bounded event
pages use the shared-state reader, preserving composite session identity and per-session
pruned watermarks. Accepted operations retain the existing worker's FIFO and settlement
owner. Schemas, retention, and update behavior are unchanged.
Synchronous creation, compaction, reset, deletion, and the public SDK's ambient prompt
probe remain separate migration work; the restart notice sweep stays in boot admission.
Durable session entry replacement reads its detached snapshot in the history
worker and commits through the existing agent database executor. The transaction

View file

@ -90,7 +90,7 @@ describe("ACP accepted cancellation ownership", () => {
]),
);
expect(
listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200).events,
(await listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200)).events,
).toMatchObject([
{ kind: "run_failed", runId: "snapshot-0", payload: { outcome: "cancelled" } },
{ kind: "run_failed", runId: "snapshot-1", payload: { outcome: "cancelled" } },
@ -136,7 +136,7 @@ describe("ACP accepted cancellation ownership", () => {
expect(activeSignal?.aborted).toBe(false);
expect(state.cancel).not.toHaveBeenCalled();
expect(
listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200).events,
(await listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200)).events,
).toEqual([]);
} finally {
release.resolve();
@ -144,7 +144,7 @@ describe("ACP accepted cancellation ownership", () => {
}
expect(state.runTurn).toHaveBeenCalledOnce();
expect(
listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200).events,
(await listSessionStateEventsSince(state.target.sessionKey, "codex", 0, 200)).events,
).toMatchObject([{ kind: "run_completed", runId: "same-id" }]);
});
});

View file

@ -63,7 +63,9 @@ describe("ACP terminal state signals", () => {
requestId: "cancelled-state-turn",
});
expect(listSessionStateEventsSince(childSessionKey, "main", 0, 200).events).toMatchObject([
expect(
(await listSessionStateEventsSince(childSessionKey, "main", 0, 200)).events,
).toMatchObject([
{ kind: "human_direct_message", runId: "human-state-turn" },
{ kind: "run_completed", runId: "human-state-turn" },
{ kind: "run_completed", runId: "system-state-turn" },
@ -121,10 +123,12 @@ describe("ACP terminal state signals", () => {
}
expect(settled).toBe(true);
expect(
listSessionStateEventsSince(childSessionKey, "main", 0, 200).events.map((event) => ({
kind: event.kind,
runId: event.runId,
})),
(await listSessionStateEventsSince(childSessionKey, "main", 0, 200)).events.map(
(event) => ({
kind: event.kind,
runId: event.runId,
}),
),
).toEqual([
{ kind: "run_completed", runId: "warm-terminal-worker" },
{ kind: "run_completed", runId: "contended-terminal" },

View file

@ -118,7 +118,8 @@ describe("AcpSessionManager", () => {
successor ? [sessionKey] : [],
);
expect(
sessionStateEvents.listSessionStateEventsSince(sessionKey, "codex", 0, 200).events,
(await sessionStateEvents.listSessionStateEventsSince(sessionKey, "codex", 0, 200))
.events,
).toMatchObject(
reason === "signal failure"
? [{ kind: "run_failed", runId: requestId, payload: { outcome: "error" } }]
@ -129,7 +130,8 @@ describe("AcpSessionManager", () => {
await successor;
expect(getActiveAcpTurnCount()).toBe(0);
expect(
sessionStateEvents.listSessionStateEventsSince(sessionKey, "codex", 0, 200).events,
(await sessionStateEvents.listSessionStateEventsSince(sessionKey, "codex", 0, 200))
.events,
).toMatchObject([{ kind: "run_completed", runId: requestId }]);
}
} finally {

View file

@ -13,16 +13,20 @@ export function createCodingToolsGatewayCaller(params: {
capabilityProfile: ResolvedConversationCapabilityProfile;
}) {
const { options, agentId, sessionKey, capabilityProfile } = params;
const settleBatch =
capabilityProfile.policy.requesterPolicySource === "completion-handoff"
? options?.trustedInternalHandoff?.settleBatch
: undefined;
const identity =
options && agentId && sessionKey?.trim()
? {
agentId,
sessionKey: sessionKey.trim(),
// The existing source fence rechecks this after tool preparation and at final I/O.
receiptAuthority:
capabilityProfile.policy.requesterPolicySource === "completion-handoff"
? options.trustedInternalHandoff?.settleBatch?.isCurrent
: undefined,
receiptAuthority: settleBatch?.isCurrent,
receiptAdmissions: settleBatch?.receiptAdmission
? [settleBatch.receiptAdmission]
: undefined,
assertToolAllowed: (toolName: string) => {
if (!isConversationToolAllowed(capabilityProfile, toolName)) {
throw new Error(`${toolName} is not allowed by this conversation's tool policy`);

View file

@ -184,7 +184,7 @@ describe("compaction handlers", () => {
stream: "compaction",
data: { phase: "end", completed: true, willRetry: false, outcome: "completed" },
});
const events = listSessionStateEventsSince(sessionKey, agentId, 0).events.filter(
const events = (await listSessionStateEventsSince(sessionKey, agentId, 0)).events.filter(
(event) => event.runId === runId,
);
expect(events).toHaveLength(expectedEventCount);

View file

@ -26,7 +26,7 @@ import {
type SessionCapabilityStore,
} from "./subagents/spawn/subagent-capabilities.js";
const MAX_DELEGATION_LINEAGE_DEPTH = 32;
export const MAX_DELEGATION_LINEAGE_DEPTH = 32;
type RequesterToolPolicySource = "current-request" | "persisted-child" | "completion-handoff";
@ -199,6 +199,7 @@ export function hasVerifiedRequesterCompletionHandoff(
| "sessionId"
| "modelProvider"
| "modelId"
| "preparedSessionCapabilityStore"
>,
): boolean {
const delegatedPolicy = resolveDelegatedPolicy(params, undefined);

View file

@ -32,6 +32,7 @@ import {
hasVisibleCompletionResult,
} from "../../internal-event-contract.js";
import type { AgentInternalEvent } from "../../internal-events.js";
import type { GatewayToolCallerReceiptAdmission } from "../../tools/gateway-caller-receipt.types.js";
import {
SOURCE_OWNER_CHANGED,
resolveActiveWakeWithRetries,
@ -90,6 +91,7 @@ export type SubagentAnnounceDirectParams = {
sourceTool?: string;
settleWakeSourceSessionKeys?: readonly string[];
isSourceSessionEffectsAllowed?: () => boolean;
sourceReceiptAdmission?: GatewayToolCallerReceiptAdmission;
/** Additional source guard released by the accepting Gateway or injection owner. */
isSourceSessionAdmissionAllowed?: () => boolean;
isCompletionOwnedByRequesterYield?: () => boolean;
@ -486,6 +488,7 @@ export async function sendSubagentAnnounceDirectly(
settleBatch: {
sourceSessionKeys: params.settleWakeSourceSessionKeys,
isCurrent: isCompletionDeliveryAllowed,
receiptAdmission: params.sourceReceiptAdmission,
},
}
: {}),

View file

@ -1,11 +1,13 @@
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import { AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION } from "../../internal-event-contract.js";
import type { AgentInternalEvent } from "../../internal-events.js";
import type { GatewayToolCallerReceiptAdmission } from "../../tools/gateway-caller-receipt.types.js";
type SubagentSettleToolPolicyBatch = {
sourceSessionKeys: readonly string[];
/** The settle owner retains batch, requester-incarnation, and revocation authority. */
isCurrent: () => boolean;
receiptAdmission?: GatewayToolCallerReceiptAdmission;
};
export type TrustedSubagentCompletionHandoff = {

View file

@ -0,0 +1,28 @@
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import { isRequesterCompletionCohortCurrent } from "../registry/subagent-requester-settle-identity.js";
import {
dedupeLatestChildCompletionRows,
filterCurrentDirectChildCompletionRows,
} from "./subagent-announce-output.js";
export function selectCurrentRequesterCompletionRows(params: {
rows: SubagentRunRecord[];
requesterSessionKey: string;
requesterAgentId?: string;
frozenBatch: boolean;
latestForSession: Parameters<typeof isRequesterCompletionCohortCurrent>[1];
}): SubagentRunRecord[] {
if (params.frozenBatch) {
return params.rows.filter((entry) =>
isRequesterCompletionCohortCurrent(entry, params.latestForSession),
);
}
return dedupeLatestChildCompletionRows(
filterCurrentDirectChildCompletionRows(params.rows, {
requesterSessionKey: params.requesterSessionKey,
requesterAgentId: params.requesterAgentId,
getLatestSubagentRunByChildSessionKey: (childSessionKey, childAgentId) =>
params.latestForSession(childSessionKey, undefined, childAgentId),
}),
);
}

View file

@ -0,0 +1,71 @@
import { vi } from "vitest";
import type { countPendingDescendantRuns } from "../registry/subagent-registry-read.js";
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import type { createRequesterDescendantReader } from "./subagent-announce.requester-settle-descendants.js";
const readDescendantFacts = vi.hoisted(() =>
vi.fn<
(
params: Parameters<typeof createRequesterDescendantReader>[0],
) => ReturnType<ReturnType<typeof createRequesterDescendantReader>>
>(async () => ({ unsettled: false, active: 0 })),
);
vi.mock("./subagent-announce.requester-settle-descendants.js", () => ({
createRequesterDescendantReader:
(params: Parameters<typeof createRequesterDescendantReader>[0]) => () =>
readDescendantFacts(params),
}));
const startTurn = vi.hoisted(() => vi.fn());
const deliver = vi.hoisted(() => vi.fn());
const registryRead = vi.hoisted(() => ({
countPendingDescendantRuns: vi.fn<typeof countPendingDescendantRuns>(
async (_key, assertCurrent) => {
assertCurrent();
return 0;
},
),
getLatestLiveSubagentRunByChildSessionKey: vi.fn<
(
sessionKey: string,
matches?: (entry: SubagentRunRecord) => boolean,
) => SubagentRunRecord | undefined
>(() => undefined),
listSubagentRunsForRequester: vi.fn<() => SubagentRunRecord[]>(() => []),
getLatestSubagentRunByChildSessionKey: vi.fn(() => undefined),
}));
vi.mock("../../../gateway/server-methods.js", () => ({
authorizeGatewayRequestPreDispatch: async () => ({ error: null }),
createRequestGatewayMethodRegistry: () => ({ isControlPlaneWrite: () => false }),
runWithGatewayRequestEnvelope: async (
_method: string,
_client: unknown,
run: () => Promise<unknown>,
) => await run(),
}));
vi.mock("../../../gateway/agent-turn/agent-request-preflight.js", () => ({
prepareAgentRequestPreflight: ({ request }: { request: unknown }) => ({ request }),
}));
vi.mock("../../../gateway/agent-turn/agent-turn-service.js", () => ({
createAgentTurnService: () => ({ startTurn, waitForTurn: vi.fn() }),
}));
vi.mock("../registry/subagent-registry-read.js", () => registryRead);
vi.mock("../spawn/subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: (sessionKey: string) =>
sessionKey.split(":subagent:").length - 1,
}));
vi.mock("./subagent-announce.js", () => ({ hasUsableSessionEntry: () => true }));
vi.mock("./subagent-announce-delivery.js", () => ({
deliverSubagentAnnouncement: (...args: unknown[]) => deliver(...args),
loadRequesterSessionEntry: () => ({
canonicalKey: "agent:main:main",
entry: { sessionId: "requester-session" },
}),
}));
export { readDescendantFacts, startTurn, deliver, registryRead };

View file

@ -1,6 +1,43 @@
import { afterEach, beforeEach, vi } from "vitest";
import { resetCommandQueueStateForTest } from "../../../process/command-queue.test-support.js";
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import * as announceOutput from "./subagent-announce-output.js";
import { setSubagentAnnounceDeliveryDepsForTest } from "./subagent-announce-overrides.test-support.js";
import {
deliver,
registryRead,
startTurn,
readDescendantFacts,
} from "./subagent-announce.requester-settle-dispatch-mocks.test-support.js";
import type { RequesterSettleWakeBatchCallbacks } from "./subagent-announce.requester-settle-state.js";
export { deliver, registryRead, startTurn, readDescendantFacts };
const readChildCompletionFindings = announceOutput.readChildCompletionFindings;
export function useRequesterSettleDispatchFixture() {
beforeEach(() => {
vi.spyOn(announceOutput, "readChildCompletionFindings").mockImplementation((children) =>
readChildCompletionFindings(children, (runId) =>
registryRead.listSubagentRunsForRequester().find((entry) => entry.runId === runId),
),
);
resetCommandQueueStateForTest();
startTurn.mockReset();
deliver.mockReset();
readDescendantFacts.mockReset().mockResolvedValue({ unsettled: false, active: 0 });
registryRead.getLatestLiveSubagentRunByChildSessionKey.mockReset().mockReturnValue(undefined);
registryRead.getLatestSubagentRunByChildSessionKey.mockReset().mockReturnValue(undefined);
});
afterEach(() => {
vi.mocked(announceOutput.readChildCompletionFindings).mockRestore();
resetCommandQueueStateForTest();
setSubagentAnnounceDeliveryDepsForTest();
vi.useRealTimers();
});
}
export const REQUESTER_KEY = "agent:main:main";
export const publishWakeTransition: RequesterSettleWakeBatchCallbacks["transitionBatch"] = (

View file

@ -1,5 +1,6 @@
import "./subagent-announce.requester-settle-dispatch-mocks.test-support.js";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js";
import {
loadSessionEntry,
@ -19,7 +20,6 @@ import {
withPluginRuntimeGatewayRequestScope,
} from "../../../plugins/runtime/gateway-request-scope.js";
import { enqueueCommandInLane, getCommandLaneSnapshot } from "../../../process/command-queue.js";
import { resetCommandQueueStateForTest } from "../../../process/command-queue.test-support.js";
import { beginSessionWorkAdmission } from "../../../sessions/session-lifecycle-admission.js";
import { trackAsyncWork } from "../../../shared/async-work-scope.js";
import { createDeferredCore } from "../../../shared/deferred.js";
@ -32,89 +32,23 @@ import type { RunEmbeddedAgentParams } from "../../embedded-agent-runner/run/par
import { MAIN_SESSION_RECOVERY_WORK_ADMISSION_OWNER } from "../../main-session-recovery/main-session-recovery-admission.js";
import { resolveAgentTimeoutMs } from "../../timeout.js";
import { consumeSubagentPauseNotice } from "../registry/subagent-delivery-state.js";
import type { countPendingDescendantRuns } from "../registry/subagent-registry-read.js";
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import {
registerRequesterFinalAttachment,
promoteRequesterFinalAttachment,
} from "../requester-final-attachment.js";
import { sendSubagentAnnounceDirectly } from "./subagent-announce-direct-delivery.js";
import * as announceOutput from "./subagent-announce-output.js";
import { setSubagentAnnounceDeliveryDepsForTest } from "./subagent-announce-overrides.test-support.js";
import type { createRequesterDescendantReader } from "./subagent-announce.requester-settle-descendants.js";
import {
deliver,
registryRead,
startTurn,
REQUESTER_KEY,
settledChild,
publishWakeTransition,
useRequesterSettleDispatchFixture,
} from "./subagent-announce.requester-settle-dispatch.test-support.js";
const readDescendantFacts = vi.hoisted(() =>
vi.fn<
(
params: Parameters<typeof createRequesterDescendantReader>[0],
) => ReturnType<ReturnType<typeof createRequesterDescendantReader>>
>(async () => ({ unsettled: false, active: 0 })),
);
vi.mock("./subagent-announce.requester-settle-descendants.js", () => ({
createRequesterDescendantReader:
(params: Parameters<typeof createRequesterDescendantReader>[0]) => () =>
readDescendantFacts(params),
}));
const startTurn = vi.hoisted(() => vi.fn());
const deliver = vi.hoisted(() => vi.fn());
const registryRead = vi.hoisted(() => ({
countPendingDescendantRuns: vi.fn<typeof countPendingDescendantRuns>(
async (_key, assertCurrent) => {
assertCurrent();
return 0;
},
),
getLatestLiveSubagentRunByChildSessionKey: vi.fn<
(
sessionKey: string,
matches?: (entry: SubagentRunRecord) => boolean,
) => SubagentRunRecord | undefined
>(() => undefined),
listSubagentRunsForRequester: vi.fn<() => SubagentRunRecord[]>(() => []),
getLatestSubagentRunByChildSessionKey: vi.fn(() => undefined),
}));
vi.mock("../../../gateway/server-methods.js", () => ({
authorizeGatewayRequestPreDispatch: async () => ({ error: null }),
createRequestGatewayMethodRegistry: () => ({ isControlPlaneWrite: () => false }),
runWithGatewayRequestEnvelope: async (
_method: string,
_client: unknown,
run: () => Promise<unknown>,
) => await run(),
}));
vi.mock("../../../gateway/agent-turn/agent-request-preflight.js", () => ({
prepareAgentRequestPreflight: ({ request }: { request: unknown }) => ({ request }),
}));
vi.mock("../../../gateway/agent-turn/agent-turn-service.js", () => ({
createAgentTurnService: () => ({ startTurn, waitForTurn: vi.fn() }),
}));
vi.mock("../registry/subagent-registry-read.js", () => registryRead);
vi.mock("../spawn/subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: (sessionKey: string) =>
sessionKey.split(":subagent:").length - 1,
}));
vi.mock("./subagent-announce.js", () => ({ hasUsableSessionEntry: () => true }));
vi.mock("./subagent-announce-delivery.js", () => ({
deliverSubagentAnnouncement: (...args: unknown[]) => deliver(...args),
loadRequesterSessionEntry: () => ({
canonicalKey: "agent:main:main",
entry: { sessionId: "requester-session" },
}),
}));
import { maybeWakeRequesterAfterAllChildrenSettled } from "./subagent-announce.requester-settle-wake.js";
const readChildCompletionFindings = announceOutput.readChildCompletionFindings;
const tempDirs = useAutoCleanupTempDirTracker((cleanup) =>
afterEach(async () => {
@ -154,26 +88,7 @@ function createContext(): GatewayRequestContext {
}
describe("requester settle dispatch deadline", () => {
beforeEach(() => {
vi.spyOn(announceOutput, "readChildCompletionFindings").mockImplementation((children) =>
readChildCompletionFindings(children, (runId) =>
registryRead.listSubagentRunsForRequester().find((entry) => entry.runId === runId),
),
);
resetCommandQueueStateForTest();
startTurn.mockReset();
deliver.mockReset();
readDescendantFacts.mockReset().mockResolvedValue({ unsettled: false, active: 0 });
registryRead.getLatestLiveSubagentRunByChildSessionKey.mockReset().mockReturnValue(undefined);
registryRead.getLatestSubagentRunByChildSessionKey.mockReset().mockReturnValue(undefined);
});
afterEach(() => {
vi.mocked(announceOutput.readChildCompletionFindings).mockRestore();
resetCommandQueueStateForTest();
setSubagentAnnounceDeliveryDepsForTest();
vi.useRealTimers();
});
useRequesterSettleDispatchFixture();
it.each([false, true])(
"wakes a nested yielded requester once (child completed before yield=%s)",

View file

@ -0,0 +1,98 @@
import type {
CapturedSessionEntryCurrentRead,
SessionEntryCurrentFacts,
} from "../../../config/sessions/session-entry-current.types.js";
import { captureSystemEventStoreCurrentCheck } from "../../../infra/system-event-ownership.js";
import { isIncognitoSessionKey } from "../../../routing/session-key.js";
import { evaluateGatewayToolCallerReceiptAdmission } from "../../tools/gateway-caller-context.js";
import type { GatewayToolCallerReceiptAdmission } from "../../tools/gateway-caller-receipt.types.js";
/** Watch effects retain their original stores; the durable wake keeps its ordinary handoff rules. */
export function createRequesterSettleReceiptAdmission(params: {
requester: { canonicalKey: string; agentId?: string; storePath?: string };
identity: { sessionId: string; lifecycleRevision?: string };
storeSessionKey: string;
storeAgentId?: string;
storePaths(): readonly (string | null | undefined)[];
isRecoveryCurrent(): boolean;
readCurrent(): SessionEntryCurrentFacts | undefined;
isStoreCurrent(): boolean;
}) {
const isEntryCurrent = (entry: SessionEntryCurrentFacts | undefined) =>
entry?.sessionId === params.identity.sessionId &&
entry.lifecycleRevision === params.identity.lifecycleRevision &&
params.isRecoveryCurrent();
const admission: GatewayToolCallerReceiptAdmission = {
async prepare() {
const storeCurrent = captureSystemEventStoreCurrentCheck(
params.storeSessionKey,
params.storeAgentId,
);
const [
{ withSessionEntryReadOnlyInWorker },
{ captureSessionEntryCurrentRead, captureNativeSessionEntryCurrentRead },
] = await Promise.all([
import("../../../config/sessions/session-entry-read-runtime.js"),
import("../../../config/sessions/session-entry-current-runtime.js"),
]);
const scope = {
sessionKey: params.requester.canonicalKey,
agentId: params.requester.agentId,
storePath: params.requester.storePath,
projection: "list" as const,
};
const bind = (
current: CapturedSessionEntryCurrentRead,
entry: SessionEntryCurrentFacts | undefined,
) => {
let currentEntry = entry;
const isCurrent = () => {
try {
current.assertSourceCurrent();
return (
params.storePaths().every(storeCurrent) &&
isEntryCurrent(current.kind === "file" ? currentEntry : current.readCurrent())
);
} catch {
return false;
}
};
return {
current: {
sources: current.kind === "file" ? [current.source] : [],
assertCurrent(entries: readonly (SessionEntryCurrentFacts | undefined)[]) {
if (current.kind === "file") {
currentEntry = entries[0];
}
if (!isCurrent()) {
throw new Error("Requester settle authority changed during watch admission");
}
},
},
isCurrent,
};
};
if (isIncognitoSessionKey(scope.sessionKey)) {
return bind(captureNativeSessionEntryCurrentRead(scope), undefined);
}
return withSessionEntryReadOnlyInWorker(
scope,
() => {},
async (read, owner) => {
if (!read.ok) {
throw read.error;
}
return bind(captureSessionEntryCurrentRead(scope, owner), read.value);
},
);
},
};
return Object.assign(admission, {
isRequesterCurrent: () =>
evaluateGatewayToolCallerReceiptAdmission(admission, () =>
isEntryCurrent(params.readCurrent()),
),
isStoreCurrent: () =>
evaluateGatewayToolCallerReceiptAdmission(admission, () => params.isStoreCurrent()),
});
}

View file

@ -580,10 +580,11 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
},
});
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([staleChild, ...children]);
registryRuntimeMock.getLatestSubagentRunByChildSessionKey.mockImplementation((sessionKey) =>
sessionKey === staleChild.childSessionKey
? { runId: "run-replacement", requesterSessionKey: "agent:other:main" }
: undefined,
registryRuntimeMock.getLatestLiveSubagentRunByChildSessionKey.mockImplementation(
(sessionKey) =>
sessionKey === staleChild.childSessionKey
? { ...staleChild, runId: "run-replacement", requesterSessionKey: "agent:other:main" }
: undefined,
);
expect(

View file

@ -1,9 +1,4 @@
/**
* Durable requester settle wake delivery.
*
* Lifecycle owns the persisted outbox state on retained subagent run rows;
* this module selects a drained wave and delivers its synthesized wake.
*/
/** Deliver drained requester waves; lifecycle owns their persisted outbox on retained run rows. */
import { getRuntimeConfig } from "../../../config/config.js";
import { isSystemEventStoreCurrent } from "../../../infra/system-event-ownership.js";
import { logWarn } from "../../../logger.js";
@ -29,14 +24,12 @@ import {
} from "../registry/subagent-registry-queries.js";
import {
getLatestLiveSubagentRunByChildSessionKey,
getLatestSubagentRunByChildSessionKey,
listSubagentRunsForRequester,
} from "../registry/subagent-registry-read.js";
import type { SubagentRunRecord } from "../registry/subagent-registry.types.js";
import {
buildRequesterSettleWakeIdentity,
hasRequesterCompletionCohort,
isRequesterCompletionCohortCurrent,
resolveCurrentRequesterSettleWakeBatch,
} from "../registry/subagent-requester-settle-identity.js";
import { isSameSubagentRunOwner } from "../registry/subagent-run-generation.js";
@ -54,14 +47,12 @@ import {
} from "./subagent-announce-delivery.js";
import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
import {
dedupeLatestChildCompletionRows,
filterCurrentDirectChildCompletionRows,
readChildCompletionFindings,
} from "./subagent-announce-output.js";
import { readChildCompletionFindings } from "./subagent-announce-output.js";
import { hasUsableSessionEntry } from "./subagent-announce.js";
import { selectCurrentRequesterCompletionRows } from "./subagent-announce.requester-settle-cohort.js";
import { createRequesterDescendantReader } from "./subagent-announce.requester-settle-descendants.js";
import { buildRequesterSettleWakeMessage } from "./subagent-announce.requester-settle-message.js";
import { createRequesterSettleReceiptAdmission } from "./subagent-announce.requester-settle-receipt.js";
import {
readSharedBatchState,
createRequesterSettleBatchClaim,
@ -77,11 +68,7 @@ const REQUESTER_SETTLE_WAKE_MAX_AMBIGUOUS_REPLAYS = 3;
const REQUESTER_SETTLE_WAKE_MAX_DEFERRALS = 10;
const REQUESTER_SETTLE_WAKE_RETRY_DELAYS_MS = [30_000, 120_000] as const;
/**
* Wakes a top-level or explicitly yielded nested requester once its batch's last
* child and descendants settle. Await lifecycle-owned durable state transitions
* before and after every delivery.
*/
/** Wake top-level or yielded requesters after their descendants settle; lifecycle owns transitions. */
export async function maybeWakeRequesterAfterAllChildrenSettled(
params: RequesterSettleWakeBatchCallbacks & {
requesterSessionKey: string;
@ -213,17 +200,13 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
const batchRunIds = settledBatch.map((entry) => entry.runId).toSorted();
const batchSessionKeys = [...new Set(settledBatch.map((run) => run.childSessionKey))].toSorted();
const currentCompletionRows = (rows: SubagentRunRecord[]) =>
frozenBatchRunIds?.length
? rows.filter((entry) =>
isRequesterCompletionCohortCurrent(entry, getLatestLiveSubagentRunByChildSessionKey),
)
: dedupeLatestChildCompletionRows(
filterCurrentDirectChildCompletionRows(rows, {
requesterSessionKey,
requesterAgentId,
getLatestSubagentRunByChildSessionKey,
}),
);
selectCurrentRequesterCompletionRows({
rows,
requesterSessionKey,
requesterAgentId,
frozenBatch: Boolean(frozenBatchRunIds?.length),
latestForSession: getLatestLiveSubagentRunByChildSessionKey,
});
const readCurrentBatch = (requireUnchangedProgress = false) =>
resolveCurrentRequesterSettleWakeBatch({
observed: settledBatch,
@ -428,10 +411,8 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
return false;
}
const { entry: requesterEntry } = loadRequesterSessionEntry(
requesterSessionKey,
requesterAgentId,
);
const requester = loadRequesterSessionEntry(requesterSessionKey, requesterAgentId);
const requesterEntry = requester.entry;
if (!hasUsableSessionEntry(requesterEntry)) {
await completeBatch(settledBatch, selectedState, {
delivered: false,
@ -549,16 +530,19 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
sharedAttemptKey: parentOnly,
pause: Boolean(pauseNotice),
});
const isRequesterSessionCurrent = () => {
const currentSession = loadRequesterSessionEntry(requesterSessionKey, requesterAgentId).entry;
return (
currentSession?.sessionId === requesterIdentity.sessionId &&
currentSession?.lifecycleRevision === requesterIdentity.lifecycleRevision &&
recoveryRows.every((entry) => matchesSubagentRequesterSession(entry, requesterIdentity))
);
};
const sourceReceiptAdmission = createRequesterSettleReceiptAdmission({
requester,
identity: requesterIdentity,
storeSessionKey: requesterSessionKey,
storeAgentId: requesterAgentId,
storePaths: () => settledBatch.map((entry) => entry.requesterStorePath),
isRecoveryCurrent: () =>
recoveryRows.every((entry) => matchesSubagentRequesterSession(entry, requesterIdentity)),
readCurrent: () => loadRequesterSessionEntry(requesterSessionKey, requesterAgentId).entry,
isStoreCurrent,
});
const isRequesterCurrent = () => {
if (!isRequesterSessionCurrent()) {
if (!sourceReceiptAdmission.isRequesterCurrent()) {
return false;
}
if (followup) {
@ -574,7 +558,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
const isSourceSessionEffectsAllowed = () =>
!params.signal?.aborted &&
params.isSourceCurrent() &&
isStoreCurrent() &&
sourceReceiptAdmission.isStoreCurrent() &&
preparedFindings.isCurrent() &&
!isGatewayClosed() &&
isBatchCurrent() &&
@ -585,7 +569,11 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
return true;
}
if (isBatchDeliveryClosed() || !isRequesterCurrent()) {
if (pauseNotice && !isBatchDeliveryClosed() && isRequesterSessionCurrent()) {
if (
pauseNotice &&
!isBatchDeliveryClosed() &&
sourceReceiptAdmission.isRequesterCurrent()
) {
// Requester turnover revokes this attempt, not the child's need for direction.
await deferBatch(knownUndelivered ? { status: "pending" } : {}, false);
return true;
@ -645,6 +633,7 @@ export async function maybeWakeRequesterAfterAllChildrenSettled(
signal: params.signal,
resolveGatewayContext,
isSourceSessionEffectsAllowed,
sourceReceiptAdmission,
}),
),
);

View file

@ -0,0 +1,190 @@
import "./subagent-announce.requester-settle-dispatch-mocks.test-support.js";
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import {
loadSessionEntry,
replaceSessionEntry,
} from "../../../config/sessions/session-accessor.js";
import { resolvePhysicalSessionStorePath } from "../../../config/sessions/session-store-path.js";
import * as workerAdmission from "../../../infra/sqlite-worker-operation-admission.js";
import { publishSystemEventStoreResolver } from "../../../infra/system-event-ownership.js";
import { registerSessionStateWatch } from "../../../sessions/session-state-events.js";
import { openOpenClawStateDatabase } from "../../../state/openclaw-state-db.js";
import { observeMainThreadSql } from "../../../test-utils/main-thread-sql-spies.test-support.js";
import { createOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import {
createOperationalRunInstanceRef,
prepareAgentRunAdmission,
} from "../../admitted-run-context.js";
import {
createAdmittedGatewayToolCallerIdentity,
prepareGatewayToolCallerAssertion,
withGatewayToolCallerIdentity,
} from "../../tools/gateway-caller-context.js";
import * as announceDelivery from "./subagent-announce-delivery.js";
import type { sendSubagentAnnounceDirectly } from "./subagent-announce-direct-delivery.js";
import { setSubagentAnnounceDeliveryDepsForTest } from "./subagent-announce-overrides.test-support.js";
import {
deliver,
registryRead,
readDescendantFacts,
REQUESTER_KEY,
settledChild,
publishWakeTransition,
useRequesterSettleDispatchFixture,
} from "./subagent-announce.requester-settle-dispatch.test-support.js";
import { maybeWakeRequesterAfterAllChildrenSettled } from "./subagent-announce.requester-settle-wake.js";
describe("requester settle watch admission", () => {
useRequesterSettleDispatchFixture();
it.each(["current", "foreign reset", "same-store handoff"] as const)(
"fences a settled requester's worker watch without retiring its wake (%s)",
async (change) => {
const resetRequester = change === "foreign reset";
const sameStoreHandoff = change === "same-store handoff";
const state = await createOpenClawTestState({
prefix: "settle-watch-",
layout: "state-only",
});
onTestFinished(() => state.cleanup());
const storePath = state.statePath("requester-watch.sqlite");
const target = { agentId: "main", sessionKey: REQUESTER_KEY, storePath };
const cfg = { session: { store: storePath } };
await replaceSessionEntry(target, {
sessionId: "requester-session",
lifecycleRevision: "requester-revision",
updatedAt: 100,
});
setSubagentAnnounceDeliveryDepsForTest({ getRuntimeConfig: () => cfg });
const requesterRead = vi
.spyOn(announceDelivery, "loadRequesterSessionEntry")
.mockImplementation(() => ({
cfg,
storePath,
canonicalKey: REQUESTER_KEY,
agentId: "main",
entry: loadSessionEntry(target),
}));
onTestFinished(() => requesterRead.mockRestore());
const child = settledChild();
if (sameStoreHandoff) {
child.requesterStorePath = storePath;
publishSystemEventStoreResolver(() => storePath);
onTestFinished(() => publishSystemEventStoreResolver(undefined));
readDescendantFacts.mockImplementationOnce(async () => {
// A same-store handoff while the wake prepares must not consume its obligation.
publishSystemEventStoreResolver(() => storePath);
return { unsettled: false, active: 0 };
});
}
registryRead.listSubagentRunsForRequester.mockReturnValue([child]);
const peer = resetRequester
? new DatabaseSync(resolvePhysicalSessionStorePath(target))
: undefined;
onTestFinished(() => peer?.close());
const admission = prepareAgentRunAdmission({
cfg,
operationalRunInstance: createOperationalRunInstanceRef("settle-watch"),
facts: {
runId: "settle-watch",
agentId: "main",
ingress: { kind: "system", boundary: "settle-watch-test", state: "present" },
},
});
onTestFinished(() => admission.close());
const admittedRunContext = await admission.admit("gateway");
let watched: boolean | undefined;
let sqlCount: number | undefined;
let witnessed = false;
const watchTarget = "agent:main:dashboard:settle-watch-target";
deliver.mockImplementation(
async (params: Parameters<typeof sendSubagentAnnounceDirectly>[0]) => {
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
const interception =
peer || sameStoreHandoff
? vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission((request, grant) => {
if (
request.stage === "commit" &&
!witnessed &&
request.facts !== null &&
typeof request.facts === "object" &&
"kind" in request.facts &&
request.facts.kind === "session-entry-current"
) {
witnessed = true;
if (peer) {
// Independent native writer changes the requester after the worker's read.
peer
.prepare(
"UPDATE session_nodes SET entry_json = json_set(entry_json, '$.lifecycleRevision', ?) WHERE session_key = ?",
)
.run("replaced-requester-revision", REQUESTER_KEY);
} else {
publishSystemEventStoreResolver(() => storePath);
}
}
admit(request, grant);
}, attachment),
)
: undefined;
const caller = createAdmittedGatewayToolCallerIdentity({
admittedRunContext,
agentId: "main",
sessionKey: REQUESTER_KEY,
receiptAuthority: params.isSourceSessionEffectsAllowed,
receiptAdmission: params.sourceReceiptAdmission,
});
const sql = peer ? undefined : observeMainThreadSql();
try {
sql?.calibrate();
watched = await withGatewayToolCallerIdentity(caller, () =>
registerSessionStateWatch(
{ watcherSessionKey: REQUESTER_KEY, targetSessionKey: watchTarget },
{ prepareCurrent: prepareGatewayToolCallerAssertion },
),
);
sqlCount = sql?.count();
} finally {
sql?.restore();
interception?.mockRestore();
}
return { delivered: true, path: "direct" };
},
);
const completeBatch = vi.fn<
Parameters<typeof maybeWakeRequesterAfterAllChildrenSettled>[0]["completeBatch"]
>(() => {
child.requesterSettleWake = undefined;
});
await expect(
maybeWakeRequesterAfterAllChildrenSettled({
requesterSessionKey: REQUESTER_KEY,
settledEntry: child,
isSourceCurrent: () => true,
transitionBatch: publishWakeTransition,
completeBatch,
}),
).resolves.toBe(true);
expect(deliver).toHaveBeenCalledOnce();
expect(completeBatch).toHaveBeenCalledOnce();
expect(completeBatch.mock.calls[0]?.[2]).toMatchObject({ delivered: true });
expect(watched).toBe(change === "current");
if (change !== "current") {
expect(witnessed).toBe(true);
}
if (!resetRequester) {
expect(sqlCount).toBe(0);
}
const cursor = openOpenClawStateDatabase()
.db.prepare(
"SELECT target_session_key FROM session_watch_cursors WHERE watcher_session_key = ? AND target_session_key = ?",
)
.get(REQUESTER_KEY, watchTarget);
expect(Boolean(cursor)).toBe(change === "current");
},
);
});

View file

@ -125,11 +125,13 @@ export function registerTerminalStateSignalAuthorityTests({
await expect(completion).rejects.toMatchObject({ outcome: "not-committed" });
}
expect(observed).toBe(true);
const events = sessionStateEvents.listSessionStateEventsSince(
entry.childSessionKey,
"main",
0,
200,
const events = (
await sessionStateEvents.listSessionStateEventsSince(
entry.childSessionKey,
"main",
0,
200,
)
).events;
const stored = loadSubagentRegistryFromSqlite().get(entry.runId);
if (change === "none" || serializedSuccessor) {

View file

@ -3,6 +3,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { asNonArrayRecord } from "@openclaw/normalization-core/record-coerce";
import type { ExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import type { ReplyTurnParticipants } from "../../auto-reply/reply/reply-run-registry.contracts.js";
import type { SessionEntriesCurrentCheck } from "../../config/sessions/session-entry-current.types.js";
import type { AgentRuntimeIdentity } from "../../gateway/agent-runtime-identity-token.js";
import type { CronCreatorAuthorityGrant } from "../../gateway/cron-creator-authority-grant.types.js";
import type {
@ -37,6 +38,7 @@ import {
getInternalToolExecutionPreparer,
} from "../runtime/internal-hooks.js";
import { readToolStringParam, type AnyAgentTool } from "./common.js";
import type { GatewayToolCallerReceiptAdmission } from "./gateway-caller-receipt.types.js";
type GatewayToolCallerIdentity = {
personalToolParticipants?: ReplyTurnParticipants;
@ -64,6 +66,7 @@ type GatewayToolCallerIdentity = {
executionIdentityToken?: ExecutionIdentityAdmissionToken;
/** Synchronous host-owned fence for tool effects and decision receipts. */
receiptAuthority?: () => boolean | void;
receiptAdmissions?: readonly GatewayToolCallerReceiptAdmission[];
/** Captured conversation policy for tools delegated through another tool's transport. */
assertToolAllowed?: (toolName: string) => void;
/** Exact Gateway-owned worker claim; never sourced from model or RPC arguments. */
@ -106,6 +109,18 @@ type GatewayToolCallerSource = {
const gatewayToolCallerStorage = new AsyncLocalStorage<GatewayToolCallerIdentity>();
const receiptAdmissionStorage = new AsyncLocalStorage<
ReadonlyMap<GatewayToolCallerReceiptAdmission, () => boolean>
>();
export function evaluateGatewayToolCallerReceiptAdmission(
admission: GatewayToolCallerReceiptAdmission,
otherwise: () => boolean,
): boolean {
const prepared = receiptAdmissionStorage.getStore()?.get(admission);
return prepared ? prepared() : otherwise();
}
export type GatewayToolOperatorSelection = Readonly<{
/** Raw host-issued source; custody transfers must not retain the turn-bound assertion. */
operatorAuthority?: AdmittedRunOperatorAuthority;
@ -143,6 +158,7 @@ function bindGatewayToolContextResolver(
type AdmittedGatewayToolCallerParams = {
admittedRunContext: AdmittedRunContext;
receiptAuthority?: () => boolean | void;
receiptAdmission?: GatewayToolCallerReceiptAdmission;
cronAuthorityCheck?: () => boolean;
mintCronRequesterGrant?: GatewayToolCallerIdentity["mintCronRequesterGrant"];
approvalSignals?: readonly AbortSignal[];
@ -206,6 +222,7 @@ export function createAdmittedGatewayToolCallerIdentity(
getAdmittedRunDelegatedAuthority(params.admittedRunContext) === delegatedAuthority,
params.receiptAuthority,
),
...(params.receiptAdmission ? { receiptAdmissions: [params.receiptAdmission] } : {}),
...(params.approvalSignals?.length ? { approvalSignals: params.approvalSignals } : {}),
...(params.mintCronRequesterGrant
? { mintCronRequesterGrant: params.mintCronRequesterGrant }
@ -356,6 +373,53 @@ export function captureGatewayToolCallerAssertion(): ((method?: string) => void)
};
}
/** Watch admission preserves opaque lifecycle assertions while moving registered row predicates. */
export async function prepareGatewayToolCallerAssertion(): Promise<{
assertCurrent?: () => void;
sessionEntriesCurrent?: SessionEntriesCurrentCheck;
release(): void;
}> {
const caller = getGatewayToolCallerIdentity();
const assertion = captureGatewayToolCallerAssertion();
const admissions = [...new Set(caller?.receiptAdmissions ?? [])];
const prepared = await Promise.all(admissions.map((admission) => admission.prepare()));
const predicates = new Map(
admissions.map((admission, index) => [admission, () => prepared[index]!.isCurrent()] as const),
);
let active = true;
const assertCurrent = () => {
if (!active) {
throw new Error("agent tool caller admission is no longer active");
}
receiptAdmissionStorage.run(predicates, () => assertion?.());
};
assertCurrent();
return {
assertCurrent,
...(prepared.length
? {
sessionEntriesCurrent: {
sources: prepared.flatMap((entry) => entry.current.sources),
assertCurrent(entries) {
let offset = 0;
for (const entry of prepared) {
entry.current.assertCurrent(
entries.slice(offset, offset + entry.current.sources.length),
);
offset += entry.current.sources.length;
}
assertCurrent();
},
},
}
: {}),
release() {
active = false;
predicates.clear();
},
};
}
/** Process-owned work must not retain the turn that authorized its launch. */
export function withoutGatewayToolCallerIdentity<T>(run: () => T): T {
return gatewayToolCallerStorage.exit(run);
@ -417,6 +481,12 @@ export async function withGatewayToolCallerIdentity<T>(
inheritedOwner?.receiptAuthority,
identity.receiptAuthority,
);
const receiptAdmissions = [
...new Set([
...(inheritedOwner?.receiptAdmissions ?? []),
...(identity.receiptAdmissions ?? []),
]),
];
const toolPolicyAssertions = [
...new Set(
[inheritedOwner?.assertToolAllowed, identity.assertToolAllowed].filter(
@ -495,6 +565,7 @@ export async function withGatewayToolCallerIdentity<T>(
...(cronAuthorityCheck ? { cronAuthorityCheck } : {}),
...(executionIdentityToken ? { executionIdentityToken } : {}),
...(receiptAuthority ? { receiptAuthority } : {}),
...(receiptAdmissions.length ? { receiptAdmissions } : {}),
...(assertToolAllowed ? { assertToolAllowed } : {}),
...(approvalSignals.length ? { approvalSignals } : {}),
...(workerTurnClaim ? { workerTurnClaim } : {}),

View file

@ -0,0 +1,9 @@
import type { SessionEntriesCurrentCheck } from "../../config/sessions/session-entry-current.types.js";
/** A storage predicate supplies worker facts without replacing its caller's lifetime fences. */
export type GatewayToolCallerReceiptAdmission = {
prepare(): Promise<{
current: SessionEntriesCurrentCheck;
isCurrent(): boolean;
}>;
};

View file

@ -0,0 +1,55 @@
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import type { SessionStateReadOperations } from "../../sessions/session-state-events.read.worker-contract.js";
function compactSessionStateEventPayload(
payload: Record<string, unknown> | undefined,
): { outcome?: "error" | "timeout" | "cancelled"; channel?: string; turns?: number } | undefined {
if (!payload) {
return undefined;
}
const outcome =
payload.outcome === "error" || payload.outcome === "timeout" || payload.outcome === "cancelled"
? payload.outcome
: undefined;
const channel = readStringValue(payload.channel);
const turns = asPositiveSafeInteger(payload.turns);
return outcome || channel || turns !== undefined
? {
...(outcome ? { outcome } : {}),
...(channel ? { channel } : {}),
...(turns !== undefined ? { turns } : {}),
}
: undefined;
}
export function compactSessionStateChanges(
stateChanges: SessionStateReadOperations["sessionState.events"]["output"]["page"],
) {
return {
...stateChanges,
events: stateChanges.events.map((event) => {
const payload = compactSessionStateEventPayload(event.payload);
return {
sequence: event.sequence,
kind: event.kind,
actorType: event.actorType,
occurredAt: event.occurredAt,
summary: event.summary,
...(event.actorId ? { actorId: event.actorId } : {}),
...(event.runId ? { runId: event.runId } : {}),
...(payload ? { payload } : {}),
};
}),
};
}
export function formatSessionStateChanges(details: {
stateVersion: number;
stateChanges: ReturnType<typeof compactSessionStateChanges>;
}): string {
return `Session state changes:
\`\`\`json
${JSON.stringify(details, null, 2)}
\`\`\``;
}

View file

@ -1,4 +1,3 @@
import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import type {
ElevatedLevel,
@ -66,6 +65,10 @@ import {
resolveSessionStatusEntry,
resolveStoreScopedRequesterKey,
} from "./session-status-session-resolve.js";
import {
compactSessionStateChanges,
formatSessionStateChanges,
} from "./session-status-state-changes.js";
import {
SessionStatusOutputSchema,
SessionStatusToolSchema,
@ -83,46 +86,6 @@ import {
shouldResolveSessionIdInput,
} from "./sessions-helpers.js";
function compactSessionStateEventPayload(
payload: Record<string, unknown> | undefined,
): { outcome?: "error" | "timeout" | "cancelled"; channel?: string; turns?: number } | undefined {
if (!payload) {
return undefined;
}
const outcome =
payload.outcome === "error" || payload.outcome === "timeout" || payload.outcome === "cancelled"
? payload.outcome
: undefined;
const channel = readStringValue(payload.channel);
const turns = asPositiveSafeInteger(payload.turns);
return outcome || channel || turns !== undefined
? {
...(outcome ? { outcome } : {}),
...(channel ? { channel } : {}),
...(turns !== undefined ? { turns } : {}),
}
: undefined;
}
function compactSessionStateChanges(stateChanges: ReturnType<typeof listSessionStateEventsSince>) {
return {
...stateChanges,
events: stateChanges.events.map((event) => {
const payload = compactSessionStateEventPayload(event.payload);
return {
sequence: event.sequence,
kind: event.kind,
actorType: event.actorType,
occurredAt: event.occurredAt,
summary: event.summary,
...(event.actorId ? { actorId: event.actorId } : {}),
...(event.runId ? { runId: event.runId } : {}),
...(payload ? { payload } : {}),
};
}),
};
}
const loadCommandsStatusRuntime = createLazyPromise(() => import("../../status/status-text.js"));
type ActiveStatusModelIdentity = { provider?: string; model: string };
@ -228,16 +191,6 @@ ${JSON.stringify(details, null, 2)}
\`\`\``;
}
function formatSessionStateChanges(details: {
stateVersion: number;
stateChanges: ReturnType<typeof compactSessionStateChanges>;
}): string {
return `Session state changes:
\`\`\`json
${JSON.stringify(details, null, 2)}
\`\`\``;
}
function resolveActiveStatusModelIdentity(params: {
activeModelId?: string;
activeModelProvider?: string;
@ -774,10 +727,10 @@ export function createSessionStatusTool(opts?: {
isLiveRunSession: isLiveRouteSession,
});
const routeContextText = formatSessionStatusRouteContext(routeDetails);
const stateVersion = getSessionStateVersion(scopedResolved.key, agentId);
const stateVersion = await getSessionStateVersion(scopedResolved.key, agentId);
const rawStateChanges =
changesSince !== undefined
? listSessionStateEventsSince(scopedResolved.key, agentId, changesSince, 200)
? await listSessionStateEventsSince(scopedResolved.key, agentId, changesSince, 200)
: undefined;
const stateChanges = rawStateChanges
? compactSessionStateChanges(rawStateChanges)

View file

@ -374,7 +374,7 @@ export function createSessionsListTool(opts?: {
offset = pageNextOffset;
}
const stateVersions = getSessionStateVersions(
const stateVersions = await getSessionStateVersions(
sessions.map(({ entry, agentId: stateAgentId }) => ({
sessionKey: entry.key,
agentId: stateAgentId,

View file

@ -13,6 +13,7 @@ import type { AgentStepSession } from "./agent-step.js";
import {
captureGatewayToolCallerAssertion,
getGatewayToolCallerIdentity,
prepareGatewayToolCallerAssertion,
} from "./gateway-caller-context.js";
import { runWithGatewayToolCleanupContext } from "./in-process-gateway.js";
import { prepareSessionsSendFollowup } from "./sessions-send-followup-custody.js";
@ -276,19 +277,24 @@ export async function dispatchSessionsSendFollowup(
const targetSessionKey = start.ok
? (start.a2aSessionKey ?? params.sessionStoreTarget.canonicalKey)
: undefined;
const watched =
let watched = false;
if (
start.ok &&
options.watch &&
!params.expectedSessionId &&
replyContext.requesterSessionKey &&
targetSessionKey &&
replyContext.requesterSessionKey !== targetSessionKey
? registerSessionStateWatch({
watcherSessionKey: replyContext.requesterSessionKey,
targetSessionKey,
targetAgentId: params.sendParams.agentId,
})
: false;
) {
watched = await registerSessionStateWatch(
{
watcherSessionKey: replyContext.requesterSessionKey,
targetSessionKey,
targetAgentId: params.sendParams.agentId,
},
{ prepareCurrent: prepareGatewayToolCallerAssertion },
);
}
return {
start,
completion,

View file

@ -14,8 +14,8 @@ function stubStateDir() {
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
}
function watchGroup(sessionKey: string) {
expect(registerMainSessionGroupWatch({ sessionKey, agentId: "main" })).toBe(true);
async function watchGroup(sessionKey: string) {
expect(await registerMainSessionGroupWatch({ sessionKey, agentId: "main" })).toBe(true);
}
afterEach(() => {
@ -25,8 +25,8 @@ afterEach(() => {
describe("prepareWatchedSessionsPrompt", () => {
it("returns key-sorted watched sessions with store-derived titles", async () => {
stubStateDir();
watchGroup("agent:main:telegram:group:beta");
watchGroup("agent:main:telegram:group:alpha:topic:7");
await watchGroup("agent:main:telegram:group:beta");
await watchGroup("agent:main:telegram:group:alpha:topic:7");
await upsertSessionEntryCore(
{ sessionKey: "agent:main:telegram:group:beta" },
{ sessionId: "session-beta", displayName: "Family group", updatedAt: 1 },
@ -49,10 +49,10 @@ describe("prepareWatchedSessionsPrompt", () => {
});
});
it("caps rendered rows and reports the overflow count", () => {
it("caps rendered rows and reports the overflow count", async () => {
stubStateDir();
for (let index = 0; index < 23; index += 1) {
watchGroup(`agent:main:telegram:group:room-${String(index).padStart(2, "0")}`);
await watchGroup(`agent:main:telegram:group:room-${String(index).padStart(2, "0")}`);
}
const prepared = prepareWatchedSessionsPrompt({
@ -68,9 +68,9 @@ describe("prepareWatchedSessionsPrompt", () => {
expect(prepared?.listToolAvailable).toBe(false);
});
it("accepts capability-provided read tools regardless of casing", () => {
it("accepts capability-provided read tools regardless of casing", async () => {
stubStateDir();
watchGroup("agent:main:telegram:group:beta");
await watchGroup("agent:main:telegram:group:beta");
const prepared = prepareWatchedSessionsPrompt({
enabled: true,
@ -82,9 +82,9 @@ describe("prepareWatchedSessionsPrompt", () => {
expect(prepared?.readToolNames).toEqual(["sessions_search"]);
});
it("keeps the section for sandboxed sessions only when the clamp allows non-spawned reads", () => {
it("keeps the section for sandboxed sessions only when the clamp allows non-spawned reads", async () => {
stubStateDir();
watchGroup("agent:main:telegram:group:beta");
await watchGroup("agent:main:telegram:group:beta");
const base = {
enabled: true,
sessionKey: mainSessionKey,
@ -101,9 +101,9 @@ describe("prepareWatchedSessionsPrompt", () => {
).toHaveLength(1);
});
it("returns undefined when disabled, keyless, non-main, toolless, or unwatched", () => {
it("returns undefined when disabled, keyless, non-main, toolless, or unwatched", async () => {
stubStateDir();
watchGroup("agent:main:telegram:group:beta");
await watchGroup("agent:main:telegram:group:beta");
const base = { enabled: true, sessionKey: mainSessionKey, toolNames: sessionReadTools };
expect(prepareWatchedSessionsPrompt({ ...base, enabled: false })).toBe(undefined);
@ -120,9 +120,9 @@ describe("prepareWatchedSessionsPrompt", () => {
);
});
it("renders the harness context block plugin-owned runtimes inject per turn", () => {
it("renders the harness context block plugin-owned runtimes inject per turn", async () => {
stubStateDir();
watchGroup("agent:main:telegram:group:beta");
await watchGroup("agent:main:telegram:group:beta");
const block = buildWatchedSessionsHarnessContext({
sessionKey: mainSessionKey,

View file

@ -305,7 +305,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
sessionKey,
workspaceDir: state.workspaceDir,
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual(
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20)).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",

View file

@ -11,7 +11,10 @@ import {
resolveTimezone,
} from "../../infra/format-time/format-datetime.ts";
import { isExecCompletionEvent } from "../../infra/heartbeat-events-filter.js";
import { resolveSystemEventQueueKey } from "../../infra/system-event-ownership.js";
import {
isSystemEventStoreCurrent,
resolveSystemEventQueueKey,
} from "../../infra/system-event-ownership.js";
import {
consumeSelectedSystemEventEntries,
peekSystemEventEntries,
@ -104,15 +107,22 @@ export async function drainFormattedSystemEvents(params: {
(event) => !isExecCompletionEvent(event.text),
),
);
const sessionStateTargets = queued
.map((event) =>
event.contextKey ? decodeSessionStateNoticeContextKey(event.contextKey) : undefined,
)
.filter((target): target is string => target !== undefined);
if (sessionStateTargets.length > 0) {
acknowledgeSessionStateNotices(params.sessionKey, sessionStateTargets);
const sessionStateNotices = queued.flatMap((event) => {
const targetSessionKey = event.contextKey
? decodeSessionStateNoticeContextKey(event.contextKey)
: undefined;
return targetSessionKey === undefined
? []
: [{ targetSessionKey, watcherStorePath: event.sessionStorePath ?? null }];
});
if (sessionStateNotices.length > 0) {
await acknowledgeSessionStateNotices(params.sessionKey, sessionStateNotices);
}
for (const event of queued) {
// A same-store resolver handoff does not retire already-consumed events.
if (!isSystemEventStoreCurrent(params.sessionKey, event.sessionStorePath, params.agentId)) {
continue;
}
const compacted = compactSystemEvent(event);
if (!compacted) {
continue;

View file

@ -1546,13 +1546,11 @@ describe("initSessionState RawBody", () => {
},
cfg: { session: { store: storePath } } as OpenClawConfig,
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: "profile-ada",
}),
);
const { events } = await listSessionStateEventsSince(sessionKey, "main", 0, 20);
expect(events.find((event) => event.kind === "created")).toMatchObject({
actorType: "human",
actorId: "profile-ada",
});
expect(
listSessionParticipantsReadOnly({ agentId: "main", storePath }).get(sessionKey),
).toBeUndefined();

View file

@ -1135,17 +1135,16 @@ async function initSessionStateAttemptLocked(
if (createdNewEntry) {
recordSessionCreated(cfg, { sessionKey, agentId, entry: sessionEntry });
}
if (
!isSystemEvent &&
classifySessionStateActor({ inputProvenance: ctx.InputProvenance }).actorType === "human"
) {
registerMainSessionGroupWatch({
sessionKey,
agentId,
entry: sessionEntry,
mainKey,
});
}
await registerMainSessionGroupWatch({
sessionKey,
agentId,
entry: sessionEntry,
mainKey,
isSystemEvent,
inputProvenance: ctx.InputProvenance,
signal: params.signal,
});
params.signal?.throwIfAborted();
const sessionStore = committed.sessionStoreView;
const sessionEntryHandle = createReplySessionEntryHandle({
sessionEntry,

View file

@ -11,9 +11,14 @@ import {
updateSessionSharingField,
type CommittedSessionSharingFacts,
} from "./session-accessor.sqlite-sharing-acquisition.js";
import { projectSessionEntryCapabilityFacts } from "./session-entry-capability-facts.js";
import { listSessionMembersInDatabase } from "./session-sharing-store.kernel.js";
import type { SessionEntry } from "./types.js";
type IncognitoSessionSharingFacts = CommittedSessionSharingFacts & {
capability?: ReturnType<typeof projectSessionEntryCapabilityFacts>;
};
// Process-held stores cannot be reopened in a worker. Their existing writer publishes
// content-free metadata, bounded by live entries and the native database's lifetime.
const incognitoSharingEntries = resolveGlobalSingleton(
@ -22,8 +27,8 @@ const incognitoSharingEntries = resolveGlobalSingleton(
new WeakMap<
DatabaseSync,
{
entries: Map<string, CommittedSessionSharingFacts | null>;
pending: Map<string, Map<object, CommittedSessionSharingFacts | null | undefined>>;
entries: Map<string, IncognitoSessionSharingFacts | null>;
pending: Map<string, Map<object, IncognitoSessionSharingFacts | null | undefined>>;
}
>(),
);
@ -40,13 +45,13 @@ function incognitoSharingState(database: DatabaseSync) {
export function stageIncognitoSharingPublication(
database: DatabaseSync,
sessionKey: string,
current?: { facts: CommittedSessionSharingFacts | null | undefined },
current?: { facts: IncognitoSessionSharingFacts | null | undefined },
) {
const state = incognitoSharingState(database);
const token = {};
const pending =
state.pending.get(sessionKey) ??
new Map<object, CommittedSessionSharingFacts | null | undefined>();
new Map<object, IncognitoSessionSharingFacts | null | undefined>();
state.pending.set(sessionKey, pending);
let facts = current ? current.facts : state.entries.get(sessionKey);
if (!current) {
@ -66,7 +71,7 @@ export function stageIncognitoSharingPublication(
export function commitIncognitoSessionSharingFacts(
database: DatabaseSync,
sessionKey: string,
facts: CommittedSessionSharingFacts | null | undefined,
facts: IncognitoSessionSharingFacts | null | undefined,
): void {
const entries = incognitoSharingState(database).entries;
if (facts !== undefined) {
@ -108,7 +113,7 @@ export function readIncognitoSessionEntryCurrent(database: DatabaseSync, session
if (!pending?.size) {
return readCommittedIncognitoSessionSharing(database, sessionKey)?.entry;
}
let current: CommittedSessionSharingFacts | null | undefined;
let current: IncognitoSessionSharingFacts | null | undefined;
for (const facts of pending.values()) {
current = facts;
}
@ -122,13 +127,14 @@ export function publishIncognitoSessionEntryChange(
database: SessionEntryCacheDatabase & { path: string },
update: { sessionKey: string; entry?: SessionEntry },
): void {
let current: CommittedSessionSharingFacts | null | undefined;
let current: IncognitoSessionSharingFacts | null | undefined;
try {
const entry =
update.entry ?? readExactSessionEntryRow(database, update.sessionKey, "list")?.entry;
current = entry
? {
entry: projectSessionSharingEntry(entry),
capability: projectSessionEntryCapabilityFacts(entry),
membership: new Set(
listSessionMembersInDatabase(database, update.sessionKey).map(
(member) => member.identityId,

View file

@ -0,0 +1,20 @@
import type { SessionEntryCurrentFacts } from "./session-entry-current.types.js";
/** Both native publication and worker reads carry the same content-free capability facts. */
export function projectSessionEntryCapabilityFacts(entry: SessionEntryCurrentFacts) {
return {
sessionId: entry.sessionId,
spawnedBy: entry.spawnedBy,
spawnDepth: entry.spawnDepth,
completionOwnerSessionKey: entry.completionOwnerSessionKey,
subagentRole: entry.subagentRole,
subagentControlScope: entry.subagentControlScope,
inheritedToolPolicyVersion: entry.inheritedToolPolicyVersion,
inheritedToolAllow: Array.isArray(entry.inheritedToolAllow)
? [...entry.inheritedToolAllow]
: entry.inheritedToolAllow,
inheritedToolDeny: Array.isArray(entry.inheritedToolDeny)
? [...entry.inheritedToolDeny]
: entry.inheritedToolDeny,
};
}

View file

@ -3,15 +3,32 @@ import type { SqliteWorkerAdmissionRequest } from "../../infra/sqlite-worker-ope
import type {
SessionEntryCurrentCheck,
SessionEntryCurrentFacts,
SessionEntriesCurrentCheck,
} from "./session-entry-current.types.js";
function decodeSessionEntryCurrentFacts(value: unknown): SessionEntryCurrentFacts | undefined {
function decodeSessionEntryCurrentFacts(
value: unknown,
capability: boolean,
): SessionEntryCurrentFacts | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value) || typeof value.sessionId !== "string") {
throw new Error("Session currency facts have an invalid entry identity");
}
if (capability) {
return {
sessionId: value.sessionId,
spawnedBy: value.spawnedBy,
spawnDepth: value.spawnDepth,
completionOwnerSessionKey: value.completionOwnerSessionKey,
subagentRole: value.subagentRole,
subagentControlScope: value.subagentControlScope,
inheritedToolPolicyVersion: value.inheritedToolPolicyVersion,
inheritedToolAllow: value.inheritedToolAllow,
inheritedToolDeny: value.inheritedToolDeny,
};
}
const recovery = value.subagentRecovery;
if (recovery !== undefined && !isRecord(recovery)) {
throw new Error("Session currency facts have an invalid recovery projection");
@ -56,10 +73,34 @@ export function assertSessionEntryCurrentAdmission(
facts.source.path !== check.source.path ||
facts.source.databaseIdentity !== check.source.databaseIdentity ||
facts.source.databaseBirthtime !== check.source.databaseBirthtime ||
facts.source.sessionKey !== check.source.sessionKey
facts.source.sessionKey !== check.source.sessionKey ||
facts.source.sessionIdLookup !== check.source.sessionIdLookup ||
facts.source.projection !== check.source.projection
) {
throw new Error("Session currency facts differ from the captured source");
}
check.assertCurrent(decodeSessionEntryCurrentFacts(facts.entry));
check.assertCurrent(
decodeSessionEntryCurrentFacts(facts.entry, check.source.projection === "capability"),
);
return { ...request, facts: facts.domainFacts };
}
/** Unwrap the worker's nested reads before evaluating a predicate over the complete cohort. */
export function assertSessionEntriesCurrentAdmission(
request: SqliteWorkerAdmissionRequest,
check: SessionEntriesCurrentCheck | undefined,
): SqliteWorkerAdmissionRequest {
const entries: Array<SessionEntryCurrentFacts | undefined> = [];
let currentRequest = request;
for (let index = (check?.sources.length ?? 0) - 1; index >= 0; index -= 1) {
currentRequest = assertSessionEntryCurrentAdmission(currentRequest, {
source: check!.sources[index]!,
assertCurrent: (entry) => {
entries[index] = entry;
},
});
}
currentRequest = assertSessionEntryCurrentAdmission(currentRequest, undefined);
check?.assertCurrent(entries);
return currentRequest;
}

View file

@ -16,6 +16,7 @@ import {
} from "../../state/openclaw-agent-db-readonly.js";
import {
readExactSessionEntryRow,
readSessionEntryByIdInDatabase,
readSessionEntryRow,
} from "./session-accessor.sqlite-entry-read.js";
import { createSessionEntryRevisionGuard } from "./session-accessor.sqlite-entry-revision.js";
@ -23,6 +24,7 @@ import {
assertCanonicalSessionKeyWrite,
readWithCanonicalSessionAdmission,
} from "./session-canonical-key.js";
import { projectSessionEntryCapabilityFacts } from "./session-entry-capability-facts.js";
import type {
SessionEntryCurrentAdmissionFacts,
SessionEntryCurrentFacts,
@ -35,6 +37,8 @@ const currentEntryReads = new WeakMap<
{
sessionKey: string;
lookup: "exact" | "logical";
sessionIdLookup?: string;
projection?: "capability";
read: () => SessionEntryCurrentFacts | undefined;
}
>();
@ -43,6 +47,7 @@ function createCurrentEntryRead(
database: OpenClawAgentReadOnlyDatabase,
sessionKey: string,
lookup: "exact" | "logical",
options: Pick<SessionEntryCurrentSource, "sessionIdLookup" | "projection">,
) {
let entry: SessionEntryCurrentFacts | undefined;
const guard = createSessionEntryRevisionGuard(
@ -54,9 +59,18 @@ function createCurrentEntryRead(
},
() => {
const current =
lookup === "logical"
? readSessionEntryRow(database, sessionKey, "full")?.entry
: readExactSessionEntryRow(database, sessionKey, "list", "canonical")?.entry;
options.sessionIdLookup !== undefined
? readSessionEntryByIdInDatabase(database, {
sessionId: options.sessionIdLookup,
projection: "list",
})?.entry
: lookup === "logical"
? readSessionEntryRow(database, sessionKey, "full")?.entry
: readExactSessionEntryRow(database, sessionKey, "list", "canonical")?.entry;
if (options.projection === "capability") {
entry = current ? projectSessionEntryCapabilityFacts(current) : undefined;
return true;
}
entry = current
? {
sessionId: current.sessionId,
@ -90,11 +104,22 @@ export function readSessionEntryCurrentFactsInDatabase(
database: OpenClawAgentReadOnlyDatabase,
sessionKey: string,
lookup: "exact" | "logical" = "exact",
options: Pick<SessionEntryCurrentSource, "sessionIdLookup" | "projection"> = {},
): SessionEntryCurrentFacts | undefined {
assertCanonicalSessionKeyWrite(sessionKey);
let cached = currentEntryReads.get(database.db);
if (cached?.sessionKey !== sessionKey || cached.lookup !== lookup) {
cached = { sessionKey, lookup, read: createCurrentEntryRead(database, sessionKey, lookup) };
if (
cached?.sessionKey !== sessionKey ||
cached.lookup !== lookup ||
cached.sessionIdLookup !== options.sessionIdLookup ||
cached.projection !== options.projection
) {
cached = {
sessionKey,
lookup,
...options,
read: createCurrentEntryRead(database, sessionKey, lookup, options),
};
currentEntryReads.set(database.db, cached);
}
return readWithCanonicalSessionAdmission(database, cached.read);
@ -141,6 +166,7 @@ export function requestSessionEntryCurrentAdmission(
database,
source.sessionKey,
options.lookup,
source,
);
const facts: SessionEntryCurrentAdmissionFacts = {
kind: "session-entry-current",
@ -154,7 +180,7 @@ export function requestSessionEntryCurrentAdmission(
if (
!isDeepStrictEqual(
entry,
readSessionEntryCurrentFactsInDatabase(database, source.sessionKey, options.lookup),
readSessionEntryCurrentFactsInDatabase(database, source.sessionKey, options.lookup, source),
)
) {
throw new Error("Session currency changed while awaiting its native grant");
@ -173,3 +199,19 @@ export function requestSessionEntryCurrentAdmission(
throw new Error("Session currency native source is unavailable");
}
}
/** Keep every original source live through the common grant and recheck each after it settles. */
export function requestSessionEntriesCurrentAdmission(
sources: readonly SessionEntryCurrentSource[] | undefined,
request: SqliteWorkerAdmissionRequest,
): void {
const enter = (index: number, current: SqliteWorkerAdmissionRequest): void => {
const source = sources?.[index];
if (!source) {
requestSqliteWorkerOperationAdmission(current);
return;
}
requestSessionEntryCurrentAdmission(source, current, {}, (next) => enter(index + 1, next));
};
enter(0, request);
}

View file

@ -17,6 +17,39 @@ import { captureSessionStoreReadCandidate } from "./session-store-read-candidate
import { withSessionHistoryWorkerDatabase } from "./session-transcript-worker-runtime.js";
import { captureSessionTranscriptStorageEnvironment } from "./transcript-target-binding.js";
/** Process-held currency consumes its original writer's published facts, never a native query. */
export function captureNativeSessionEntryCurrentRead(
scope: SessionEntryReadScope,
): Exclude<CapturedSessionEntryCurrentRead, { kind: "file" }> {
const sessionKey = scope.sessionKey;
const agentId = scope.agentId ?? parseAgentSessionKey(sessionKey)?.agentId;
assertCanonicalSessionKeyWrite(sessionKey, agentId);
if (!agentId) {
throw new Error("Session currency requires its original agent");
}
const env = captureSessionTranscriptStorageEnvironment(scope.env ?? process.env);
const storePath = isIncognitoSessionKey(sessionKey)
? resolveIncognitoOpenClawAgentSqlitePath({ agentId, env })
: scope.storePath;
if (!storePath) {
throw new Error("Session currency requires its original incognito store");
}
const database = getOpenIncognitoAgentDatabase(agentId, storePath);
const assertSourceCurrent = () => {
if (getOpenIncognitoAgentDatabase(agentId, storePath) !== database) {
throw new Error("Session currency incognito owner changed");
}
};
return {
kind: database ? "native" : "missing",
assertSourceCurrent,
readCurrent() {
assertSourceCurrent();
return database ? readIncognitoSessionEntryCurrent(database.db, sessionKey) : undefined;
},
};
}
/** Capture during the initial admitted read; later checks acquire only finite worker custody. */
export function captureSessionEntryCurrentRead(
scope: SessionEntryReadScope,
@ -27,30 +60,7 @@ export function captureSessionEntryCurrentRead(
const agentId = scope.agentId ?? parseAgentSessionKey(sessionKey)?.agentId;
assertCanonicalSessionKeyWrite(sessionKey, agentId);
if (owner.kind === "native") {
if (!agentId) {
throw new Error("Session currency requires its original agent");
}
const env = captureSessionTranscriptStorageEnvironment(scope.env ?? process.env);
const storePath = isIncognitoSessionKey(sessionKey)
? resolveIncognitoOpenClawAgentSqlitePath({ agentId, env })
: scope.storePath;
if (!storePath) {
throw new Error("Session currency requires its original incognito store");
}
const database = getOpenIncognitoAgentDatabase(agentId, storePath);
const assertSourceCurrent = () => {
if (getOpenIncognitoAgentDatabase(agentId, storePath) !== database) {
throw new Error("Session currency incognito owner changed");
}
};
return {
kind: database ? "native" : "missing",
assertSourceCurrent,
readCurrent() {
assertSourceCurrent();
return database ? readIncognitoSessionEntryCurrent(database.db, sessionKey) : undefined;
},
};
return captureNativeSessionEntryCurrentRead(scope);
}
if (owner.kind !== "file" || !owner.scope || !owner.selectedStore) {
throw new Error("Session currency source is unavailable");

View file

@ -7,6 +7,14 @@ export type SessionEntryCurrentFacts = {
lifecycleRevision?: unknown;
lifecycleRunId?: unknown;
activeWriterRunId?: unknown;
spawnedBy?: unknown;
spawnDepth?: unknown;
completionOwnerSessionKey?: unknown;
subagentRole?: unknown;
subagentControlScope?: unknown;
inheritedToolPolicyVersion?: unknown;
inheritedToolAllow?: unknown;
inheritedToolDeny?: unknown;
subagentRecovery?: {
lastRunId?: unknown;
sessionLifecycleRunId?: unknown;
@ -17,6 +25,8 @@ export type SessionEntryCurrentSource = CapturedSessionEntryReadSource &
Readonly<{
databaseIdentity: string;
sessionKey: string;
sessionIdLookup?: string;
projection?: "capability";
}>;
/** A current-row restriction; the caller's existing admission still supplies authority. */
@ -25,6 +35,12 @@ export type SessionEntryCurrentCheck = Readonly<{
assertCurrent(facts: SessionEntryCurrentFacts | undefined): void;
}>;
/** One predicate can depend on several source-bound rows, including absent exact-key probes. */
export type SessionEntriesCurrentCheck = Readonly<{
sources: readonly SessionEntryCurrentSource[];
assertCurrent(entries: readonly (SessionEntryCurrentFacts | undefined)[]): void;
}>;
export type SessionEntryCurrentPreparation =
| { prepareCurrent?: () => Promise<boolean>; sessionEntryCurrent?: undefined }
| { prepareCurrent: () => Promise<boolean>; sessionEntryCurrent?: SessionEntryCurrentCheck };

View file

@ -8,6 +8,7 @@ import {
closeOpenClawAgentDatabasesForTest,
isOpenClawAgentDatabaseOpen,
openOpenClawAgentDatabase,
resolveIncognitoOpenClawAgentSqlitePath,
resolveOpenClawAgentSqlitePath,
runOpenClawAgentWriteTransaction,
} from "../../state/openclaw-agent-db.js";
@ -18,14 +19,67 @@ import {
upsertSessionEntryCore,
} from "./session-accessor.js";
import * as sqliteArchive from "./session-accessor.sqlite-archive.js";
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
import { readCommittedIncognitoSessionSharing } from "./session-accessor.sqlite-incognito-sharing.js";
import * as reclamation from "./session-accessor.sqlite-reclamation-run.js";
import { createSessionMaintenanceFinalizationOperation } from "./session-accessor.sqlite-reclamation.js";
import { isSessionMember, listSessionMembers } from "./session-sharing-store.js";
import { addSessionMember, removeSessionMember } from "./session-sharing-store.native.js";
import type { SessionEntry } from "./types.js";
afterEach(() => vi.restoreAllMocks());
describe("session sharing store", () => {
it.each(["commit", "rollback"] as const)(
"keeps incognito capability and membership aligned after owner reversal (%s)",
async (outcome) => {
await withOpenClawTestState({ layout: "state-only" }, async ({ env }) => {
const sessionKey = "agent:main:subagent:incognito-owner-reversal";
const storePath = resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main", env });
const scope = { agentId: "main", env, sessionKey, storePath };
const options = { agentId: "main", env, path: storePath };
const entry: SessionEntry = {
sessionId: "incognito-owner-reversal",
updatedAt: 1,
incognito: true,
spawnedBy: "agent:main:requester-a",
spawnDepth: 1,
inheritedToolPolicyVersion: 1,
completionOwnerSessionKey: "agent:main:requester-a",
};
await upsertSessionEntryCore(scope, entry);
addSessionMember(scope, { identityId: "existing", addedBy: "owner" });
const database = openOpenClawAgentDatabase(options);
const rollback = new Error("Rollback owner reversal");
const reverseOwner = () =>
runOpenClawAgentWriteTransaction((writer) => {
writeSessionEntry(writer, sessionKey, {
...entry,
completionOwnerSessionKey: "agent:main:requester-b",
});
addSessionMember(scope, { identityId: "late", addedBy: "owner" });
writeSessionEntry(writer, sessionKey, entry);
if (outcome === "rollback") {
throw rollback;
}
}, options);
if (outcome === "rollback") {
expect(reverseOwner).toThrow(rollback);
} else {
reverseOwner();
}
expect(loadSessionEntry(scope)?.completionOwnerSessionKey).toBe("agent:main:requester-a");
const published = readCommittedIncognitoSessionSharing(database.db, sessionKey);
expect(published?.capability?.completionOwnerSessionKey).toBe("agent:main:requester-a");
const expectedMembers = outcome === "commit" ? ["existing", "late"] : ["existing"];
expect([...(published?.membership ?? [])].toSorted()).toEqual(expectedMembers);
expect(listSessionMembers(scope).map((member) => member.identityId)).toEqual(
expectedMembers,
);
});
},
);
it("joins exited maintenance leases before removing sharing fixture state", async () => {
let fixtureRoot = "";
const workers: Worker[] = [];

View file

@ -45,15 +45,32 @@ export async function preparePhysicalSessionStorePath(
export function publishSystemEventStoreConfig(cfg: OpenClawConfig): void {
const env = { ...process.env };
const paths = new Map<string, string>();
publishSystemEventStoreResolver((sessionKey, owner) => {
const resolve = (sessionKey: string, owner?: string) => {
const agentId = resolveAgentIdFromSessionKey(sessionKey, owner);
const scope = { sessionKey, agentId, env };
const key = JSON.stringify([agentId, resolveSessionStorePathForScope(scope, cfg)]);
if (!paths.has(key)) {
paths.set(key, resolvePhysicalSessionStorePath(scope, cfg));
}
return paths.get(key)!;
});
return { scope, key };
};
publishSystemEventStoreResolver(
(sessionKey, owner) => {
const { scope, key } = resolve(sessionKey, owner);
if (!paths.has(key)) {
paths.set(key, resolvePhysicalSessionStorePath(scope, cfg));
}
return paths.get(key)!;
},
async (sessionKey, owner) => {
const { scope, key } = resolve(sessionKey, owner);
if (!paths.has(key)) {
const prepared = await preparePhysicalSessionStorePath(scope, cfg);
// A synchronous sibling may already have installed the same owner's selection.
if (!paths.has(key)) {
paths.set(key, prepared);
}
}
return paths.get(key)!;
},
);
}
export function captureSessionWatcherStorePaths(

View file

@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
createOperationalRunInstanceRef,
@ -7,19 +8,29 @@ import {
type PreparedAgentRunAdmission,
} from "../agents/admitted-run-context.js";
import { withFileMutationQueue } from "../agents/sessions/tools/file-mutation-queue.js";
import { prepareGatewayToolCallerAssertion } from "../agents/tools/gateway-caller-context.js";
import { callGatewayTool } from "../agents/tools/gateway.js";
import type { SessionEntry } from "../config/sessions.js";
import { replaceSessionEntry } from "../config/sessions/session-accessor.js";
import { deleteSessionEntryRows } from "../config/sessions/session-accessor.sqlite-entry-store.js";
import {
deleteSessionEntryRows,
writeSessionEntry,
} from "../config/sessions/session-accessor.sqlite-entry-store.js";
import { resolvePhysicalSessionStorePath } from "../config/sessions/session-store-path.js";
import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import * as workerAdmission from "../infra/sqlite-worker-operation-admission.js";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "../plugins/hook-runner-global.js";
import { createMockPluginRegistry } from "../plugins/hooks.test-fixtures.js";
import { registerSessionStateWatch } from "../sessions/session-state-events.js";
import { createDeferredCore } from "../shared/deferred.js";
import { runOpenClawAgentWriteTransaction } from "../state/openclaw-agent-db.js";
import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { observeMainThreadSql } from "../test-utils/main-thread-sql-spies.test-support.js";
import {
createOpenClawTestState,
type OpenClawTestState,
@ -45,6 +56,7 @@ vi.mock("../agents/tools/gateway.js", async (importOriginal) => ({
const requesterKey = "agent:main:direct:completion-requester";
const requesterSessionId = "completion-requester-session";
const childKey = "agent:main:subagent:completion-child";
const incognitoChildKey = "agent:main:subagent:incognito-completion-child";
const childEntry: SessionEntry = {
sessionId: "completion-child-session",
updatedAt: 1,
@ -98,12 +110,15 @@ afterAll(async () => {
await state?.cleanup();
});
async function seedLineage(child: Partial<SessionEntry> = {}) {
async function seedLineage(child: Partial<SessionEntry> = {}, sourceKey = childKey) {
await replaceSessionEntry(
{ agentId: "main", sessionKey: requesterKey },
{ sessionId: requesterSessionId, updatedAt: 1 },
);
await replaceSessionEntry({ agentId: "main", sessionKey: childKey }, { ...childEntry, ...child });
await replaceSessionEntry(
{ agentId: "main", sessionKey: sourceKey },
{ ...childEntry, ...child },
);
clearSessionStoreCacheForTest();
}
@ -131,7 +146,11 @@ async function reownChild() {
}
/** Mints the grant a verified Claude CLI completion turn holds and binds its capture. */
async function mintCompletionGrant(runId: string) {
async function mintCompletionGrant(
runId: string,
sourceKey = childKey,
sourceSessionId = childEntry.sessionId,
) {
const runtime = getActiveMcpLoopbackRuntime();
if (!runtime) {
throw new Error("Expected the isolated MCP runtime");
@ -163,14 +182,14 @@ async function mintCompletionGrant(runId: string) {
toolsAllow: ["write"],
inputProvenance: {
kind: "inter_session",
sourceSessionKey: childKey,
sourceSessionKey: sourceKey,
sourceChannel: "internal",
sourceTool: "subagent_announce",
},
trustedInternalHandoff: {
kind: "subagent-completion",
sourceSessionKey: childKey,
sourceSessionId: childEntry.sessionId,
sourceSessionKey: sourceKey,
sourceSessionId,
targetSessionKey: requesterKey,
targetSessionId: requesterSessionId,
provider: "claude-cli",
@ -227,26 +246,181 @@ function registerBeforeToolCallHook(handler: () => Promise<object | void>) {
}
describe("MCP loopback completion lineage at the final tool-effect fence", () => {
it("lets the verified requester write after an awaited before-tool hook", async () => {
await seedLineage();
const grant = await mintCompletionGrant("lineage-allowed");
const listed = await grant.request("tools/list");
expect(await listed.json()).toMatchObject({ result: { tools: [{ name: "write" }] } });
const hook = vi.fn(async () => {
await new Promise<void>((resolve) => {
setImmediate(resolve);
it.each([
{ kind: "durable", sourceKey: childKey, storedKey: childKey, sessionId: childEntry.sessionId },
{
kind: "incognito",
sourceKey: incognitoChildKey,
storedKey: incognitoChildKey,
sessionId: childEntry.sessionId,
},
{
kind: "by-id",
sourceKey: "agent:main:subagent:lineage-id-alias",
storedKey: childKey,
sessionId: "agent:main:subagent:lineage-id-alias",
},
])(
"registers watches without host SQL for $kind lineage",
async ({ kind, sourceKey, storedKey, sessionId }) => {
await seedLineage({ sessionId }, storedKey);
const runId = `lineage-${kind}-allowed`;
const grant = await mintCompletionGrant(runId, sourceKey, sessionId);
const listed = await grant.request("tools/list");
expect(await listed.json()).toMatchObject({ result: { tools: [{ name: "write" }] } });
let authoritySql: number | undefined;
const watches: boolean[] = [];
const hook = vi.fn(async () => {
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
const sql = observeMainThreadSql();
try {
sql.calibrate();
for (let index = 0; index < 2; index++) {
watches.push(
await registerSessionStateWatch(
{ watcherSessionKey: requesterKey, targetSessionKey: storedKey },
{ prepareCurrent: prepareGatewayToolCallerAssertion },
),
);
}
authoritySql = sql.count();
} finally {
sql.restore();
}
});
registerBeforeToolCallHook(hook);
const response = await grant.request("tools/call");
expect(await response.json()).toMatchObject({ result: { isError: false } });
expect(hook).toHaveBeenCalledTimes(1);
expect(watches).toEqual([true, true]);
expect(authoritySql).toBe(0);
expect(await grant.written()).toBe(runId);
expect(grant.outcomes).toMatchObject([{ toolName: "write", outcome: "completed" }]);
},
);
it("rolls back a watch after an incognito completion-owner change at commit", async () => {
await seedLineage({}, incognitoChildKey);
const grant = await mintCompletionGrant("lineage-incognito-reowned", incognitoChildKey);
const targetSessionKey = "agent:main:dashboard:incognito-lineage-watch";
let watched: boolean | undefined;
let witnessed = false;
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
registerBeforeToolCallHook(async () => {
const admission = vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission((request, allow) => {
if (request.stage === "commit" && !witnessed) {
witnessed = true;
runOpenClawAgentWriteTransaction(
(database) =>
writeSessionEntry(database, incognitoChildKey, {
...childEntry,
completionOwnerSessionKey: "agent:main:direct:another-requester",
}),
{
agentId: "main",
path: resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }),
},
);
}
admit(request, allow);
}, attachment),
);
try {
watched = await registerSessionStateWatch(
{ watcherSessionKey: requesterKey, targetSessionKey },
{ prepareCurrent: prepareGatewayToolCallerAssertion },
);
} finally {
admission.mockRestore();
}
});
registerBeforeToolCallHook(hook);
const response = await grant.request("tools/call");
expect(await response.json()).toMatchObject({ result: { isError: false } });
expect(hook).toHaveBeenCalledTimes(1);
expect(await grant.written()).toBe("lineage-allowed");
expect(grant.outcomes).toMatchObject([{ toolName: "write", outcome: "completed" }]);
expect(await response.json()).toMatchObject({ result: { isError: true } });
expect(witnessed).toBe(true);
expect(watched).toBe(false);
expect(
openOpenClawStateDatabase()
.db.prepare(
"SELECT 1 FROM session_watch_cursors WHERE watcher_session_key = ? AND target_session_key = ?",
)
.get(requesterKey, targetSessionKey),
).toBeUndefined();
expect(await grant.written()).toBeUndefined();
});
it.each(["transaction", "commit", "prepare"] as const)(
"rejects a watch when foreign lineage changes during its %s grant",
async (stage) => {
await seedLineage();
const targetSessionKey = `agent:main:dashboard:lineage-watch-${stage}`;
const watch = { watcherSessionKey: requesterKey, targetSessionKey };
if (stage === "prepare") {
expect(await registerSessionStateWatch(watch)).toBe(true);
}
const grant = await mintCompletionGrant(`lineage-watch-${stage}`);
const peer = new DatabaseSync(
resolvePhysicalSessionStorePath({ agentId: "main", sessionKey: childKey }),
);
let witnessed = false;
let watched: boolean | undefined;
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
registerBeforeToolCallHook(async () => {
const admission = vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission((request, allow) => {
if (
request.stage === stage &&
!witnessed &&
request.facts !== null &&
typeof request.facts === "object" &&
"kind" in request.facts &&
request.facts.kind === "session-entry-current"
) {
witnessed = true;
const replacementOwner = "agent:main:direct:another-requester";
peer
.prepare(
"UPDATE session_nodes SET entry_json = json_set(entry_json, '$.spawnedBy', ?), spawned_by = ?, parent_session_key = ? WHERE session_key = ?",
)
.run(replacementOwner, replacementOwner, replacementOwner, childKey);
}
admit(request, allow);
}, attachment),
);
try {
watched = await registerSessionStateWatch(watch, {
prepareCurrent: prepareGatewayToolCallerAssertion,
});
} finally {
admission.mockRestore();
}
});
try {
const response = await grant.request("tools/call");
expect(await response.json()).toMatchObject({ result: { isError: true } });
expect(witnessed).toBe(true);
expect(watched).toBe(false);
const cursor = openOpenClawStateDatabase()
.db.prepare(
"SELECT target_session_key FROM session_watch_cursors WHERE watcher_session_key = ? AND target_session_key = ?",
)
.get(requesterKey, targetSessionKey);
expect(Boolean(cursor)).toBe(stage === "prepare");
expect(await grant.written()).toBeUndefined();
} finally {
peer.close();
}
},
);
it("lets the completion owner write when another session controls the child", async () => {
// The persisted completion owner, when set, is the lineage; the controller is not.
await seedLineage({

View file

@ -135,12 +135,15 @@ async function startMcpLoopbackServer(
work: AsyncWorkScope,
): Promise<() => Promise<void>> {
// Shutdown preloads this module even when no MCP listener is needed.
const [{ handleMcpJsonRpc }, { McpLoopbackToolCache }, { isCompletionGrantLineageCurrent }] =
await Promise.all([
import("./mcp-http.handlers.js"),
import("./mcp-http.runtime.js"),
import("./tool-resolution-completion.js"),
]);
const [
{ handleMcpJsonRpc },
{ McpLoopbackToolCache },
{ createCompletionGrantLineageAdmission },
] = await Promise.all([
import("./mcp-http.handlers.js"),
import("./mcp-http.runtime.js"),
import("./tool-resolution-completion.js"),
]);
const ownerToken = crypto.randomBytes(32).toString("hex");
const nonOwnerToken = crypto.randomBytes(32).toString("hex");
const toolCache = new McpLoopbackToolCache();
@ -239,9 +242,9 @@ async function startMcpLoopbackServer(
// A completion grant is current only while its requester lineage verifies. The
// child entry can go away while preparation, hooks or approvals await, so the
// dispatch authorization and the tools' source-effect guard both re-check it.
const lineage = createCompletionGrantLineageAdmission({ cfg, context: requestContext });
const isGrantAndLineageCurrent = () =>
(boundClientGrant?.isCurrent() ?? true) &&
isCompletionGrantLineageCurrent({ cfg, context: requestContext });
(boundClientGrant?.isCurrent() ?? true) && lineage.isCurrent();
const authorizeToolCall = () =>
!work.isClosing &&
getActiveMcpLoopbackRuntime()?.ownerToken === ownerToken &&
@ -412,6 +415,7 @@ async function startMcpLoopbackServer(
? createAdmittedGatewayToolCallerIdentity({
admittedRunContext: boundClientGrant.admittedRunContext,
receiptAuthority: isGrantAndLineageCurrent,
receiptAdmission: lineage.admission,
cronAuthorityCheck: boundClientGrant.cronAuthorityCheck,
mintCronRequesterGrant: boundClientGrant.mintCronRequesterGrant,
agentId: scopedTools.agentId,

View file

@ -386,7 +386,7 @@ describe("sessions.catalog.import with durable Gateway owners", () => {
);
expect(readSessionUpstreamLink(fixture.key, "main")).toBeUndefined();
expect(
listSessionStateEventsSince(fixture.key, "main", 0).events.filter(
(await listSessionStateEventsSince(fixture.key, "main", 0)).events.filter(
(event) => event.kind === "imported",
),
).toMatchObject([

View file

@ -744,7 +744,7 @@ describe("session message-cut methods", () => {
createdActor: { type: "human", id: profileId },
createdAt: expect.any(Number),
});
expect(listSessionStateEventsSince(forkKey ?? "", "main", 0, 20).events).toContainEqual(
expect((await listSessionStateEventsSince(forkKey ?? "", "main", 0, 20)).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",

View file

@ -265,13 +265,13 @@ describe("Goal chat admission and continuation", () => {
const request = freshGoalStart("Review the sample backlog", sessionId);
let entryAtAck: SessionEntry | undefined;
let messagesAtAck: ReturnType<typeof userMessages> = [];
const creationEvents = () =>
listSessionStateEventsSince(sessionKey, "main", 0).events.filter(
const creationEvents = async () =>
(await listSessionStateEventsSince(sessionKey, "main", 0)).events.filter(
(event) =>
event.sessionId === entryAtAck?.sessionId &&
(event.kind === "created" || event.kind === "goal_changed"),
);
let eventsAtAck: ReturnType<typeof creationEvents> = [];
let eventsAtAck: ReturnType<typeof creationEvents> = Promise.resolve([]);
await withHeldModel(async () => {
const started = await rpc(
"chat.send",
@ -285,6 +285,7 @@ describe("Goal chat admission and continuation", () => {
},
requestClient,
);
const acknowledgedEvents = await eventsAtAck;
expect(started.mock.calls).toEqual([
[
true,
@ -300,14 +301,14 @@ describe("Goal chat admission and continuation", () => {
});
expect(entryAtAck?.sessionId).not.toBe(request.idempotencyKey);
expect(messagesAtAck).toEqual([expect.objectContaining({ content: request.message })]);
expect(eventsAtAck.map((event) => event.kind)).toEqual(["created", "goal_changed"]);
expect(acknowledgedEvents.map((event) => event.kind)).toEqual(["created", "goal_changed"]);
await waitForModelRun();
context.dedupe.clear();
const replay = await rpc("chat.send", request, undefined, requestClient);
expect(replay.mock.calls[0]?.[1]).toMatchObject({ replayed: true, runId: sessionId });
expect(userMessages()).toHaveLength(1);
expect(runEmbeddedAgent).toHaveBeenCalledOnce();
expect(creationEvents()).toEqual(eventsAtAck);
expect(await creationEvents()).toEqual(acknowledgedEvents);
expect(acpDispatch).not.toHaveBeenCalled();
});
});

View file

@ -57,7 +57,7 @@ test("sessions.create stamps trusted operator provenance and records created", a
expect(created.payload?.entry).not.toHaveProperty("createdActor.label");
const key = expectDefined(created.payload?.key, "created session key");
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("createdActor.label");
expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
expect((await listSessionStateEventsSince(key, "main", 0, 20)).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",

View file

@ -701,9 +701,9 @@ test("sessions.create adopting an existing key does not restamp node provenance"
});
// 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",
),
(
await listSessionStateEventsSince("agent:main:dashboard:adopted", "main", 0, 20)
).events.filter((event) => event.kind === "created"),
).toEqual([]);
} finally {
chatSend.mockRestore();

View file

@ -354,7 +354,7 @@ test("sessions.delete snapshots dirty work before admitting same-key successor w
},
});
expect(
listSessionStateEventsSince(key, "main", 0, 20).events.filter(
(await listSessionStateEventsSince(key, "main", 0, 20)).events.filter(
(event) => event.kind === "created",
),
).toEqual([

View file

@ -9,7 +9,7 @@ import {
} from "../acp/runtime/session-meta.js";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionAcpMeta } from "../config/sessions/types.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { drainSystemEventEntries, peekSystemEvents } from "../infra/system-events.js";
import {
acknowledgeSessionStateNotices,
recordSessionStateEvent,
@ -134,11 +134,17 @@ test.each(["source", "source-and-acp", "acp", "committed-callback"])(
summary: "human message via test",
});
expect(
registerSessionStateWatch({ watcherSessionKey: sessionKey, targetSessionKey: childKey }),
await registerSessionStateWatch({
watcherSessionKey: sessionKey,
targetSessionKey: childKey,
}),
).toBe(true);
recordChildActivity();
expect(drainSystemEvents(sessionKey)).toHaveLength(1);
acknowledgeSessionStateNotices(sessionKey, [childKey]);
const drained = drainSystemEventEntries(sessionKey);
expect(drained).toHaveLength(1);
await acknowledgeSessionStateNotices(sessionKey, [
{ targetSessionKey: childKey, watcherStorePath: drained[0]?.sessionStorePath ?? null },
]);
prepareFreshSession.mockImplementation(async () => {
events.push("runtime-preparation");
if (postCommitFails) {

View file

@ -47,7 +47,7 @@ test("sessions.reset stamps provenance when it materializes a missing row", asyn
});
expect(reset.payload?.entry).not.toHaveProperty("sandbox");
expect(
listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events,
(await listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20)).events,
).toContainEqual(
expect.objectContaining({
kind: "created",

View file

@ -1,9 +1,30 @@
import { hasVerifiedRequesterCompletionHandoff } from "../agents/requester-tool-policy.js";
import {
hasVerifiedRequesterCompletionHandoff,
MAX_DELEGATION_LINEAGE_DEPTH,
} from "../agents/requester-tool-policy.js";
import type { SessionCapabilityLookup } from "../agents/subagents/spawn/subagent-session-store.js";
import { evaluateGatewayToolCallerReceiptAdmission } from "../agents/tools/gateway-caller-context.js";
import type { GatewayToolCallerReceiptAdmission } from "../agents/tools/gateway-caller-receipt.types.js";
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
import { readCommittedIncognitoSessionSharing } from "../config/sessions/session-accessor.sqlite-incognito-sharing.js";
import type {
SessionEntryCurrentFacts,
SessionEntryCurrentSource,
} from "../config/sessions/session-entry-current.types.js";
import { captureSessionStoreReadCandidate } from "../config/sessions/session-store-read-candidates.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
assertExistingDatabaseIdentity,
readDatabasePathIdentitySync,
} from "../infra/sqlite-worker-identity.js";
import { isIncognitoSessionKey, parseAgentSessionKey } from "../routing/session-key.js";
import { getOpenIncognitoAgentDatabase } from "../state/openclaw-agent-db-lifecycle.js";
import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
import type { McpLoopbackRequestContext } from "./mcp-grant-store.js";
type CompletionGrantLineageParams = {
cfg: OpenClawConfig;
preparedSessionCapabilityStore?: SessionCapabilityLookup;
context: Pick<
McpLoopbackRequestContext,
| "sessionKey"
@ -22,7 +43,7 @@ type CompletionGrantLineageParams = {
* re-parented while a tool call awaits preparation, hooks or approvals, so the tool
* list, the dispatch authorization and the tool's source-effect guard all ask this.
*/
export function isCompletionGrantLineageCurrent(params: CompletionGrantLineageParams): boolean {
function isCompletionGrantLineageCurrent(params: CompletionGrantLineageParams): boolean {
const { context } = params;
return (
!context.trustedInternalHandoff ||
@ -34,10 +55,172 @@ export function isCompletionGrantLineageCurrent(params: CompletionGrantLineagePa
modelId: context.modelId,
inputProvenance: context.inputProvenance,
trustedInternalHandoff: context.trustedInternalHandoff,
preparedSessionCapabilityStore: params.preparedSessionCapabilityStore,
})
);
}
type LineageRead = { kind: "key" | "id"; key: string };
class CompletionLineageReadRequired extends Error {
constructor(readonly query: LineageRead) {
super("Completion lineage requires current worker facts");
}
}
/** Register the SQL predicate separately from the MCP grant's existing lifecycle assertion. */
export function createCompletionGrantLineageAdmission(params: CompletionGrantLineageParams) {
if (!params.context.trustedInternalHandoff) {
return { isCurrent: () => true, admission: undefined };
}
const admission: GatewayToolCallerReceiptAdmission = {
async prepare() {
const { withSessionStoreReaderInWorker } =
await import("../config/sessions/session-entry-read-runtime.js");
const reads: Array<{
query: LineageRead;
source: SessionEntryCurrentSource;
assertSourceCurrent(): void;
}> = [];
const entries = new Map<string, SessionEntryCurrentFacts | undefined>();
const nativeReads = new Map<string, () => SessionEntryCurrentFacts | undefined>();
const readKey = (query: LineageRead) => JSON.stringify([query.kind, query.key]);
const get = (query: LineageRead) => {
const key = readKey(query);
const native = nativeReads.get(key);
if (native) {
return native();
}
if (!entries.has(key)) {
throw new CompletionLineageReadRequired(query);
}
return entries.get(key);
};
const store: SessionCapabilityLookup = {
authoritative: true,
get: (key) => get({ kind: "key", key }),
getById: (key) => get({ kind: "id", key }),
};
const current = () =>
isCompletionGrantLineageCurrent({ ...params, preparedSessionCapabilityStore: store });
const maximumReads = 2 * MAX_DELEGATION_LINEAGE_DEPTH;
for (;;) {
let query: LineageRead;
try {
if (!current()) {
throw new Error("CLI completion tool grant no longer matches its requester policy");
}
break;
} catch (error) {
if (!(error instanceof CompletionLineageReadRequired)) {
throw error;
}
query = error.query;
}
if (reads.length + nativeReads.size >= maximumReads) {
throw new Error("Completion lineage changed during worker preparation");
}
const agentId = parseAgentSessionKey(query.key)?.agentId;
if (!agentId) {
throw new Error("Completion lineage requires an agent-qualified source");
}
if (query.kind === "key" && isIncognitoSessionKey(query.key)) {
const pathname = resolveIncognitoOpenClawAgentSqlitePath({ agentId });
const database = getOpenIncognitoAgentDatabase(agentId, pathname);
nativeReads.set(readKey(query), () => {
if (getOpenIncognitoAgentDatabase(agentId, pathname) !== database) {
throw new Error("Completion lineage incognito owner changed");
}
if (!database) {
return undefined;
}
const facts = readCommittedIncognitoSessionSharing(database.db, query.key);
if (facts?.entry && !facts.capability) {
throw new Error("Completion lineage incognito facts are unavailable");
}
return facts?.capability;
});
continue;
}
const storePath = resolveSessionStorePathCore(params.cfg.session?.store, { agentId });
await withSessionStoreReaderInWorker(
{ agentId, storePath },
async (owner) => {
const result = await owner.reader.readExactEntries({
...(query.kind === "key"
? { sessionKeys: [query.key] }
: { selection: { kind: "session-id", sessionId: query.key } as const }),
projection: "sharing",
env: owner.database.env,
continuation: owner.continuation,
});
owner.assertCurrent();
const identity = readDatabasePathIdentitySync(owner.database.path);
if (!identity.key.startsWith("file:")) {
throw new Error("Completion lineage source is unavailable");
}
const selected = { ...owner.selectedStore };
const source: SessionEntryCurrentSource = Object.freeze({
agentId: owner.database.agentId,
path: owner.database.path,
databaseIdentity: identity.key.slice("file:".length),
databaseBirthtime: identity.birthtime,
sessionKey: query.key,
projection: "capability",
...(query.kind === "id" ? { sessionIdLookup: query.key } : {}),
});
const assertSourceCurrent = () => {
assertExistingDatabaseIdentity(source.path, identity.key, identity.birthtime);
if (
captureSessionStoreReadCandidate(selected.path).physicalPath !==
selected.physicalPath
) {
throw new Error("Completion lineage source changed");
}
};
assertSourceCurrent();
entries.set(readKey(query), result.entries[0]?.entry);
reads.push({ query, source, assertSourceCurrent });
},
{ backing: true, dataOnly: true },
);
}
const isCurrent = () => {
try {
for (const read of reads) {
read.assertSourceCurrent();
}
return current();
} catch {
return false;
}
};
return {
current: {
sources: reads.map((read) => read.source),
assertCurrent(values) {
if (values.length !== reads.length) {
throw new Error("Completion lineage admission has an incomplete source cohort");
}
reads.forEach((read, index) => entries.set(readKey(read.query), values[index]));
if (!isCurrent()) {
throw new Error("CLI completion tool grant no longer matches its requester policy");
}
},
},
isCurrent,
};
},
};
return {
admission,
isCurrent: () =>
evaluateGatewayToolCallerReceiptAdmission(admission, () =>
isCompletionGrantLineageCurrent(params),
),
};
}
/** Rejects a tool list, built or cached, whose completion grant outlived its lineage. */
export function assertCompletionGrantLineage(params: CompletionGrantLineageParams): void {
if (!isCompletionGrantLineageCurrent(params)) {

View file

@ -8,12 +8,14 @@ import { emitHeartbeatEvent } from "./heartbeat-events.js";
const stores = resolveGlobalSingleton<{
resolve?: (sessionKey: string, agentId?: string) => string;
prepare?: (sessionKey: string, agentId?: string) => Promise<string>;
owners: Map<symbol, () => void>;
}>(
Symbol.for("openclaw.systemEventStores"),
() => ({ owners: new Map() }),
() => {
stores.resolve = undefined;
stores.prepare = undefined;
},
"close-only",
);
@ -26,6 +28,26 @@ export function getSystemEventStorePath(sessionKey: string, agentId?: string): s
}
}
/** Prepare the current owner's path without invoking its synchronous discovery fallback. */
export function prepareSystemEventStorePath(
sessionKey: string,
agentId?: string,
): Promise<string> | undefined {
const resolve = stores.resolve;
if (!resolve) {
return undefined;
}
const preparing = stores.prepare
? stores.prepare(sessionKey, agentId)
: Promise.resolve(resolve(sessionKey, agentId));
return preparing.then((pathname) => {
if (stores.resolve !== resolve) {
throw new Error("System-event store owner changed during path preparation");
}
return pathname;
});
}
export function isSystemEventStoreCurrent(
sessionKey: string | undefined,
storePath: string | null | undefined,
@ -39,9 +61,20 @@ export function isSystemEventStoreCurrent(
);
}
/** A resumed operation must not discover a replacement owner's store on the host. */
export function captureSystemEventStoreCurrentCheck(sessionKey: string, agentId?: string) {
const resolve = stores.resolve;
return (storePath: string | null | undefined) =>
stores.resolve === resolve && isSystemEventStoreCurrent(sessionKey, storePath, agentId);
}
/** The accepted Gateway store selection owns retirement; same-store handoff retains its facts. */
export function publishSystemEventStoreResolver(resolve: typeof stores.resolve): void {
export function publishSystemEventStoreResolver(
resolve: typeof stores.resolve,
prepare?: typeof stores.prepare,
): void {
stores.resolve = resolve;
stores.prepare = prepare;
for (const retire of stores.owners.values()) {
retire();
}

View file

@ -43,13 +43,13 @@ afterEach(async () => {
describe("Home session creation notices", () => {
it.each([undefined, true, false])(
"honors notifyOnCreate=%s through the config schema",
(enabled) => {
async (enabled) => {
const cfg = {
session: SessionSchema.parse(enabled === undefined ? {} : { notifyOnCreate: enabled }),
};
recordSessionCreated(cfg, { sessionKey, agentId: "ops", entry: entry() });
expect(peekSystemEvents(mainSessionKey)).toHaveLength(enabled === false ? 0 : 1);
expect(listSessionStateEventsSince(sessionKey, "ops", 0).events).toMatchObject([
expect((await listSessionStateEventsSince(sessionKey, "ops", 0)).events).toMatchObject([
{ kind: "created", actorId: "profile-alice" },
]);
},

View file

@ -23,6 +23,7 @@ import {
type SessionStateActorType,
type SessionStateEventKind,
} from "./session-state-event-kinds.js";
import type { SessionStateEventRecord } from "./session-state-events.types.js";
import {
rowToSessionUpstreamLink,
type SessionUpstreamLink,
@ -49,19 +50,6 @@ type SessionStateDatabase = Pick<
>;
type SessionStateEventsTable = OpenClawStateKyselyDatabase["session_state_events"];
export type SessionStateEventRow = Selectable<SessionStateEventsTable>;
export type SessionStateEventRecord = {
sequence: number;
sessionKey: string;
sessionId?: string;
agentId: string;
kind: SessionStateEventKind;
actorType: SessionStateActorType;
actorId?: string;
runId?: string;
occurredAt: number;
summary: string;
payload?: Record<string, unknown>;
};
export function rowToSessionStateEvent(row: SessionStateEventRow): SessionStateEventRecord {
const payload = row.payload_json ? safeParseJsonRecord(row.payload_json) : undefined;

View file

@ -0,0 +1,20 @@
import type { SessionStateEventRecord } from "./session-state-events.types.js";
export type SessionStateReadOperations = {
"sessionState.versions": {
input: ReadonlyArray<{ sessionKey: string; agentId: string }>;
output: { type: "sessionState.versions"; versions: Record<string, Record<string, number>> };
};
"sessionState.events": {
input: { sessionKey: string; agentId: string; afterSequence: number; limit: number };
output: {
type: "sessionState.events";
page: {
events: SessionStateEventRecord[];
truncated: boolean;
earliestAvailableSequence: number;
historyGap: boolean;
};
};
};
};

View file

@ -0,0 +1,106 @@
import type { DatabaseSync } from "node:sqlite";
import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync } from "../infra/kysely-sync.js";
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js";
import type { WorkerOperationHandlers } from "../state/worker-operation-registry.js";
import {
getSessionStateKysely,
normalizeOptionalSqliteNumber,
rowToSessionStateEvent,
} from "./session-state-events.kernel.js";
export function readSessionStateSequence(
db: DatabaseSync,
sessionKey: string,
agentId: string,
): number {
const row = executeSqliteQueryTakeFirstSync(
db,
getSessionStateKysely(db)
.selectFrom("session_state_heads")
.select("last_sequence")
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
);
return normalizeOptionalSqliteNumber(row?.last_sequence) ?? 0;
}
export const sessionStateReadOperations = {
"sessionState.versions": (refs: ReadonlyArray<{ sessionKey: string; agentId: string }>, db) => {
const keys = [...new Set(refs.map((ref) => ref.sessionKey).filter(Boolean))];
const versions = new Map<string, Map<string, number>>();
// sessions_list accepts arbitrary limits; keep each statement below SQLite's bind limit.
for (let offset = 0; offset < keys.length; offset += 500) {
const rows = executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.selectFrom("session_state_heads")
.select(["session_key", "agent_id", "last_sequence"])
.where("session_key", "in", keys.slice(offset, offset + 500)),
).rows;
for (const row of rows) {
let sessions = versions.get(row.agent_id);
if (!sessions) {
sessions = new Map();
versions.set(row.agent_id, sessions);
}
sessions.set(row.session_key, normalizeSqliteNumber(row.last_sequence) ?? 0);
}
}
return {
type: "sessionState.versions" as const,
versions: Object.fromEntries(
[...versions].map(([agentId, sessions]) => [agentId, Object.fromEntries(sessions)]),
),
};
},
"sessionState.events": (
input: { sessionKey: string; agentId: string; afterSequence: number; limit: number },
db,
) =>
runSqliteDeferredTransactionSync(db, () => {
const { sessionKey, agentId, afterSequence } = input;
const boundedLimit = Math.max(1, Math.min(200, Math.floor(input.limit)));
const kysely = getSessionStateKysely(db);
const rows = executeSqliteQuerySync(
db,
kysely
.selectFrom("session_state_events")
.selectAll()
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId)
.where("sequence", ">", afterSequence)
.orderBy("sequence", "asc")
.limit(boundedLimit + 1),
).rows;
const earliest = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("session_state_events")
.select((eb) => eb.fn.min<number>("sequence").as("sequence"))
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
);
const headRow = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("session_state_heads")
.select(["last_sequence", "pruned_max_sequence"])
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
);
const head = normalizeOptionalSqliteNumber(headRow?.last_sequence) ?? 0;
const prunedMax = normalizeOptionalSqliteNumber(headRow?.pruned_max_sequence) ?? 0;
return {
type: "sessionState.events" as const,
page: {
events: rows.slice(0, boundedLimit).map(rowToSessionStateEvent),
truncated: rows.length > boundedLimit,
earliestAvailableSequence:
normalizeOptionalSqliteNumber(earliest?.sequence) ?? (head > 0 ? head + 1 : 0),
// Global sequence gaps do not prove pruning; only this session's watermark does.
historyGap: afterSequence < prunedMax,
},
};
}),
} satisfies WorkerOperationHandlers<DatabaseSync>;

View file

@ -80,7 +80,7 @@ describe("session state events", () => {
it.each(
[
{ change: "ownership", key: child, field: "lifecycleRunId", records: false },
{ change: "metadata", key: child, field: "label", records: true },
{ change: "metadata", key: child, field: "completionOwnerSessionKey", records: true },
{ change: "another session", key: watcher, field: "lifecycleRunId", records: true },
].flatMap(({ change, key, field, records }) =>
[1, 2].map((verdict) => ({ change, key, field, records, verdict })),
@ -132,10 +132,10 @@ describe("session state events", () => {
expect(changedAfterVerdict).toBe(true);
if (records) {
expect(recorded).toMatchObject({ sessionKey: child, sessionId: entry.sessionId });
expect(getSessionStateVersion(child, "main", database)).toBeGreaterThan(0);
expect(await getSessionStateVersion(child, "main", database)).toBeGreaterThan(0);
} else {
expect(recorded).toBeUndefined();
expect(getSessionStateVersion(child, "main", database)).toBe(0);
expect(await getSessionStateVersion(child, "main", database)).toBe(0);
}
await upsertSessionEntryCore(target, entry);
@ -143,17 +143,20 @@ describe("session state events", () => {
sessionKey: child,
sessionId: entry.sessionId,
});
expect(getSessionStateVersion(child, "main", database)).toBeGreaterThan(0);
expect(await getSessionStateVersion(child, "main", database)).toBeGreaterThan(0);
} finally {
peer.close();
}
},
);
it("does not advance a replacement watch from older producer facts", () => {
it("does not advance a replacement watch from older producer facts", async () => {
const database = createDatabaseOptions();
resetHeartbeatEventsForTest();
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: child }, database);
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: child },
database,
);
const readBinding = () =>
openOpenClawStateDatabase(database)
.db.prepare(
@ -172,7 +175,7 @@ describe("session state events", () => {
expect(peekSystemEventEntries(watcher)).toEqual([]);
expect(getLastHeartbeatEvent()).toMatchObject({ status: "skipped", reason: "store-replaced" });
});
it("preserves older readers and version markers when watcher provenance is first written", () => {
it("preserves older readers and version markers when watcher provenance is first written", async () => {
const database = createDatabaseOptions();
const before = openOpenClawStateDatabase(database);
before.db.exec("ALTER TABLE session_watch_cursors DROP COLUMN watcher_store_path");
@ -180,7 +183,7 @@ describe("session state events", () => {
closeOpenClawStateDatabaseForTest();
const reopened = openOpenClawStateDatabase(database);
const schemaBeforeRead = reopened.db.prepare("PRAGMA schema_version").get();
expect(getSessionStateVersion(child, "main", database)).toBe(0);
expect(await getSessionStateVersion(child, "main", database)).toBe(0);
expect(reopened.db.prepare("PRAGMA schema_version").get()).toEqual(schemaBeforeRead);
expect(
reopened.db
@ -218,19 +221,19 @@ describe("session state events", () => {
expect(reopened.db.prepare("PRAGMA user_version").get()).toEqual(userVersion);
});
it("bumps a durable head that survives pruning all retained rows", () => {
it("bumps a durable head that survives pruning all retained rows", async () => {
const database = createDatabaseOptions();
const now = Date.now();
const event = recordSessionStateEvent(eventInput(), { ...database, now });
expect(getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
expect(await getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
sweepSessionStateWatchNotices({
...database,
now: now + SESSION_STATE_RETENTION_MS + 1,
});
expect(listSessionStateEventsSince(child, "main", 0, 200, database).events).toEqual([]);
expect(getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
expect((await listSessionStateEventsSince(child, "main", 0, 200, database)).events).toEqual([]);
expect(await getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
});
it("freezes one notice watermark while material events continue", () => {
@ -248,14 +251,19 @@ describe("session state events", () => {
});
});
it("opens a fresh notice for material work interleaved before ack", () => {
it("opens a fresh notice for material work interleaved before ack", async () => {
const database = createDatabaseOptions();
seedChild(database);
const frozen = recordSessionStateEvent(eventInput(), database)!;
const interleaved = recordSessionStateEvent(eventInput(), database)!;
const watcherStorePath = peekSystemEventEntries(watcher)[0]?.sessionStorePath ?? null;
resetSystemEventsForTest();
acknowledgeSessionStateNotices(watcher, [child], database);
await acknowledgeSessionStateNotices(
watcher,
[{ targetSessionKey: child, watcherStorePath }],
database,
);
expect(readCursor(database)).toEqual({
last_seen_sequence: frozen.sequence,
@ -275,9 +283,14 @@ describe("session state events", () => {
eventInput({ kind: "run_completed", actorType: "system", runId: "run-log-only" }),
database,
);
const watcherStorePath = peekSystemEventEntries(watcher)[0]?.sessionStorePath ?? null;
resetSystemEventsForTest();
acknowledgeSessionStateNotices(watcher, [child], database);
await acknowledgeSessionStateNotices(
watcher,
[{ targetSessionKey: child, watcherStorePath }],
database,
);
expect(readCursor(database)).toEqual({
last_seen_sequence: material.sequence,
notified_sequence: material.sequence,
@ -342,14 +355,14 @@ describe("session state events", () => {
expect(peekSystemEventEntries(watcher)).toEqual([]);
});
it("records log-only kinds without queueing notices", () => {
it("records log-only kinds without queueing notices", async () => {
const database = createDatabaseOptions();
const event = recordSessionStateEvent(
eventInput({ kind: "compacted", actorType: "system" }),
database,
);
expect(getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
expect(await getSessionStateVersion(child, "main", database)).toBe(event?.sequence);
expect(peekSystemEventEntries(watcher)).toEqual([]);
});
@ -367,7 +380,7 @@ describe("session state events", () => {
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
});
it("returns the existing row for a duplicate dedupe key", () => {
it("returns the existing row for a duplicate dedupe key", async () => {
const database = createDatabaseOptions();
const input = eventInput({
kind: "run_failed",
@ -379,7 +392,9 @@ describe("session state events", () => {
const duplicate = recordSessionStateEvent(input, database);
expect(duplicate?.sequence).toBe(first?.sequence);
expect(listSessionStateEventsSince(child, "main", 0, 200, database).events).toHaveLength(1);
expect(
(await listSessionStateEventsSince(child, "main", 0, 200, database)).events,
).toHaveLength(1);
});
it("re-enqueues and re-freezes pending notices after restart", async () => {
@ -406,7 +421,7 @@ describe("session state events", () => {
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
});
it("prunes retention and cap rows while keeping monotonic autoincrement heads", () => {
it("prunes retention and cap rows while keeping monotonic autoincrement heads", async () => {
const database = createDatabaseOptions();
const now = Date.now();
const { db } = openOpenClawStateDatabase(database);
@ -431,7 +446,7 @@ describe("session state events", () => {
const next = recordSessionStateEvent(eventInput(), { ...database, now: now + 1 })!;
expect(next.sequence).toBeGreaterThan(before.sequence);
expect(getSessionStateVersion(child, "main", database)).toBe(next.sequence);
expect(await getSessionStateVersion(child, "main", database)).toBe(next.sequence);
});
it("prunes many composite session heads without recreating or regressing them", () => {
@ -512,7 +527,7 @@ describe("session state events", () => {
expect(updates.counts.watermarks).toBeLessThanOrEqual(4);
});
it("lists typed ascending deltas with truncation and history-gap signaling", () => {
it("lists typed ascending deltas with truncation and history-gap signaling", async () => {
const database = createDatabaseOptions();
const now = Date.now();
const first = recordSessionStateEvent(eventInput({ summary: "first" }), {
@ -525,7 +540,7 @@ describe("session state events", () => {
});
recordSessionStateEvent(eventInput({ summary: "third" }), { ...database, now: now + 2 });
const page = listSessionStateEventsSince(child, "main", 0, 2, database);
const page = await listSessionStateEventsSince(child, "main", 0, 2, database);
expect(page.events.map((event) => event.summary)).toEqual(["first", "second"]);
expect(page.events[1]?.payload).toEqual({ status: "active" });
expect(page.truncated).toBe(true);
@ -535,10 +550,12 @@ describe("session state events", () => {
openOpenClawStateDatabase(database)
.db.prepare("DELETE FROM session_state_events WHERE sequence = ?")
.run(first.sequence);
expect(listSessionStateEventsSince(child, "main", 0, 200, database).historyGap).toBe(false);
expect((await listSessionStateEventsSince(child, "main", 0, 200, database)).historyGap).toBe(
false,
);
});
it("reports history gaps only for actually pruned events, not sparse global sequences", () => {
it("reports history gaps only for actually pruned events, not sparse global sequences", async () => {
const database = createDatabaseOptions();
const now = Date.now();
// Other sessions consume early global sequences; the child starts high.
@ -550,7 +567,9 @@ describe("session state events", () => {
}
const old = recordSessionStateEvent(eventInput({ summary: "old" }), { ...database, now })!;
expect(old.sequence).toBeGreaterThan(1);
expect(listSessionStateEventsSince(child, "main", 0, 200, database).historyGap).toBe(false);
expect((await listSessionStateEventsSince(child, "main", 0, 200, database)).historyGap).toBe(
false,
);
const later = now + SESSION_STATE_RETENTION_MS + 1;
const fresh = recordSessionStateEvent(eventInput({ summary: "fresh" }), {
@ -559,13 +578,13 @@ describe("session state events", () => {
})!;
sweepSessionStateWatchNotices({ ...database, now: later });
const sincePruned = listSessionStateEventsSince(child, "main", 0, 200, database);
const sincePruned = await listSessionStateEventsSince(child, "main", 0, 200, database);
expect(sincePruned.historyGap).toBe(true);
expect(sincePruned.events.map((event) => event.summary)).toEqual(["fresh"]);
expect(listSessionStateEventsSince(child, "main", old.sequence, 200, database).historyGap).toBe(
false,
);
expect(getSessionStateVersion(child, "main", database)).toBe(fresh.sequence);
expect(
(await listSessionStateEventsSince(child, "main", old.sequence, 200, database)).historyGap,
).toBe(false);
expect(await getSessionStateVersion(child, "main", database)).toBe(fresh.sequence);
});
it("suppresses cursors and notices for agent-ambiguous bare watcher keys", () => {
@ -582,7 +601,7 @@ describe("session state events", () => {
expect(cursorRow.n).toBe(0);
});
it("keeps same-keyed global sessions independent across agents", () => {
it("keeps same-keyed global sessions independent across agents", async () => {
const database = createDatabaseOptions();
const mainEvent = recordSessionStateEvent(
eventInput({
@ -605,17 +624,17 @@ describe("session state events", () => {
database,
)!;
expect(getSessionStateVersion("global", "main", database)).toBe(mainEvent.sequence);
expect(getSessionStateVersion("global", "ops", database)).toBe(opsEvent.sequence);
expect(await getSessionStateVersion("global", "main", database)).toBe(mainEvent.sequence);
expect(await getSessionStateVersion("global", "ops", database)).toBe(opsEvent.sequence);
expect(
listSessionStateEventsSince("global", "main", 0, 200, database).events.map(
(await listSessionStateEventsSince("global", "main", 0, 200, database)).events.map(
(event) => event.sequence,
),
).toEqual([mainEvent.sequence]);
handleSessionStateSessionDeleted("global", "ops", database);
expect(getSessionStateVersion("global", "ops", database)).toBe(0);
expect(getSessionStateVersion("global", "main", database)).toBe(mainEvent.sequence);
expect(await getSessionStateVersion("global", "ops", database)).toBe(0);
expect(await getSessionStateVersion("global", "main", database)).toBe(mainEvent.sequence);
});
it("acks only drained session-state entries and ignores ordinary events", async () => {
@ -646,7 +665,7 @@ describe("session state events", () => {
expect(readCursor(database)?.last_seen_sequence).toBe(material.sequence);
});
it("keeps target history on reset and removes all ownership on delete", () => {
it("keeps target history on reset and removes all ownership on delete", async () => {
const database = createDatabaseOptions();
seedChild(database);
recordSessionStateEvent(eventInput(), database);
@ -654,12 +673,12 @@ describe("session state events", () => {
handleSessionStateSessionReset(watcher, database);
expect(readCursor(database)).toBeUndefined();
expect(
listSessionStateEventsSince(child, "main", 0, 200, database).events.length,
(await listSessionStateEventsSince(child, "main", 0, 200, database)).events.length,
).toBeGreaterThan(0);
handleSessionStateSessionDeleted(child, "main", database);
expect(getSessionStateVersion(child, "main", database)).toBe(0);
expect(listSessionStateEventsSince(child, "main", 0, 200, database).events).toEqual([]);
expect(await getSessionStateVersion(child, "main", database)).toBe(0);
expect((await listSessionStateEventsSince(child, "main", 0, 200, database)).events).toEqual([]);
});
it("classifies missing provenance as human and inter-session provenance as agent", () => {
@ -677,21 +696,24 @@ describe("session state events", () => {
});
});
it("registers explicit watchers who get notices only for later changes", () => {
it("registers explicit watchers who get notices only for later changes", async () => {
const database = createDatabaseOptions();
const preRegistration = recordSessionStateEvent(
eventInput({ watcherSessionKeys: [] }),
database,
)!;
expect(registerSessionStateWatch({ watcherSessionKey: child, targetSessionKey: child })).toBe(
false,
);
expect(
registerSessionStateWatch({ watcherSessionKey: "global", targetSessionKey: child }),
await registerSessionStateWatch({ watcherSessionKey: child, targetSessionKey: child }),
).toBe(false);
expect(
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: child }, database),
await registerSessionStateWatch({ watcherSessionKey: "global", targetSessionKey: child }),
).toBe(false);
expect(
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: child },
database,
),
).toBe(true);
expect(peekSystemEventEntries(watcher)).toHaveLength(0);
@ -708,7 +730,10 @@ describe("session state events", () => {
// Re-registering must keep the pending-notice cursor intact.
expect(
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: child }, database),
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: child },
database,
),
).toBe(true);
expect(readCursor(database)).toEqual({
last_seen_sequence: preRegistration.sequence,
@ -717,40 +742,46 @@ describe("session state events", () => {
});
});
it.each(["ambient", "explicit"])("rebinds a replaced %s watch on the next group turn", (kind) => {
const database = createDatabaseOptions();
if (kind === "explicit") {
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: group }, database);
} else {
registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
}
const { db } = openOpenClawStateDatabase(database);
db.prepare("UPDATE session_watch_cursors SET watcher_store_path = ?").run(
"/retired/store.sqlite",
);
expect(registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database)).toBe(
true,
);
expect(
db.prepare("SELECT watcher_store_path, provenance FROM session_watch_cursors").get(),
).toEqual({
watcher_store_path: expect.not.stringContaining("/retired/"),
provenance: "ambient-group",
});
recordSessionStateEvent(eventInput({ sessionKey: group, watcherSessionKeys: [] }), database);
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
});
it.each(["ambient", "explicit"])(
"rebinds a replaced %s watch on the next group turn",
async (kind) => {
const database = createDatabaseOptions();
if (kind === "explicit") {
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: group },
database,
);
} else {
await registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
}
const { db } = openOpenClawStateDatabase(database);
db.prepare("UPDATE session_watch_cursors SET watcher_store_path = ?").run(
"/retired/store.sqlite",
);
expect(
await registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database),
).toBe(true);
expect(
db.prepare("SELECT watcher_store_path, provenance FROM session_watch_cursors").get(),
).toEqual({
watcher_store_path: expect.not.stringContaining("/retired/"),
provenance: "ambient-group",
});
recordSessionStateEvent(eventInput({ sessionKey: group, watcherSessionKeys: [] }), database);
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
},
);
it("registers one ambient main watcher for a distinct group session", () => {
it("registers one ambient main watcher for a distinct group session", async () => {
const database = createDatabaseOptions();
expect(
registerMainSessionGroupWatch(
await registerMainSessionGroupWatch(
{ sessionKey: group, agentId: "main" },
{ ...database, now: 100 },
),
).toBe(true);
expect(
registerMainSessionGroupWatch(
await registerMainSessionGroupWatch(
{ sessionKey: group, agentId: "main" },
{ ...database, now: 200 },
),
@ -780,12 +811,12 @@ describe("session state events", () => {
expect(listAmbientGroupWatchTargets(watcher, database)).toEqual(new Set());
});
it("does not register a group routed into the configured main session", () => {
it("does not register a group routed into the configured main session", async () => {
const database = createDatabaseOptions();
const mainSessionKey = "agent:main:work";
expect(
registerMainSessionGroupWatch(
await registerMainSessionGroupWatch(
{
sessionKey: mainSessionKey,
agentId: "main",
@ -805,7 +836,7 @@ describe("session state events", () => {
await vi.runAllTimersAsync();
wakes.mockClear();
const database = createDatabaseOptions();
registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
await registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
for (const actorId of ["human-1", "human-2"]) {
await recordSessionHumanDirectMessage(
@ -821,7 +852,9 @@ describe("session state events", () => {
}
await vi.advanceTimersByTimeAsync(21_000);
expect(listSessionStateEventsSince(group, "main", 0, 200, database).events).toHaveLength(2);
expect(
(await listSessionStateEventsSince(group, "main", 0, 200, database)).events,
).toHaveLength(2);
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
const cursor = readCursor(database, watcher, group);
expect(cursor).toBeDefined();
@ -833,11 +866,11 @@ describe("session state events", () => {
const database = createDatabaseOptions();
const dormantGroup = "agent:main:slack:channel:dormant";
const registeredAt = 100;
registerMainSessionGroupWatch(
await registerMainSessionGroupWatch(
{ sessionKey: group, agentId: "main" },
{ ...database, now: registeredAt },
);
registerMainSessionGroupWatch(
await registerMainSessionGroupWatch(
{ sessionKey: dormantGroup, agentId: "main" },
{ ...database, now: registeredAt },
);
@ -870,7 +903,7 @@ describe("session state events", () => {
wakes.mockClear();
const database = createDatabaseOptions();
const coordinator = "agent:main:coordinator";
registerSessionStateWatch(
await registerSessionStateWatch(
{ watcherSessionKey: coordinator, targetSessionKey: group },
database,
);
@ -898,10 +931,13 @@ describe("session state events", () => {
await vi.runAllTimersAsync();
wakes.mockClear();
const database = createDatabaseOptions();
registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
await registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
expect(listAmbientGroupWatchTargets(watcher, database)).toEqual(new Set([group]));
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: group }, database);
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: group },
database,
);
expect(listAmbientGroupWatchTargets(watcher, database)).toEqual(new Set());
expect(
openOpenClawStateDatabase(database)
@ -912,7 +948,7 @@ describe("session state events", () => {
.get(watcher, group),
).toEqual({ provenance: "explicit" });
// Later inbound group registration must not downgrade the explicit watch.
registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
await registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database);
await recordSessionHumanDirectMessage(
{
@ -940,9 +976,14 @@ describe("session state events", () => {
actor: { actorType: "human" },
channel: "webchat",
});
expect(listSessionStateEventsSince(child, "main", 0, 200, database).events).toHaveLength(0);
expect(
(await listSessionStateEventsSince(child, "main", 0, 200, database)).events,
).toHaveLength(0);
registerSessionStateWatch({ watcherSessionKey: watcher, targetSessionKey: child }, database);
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: child },
database,
);
await recordSessionHumanDirectMessage({
sessionKey: child,
entry,
@ -951,7 +992,7 @@ describe("session state events", () => {
channel: "webchat",
});
const events = listSessionStateEventsSince(child, "main", 0, 200, database).events;
const events = (await listSessionStateEventsSince(child, "main", 0, 200, database)).events;
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ kind: "human_direct_message" });
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
@ -1013,7 +1054,7 @@ describe("session state events", () => {
sessionId: "session-child",
});
const events = listSessionStateEventsSince(child, "main", 0, 200, database).events;
const events = (await listSessionStateEventsSince(child, "main", 0, 200, database)).events;
expect(events.map((event) => event.kind)).toEqual([
"created",
"child_spawned",

View file

@ -1,29 +1,42 @@
/** Best-effort durable signal log for session state changes. */
import type { DatabaseSync } from "node:sqlite";
import { loadSessionEntryReadOnly } from "../config/sessions/session-accessor.js";
import { assertSessionEntryCurrentAdmission } from "../config/sessions/session-entry-current-admission.js";
import type { SessionEntryCurrentCheck } from "../config/sessions/session-entry-current.types.js";
import {
assertSessionEntriesCurrentAdmission,
assertSessionEntryCurrentAdmission,
} from "../config/sessions/session-entry-current-admission.js";
import type {
SessionEntriesCurrentCheck,
SessionEntryCurrentCheck,
} from "../config/sessions/session-entry-current.types.js";
import {
captureSessionWatcherStorePaths,
resolvePhysicalSessionStorePath,
preparePhysicalSessionStorePath,
} from "../config/sessions/session-store-path.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync } from "../infra/kysely-sync.js";
import { executeSqliteQuerySync } from "../infra/kysely-sync.js";
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
import { createSqliteWorkerOperationAdmission } from "../infra/sqlite-worker-operation-admission.js";
import { isSystemEventStoreCurrent } from "../infra/system-event-ownership.js";
import {
captureSystemEventStoreCurrentCheck,
prepareSystemEventStorePath,
} from "../infra/system-event-ownership.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { buildAgentMainSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { executeExistingOpenClawStateRead } from "../state/openclaw-state-db-readonly.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { captureOpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.js";
import {
captureOpenClawStateReadWorkerContext,
captureOpenClawStateWorkerContext,
} from "../state/openclaw-state-worker-context.js";
import { runOpenClawStateWorkerOperation } from "../state/openclaw-state-worker-store.js";
import {
SESSION_WATCH_PROVENANCE_AMBIENT_GROUP,
SESSION_WATCH_PROVENANCE_EXPLICIT,
type SessionWatchCursorProvenance,
} from "../state/session-watch-cursor-provenance.js";
import { classifySessionKind } from "./classify-session-kind.js";
import type { InputProvenance } from "./input-provenance.js";
@ -32,16 +45,14 @@ import {
getSessionStateKysely,
isAmbientGroupWatchCursor,
isNotifiableWatcherKey,
normalizeOptionalSqliteNumber,
pruneSessionStateEventsInDatabase,
readCursor,
recordSessionStateEventInDatabase,
rowToSessionStateEvent,
upsertSeedCursor,
type SessionStateEventInput,
type SessionStateEventRecord,
type SessionStateNotice,
} from "./session-state-events.kernel.js";
import type { SessionStateReadOperations } from "./session-state-events.read.worker-contract.js";
import type { SessionStateEventRecord } from "./session-state-events.types.js";
import type { SessionStateWatchAddress } from "./session-state-events.worker-contract.js";
import { enqueueSessionStateNotice } from "./session-state-notices.js";
import { deleteSessionUpstreamLink } from "./session-upstream-links.js";
import type { SessionUpstreamLink } from "./session-upstream-links.kernel.js";
@ -109,175 +120,171 @@ export function recordSessionStateEvent(
}
/** Return the durable signal-log head for one session; degrades to 0 on read failure. */
export function getSessionStateVersion(
export async function getSessionStateVersion(
sessionKey: string,
agentId: string,
options: OpenClawStateDatabaseOptions = {},
): number {
try {
const { db } = openOpenClawStateDatabase(options);
return readSessionStateSequence(db, sessionKey, agentId);
} catch (error) {
// Best-effort log: enrichment reads must never fail core session tools.
log.warn(`failed to read session state version: ${String(error)}`);
return 0;
}
}
function readSessionStateSequence(db: DatabaseSync, sessionKey: string, agentId: string): number {
const row = executeSqliteQueryTakeFirstSync(
db,
getSessionStateKysely(db)
.selectFrom("session_state_heads")
.select("last_sequence")
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
): Promise<number> {
return (
(await getSessionStateVersions([{ sessionKey, agentId }], options))[agentId]?.[sessionKey] ?? 0
);
return normalizeOptionalSqliteNumber(row?.last_sequence) ?? 0;
}
/** Batch durable signal-log heads for session-list enrichment, keyed agent → session key. */
export function getSessionStateVersions(
export async function getSessionStateVersions(
refs: ReadonlyArray<{ sessionKey: string; agentId: string }>,
options: OpenClawStateDatabaseOptions = {},
): Record<string, Record<string, number>> {
const keys = [...new Set(refs.map((ref) => ref.sessionKey).filter(Boolean))];
if (keys.length === 0) {
): Promise<Record<string, Record<string, number>>> {
if (refs.length === 0) {
return {};
}
const byAgent: Record<string, Record<string, number>> = {};
try {
const { db } = openOpenClawStateDatabase(options);
// Chunk IN() binds: sessions_list accepts arbitrary limits and SQLite caps
// host parameters per statement.
for (let offset = 0; offset < keys.length; offset += 500) {
const rows = executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.selectFrom("session_state_heads")
.select(["session_key", "agent_id", "last_sequence"])
.where("session_key", "in", keys.slice(offset, offset + 500)),
).rows;
for (const row of rows) {
(byAgent[row.agent_id] ??= {})[row.session_key] =
normalizeSqliteNumber(row.last_sequence) ?? 0;
}
const context = captureOpenClawStateReadWorkerContext(options);
const result = await executeExistingOpenClawStateRead(
options,
{
type: "sessionState.versions",
input: refs,
},
{ context },
);
context.admission.assertCurrent();
if (result && !result.ok) {
throw new Error(result.message);
}
return result?.type === "sessionState.versions" ? result.versions : {};
} catch (error) {
// Best-effort log: enrichment reads must never fail core session tools.
log.warn(`failed to read session state versions: ${String(error)}`);
return {};
}
return byAgent;
}
/** List retained signal-log events after a version without advancing watcher cursors. */
export function listSessionStateEventsSince(
export async function listSessionStateEventsSince(
sessionKey: string,
agentId: string,
afterSequence: number,
limit = 200,
options: OpenClawStateDatabaseOptions = {},
): {
events: SessionStateEventRecord[];
truncated: boolean;
earliestAvailableSequence: number;
historyGap: boolean;
} {
): Promise<SessionStateReadOperations["sessionState.events"]["output"]["page"]> {
try {
const boundedLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const { db } = openOpenClawStateDatabase(options);
const kysely = getSessionStateKysely(db);
const rows = executeSqliteQuerySync(
db,
kysely
.selectFrom("session_state_events")
.selectAll()
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId)
.where("sequence", ">", afterSequence)
.orderBy("sequence", "asc")
.limit(boundedLimit + 1),
).rows;
const earliest = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("session_state_events")
.select((eb) => eb.fn.min<number>("sequence").as("sequence"))
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
const context = captureOpenClawStateReadWorkerContext(options);
const result = await executeExistingOpenClawStateRead(
options,
{
type: "sessionState.events",
input: { sessionKey, agentId, afterSequence, limit },
},
{ context },
);
const headRow = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("session_state_heads")
.select(["last_sequence", "pruned_max_sequence"])
.where("session_key", "=", sessionKey)
.where("agent_id", "=", agentId),
);
const head = normalizeOptionalSqliteNumber(headRow?.last_sequence) ?? 0;
const prunedMax = normalizeOptionalSqliteNumber(headRow?.pruned_max_sequence) ?? 0;
const earliestAvailableSequence =
normalizeOptionalSqliteNumber(earliest?.sequence) ?? (head > 0 ? head + 1 : 0);
return {
events: rows.slice(0, boundedLimit).map(rowToSessionStateEvent),
truncated: rows.length > boundedLimit,
earliestAvailableSequence,
// Sequences are globally sparse, so distance from earliest retained proves nothing.
// Only the per-session pruned watermark stamped by pruneSessionStateEvents can say
// whether events this cursor never saw were actually removed.
historyGap: afterSequence < prunedMax,
};
context.admission.assertCurrent();
if (result && !result.ok) {
throw new Error(result.message);
}
if (result?.type === "sessionState.events") {
return result.page;
}
} catch (error) {
// Best-effort log: enrichment reads must never fail core session tools.
log.warn(`failed to list session state events: ${String(error)}`);
return { events: [], truncated: false, earliestAvailableSequence: 0, historyGap: false };
}
return { events: [], truncated: false, earliestAvailableSequence: 0, historyGap: false };
}
type SessionWatchOptions = Pick<OpenClawStateDatabaseOptions, "path" | "env"> & {
now?: number;
assertCurrent?: () => void;
sessionEntriesCurrent?: SessionEntriesCurrentCheck;
};
type PreparedSessionWatchCaller = Pick<
SessionWatchOptions,
"assertCurrent" | "sessionEntriesCurrent"
> & {
release(): void;
};
type SessionWatchRegistrationOptions =
| (SessionWatchOptions & { prepareCurrent?: undefined })
| (Pick<SessionWatchOptions, "path" | "env" | "now"> & {
prepareCurrent(): Promise<PreparedSessionWatchCaller>;
assertCurrent?: never;
sessionEntriesCurrent?: never;
});
function runSessionWatchOperation<T>(
context: ReturnType<typeof captureOpenClawStateWorkerContext>,
operation: Parameters<typeof runOpenClawStateWorkerOperation<T>>[1],
assertCurrent: () => void,
sessionEntriesCurrent?: SessionEntriesCurrentCheck,
): Promise<T> {
return runOpenClawStateWorkerOperation(context, operation, {
assertCurrent,
createAdmission: () => ({
nativeLocations: [context.admission.databasePath],
admission: createSqliteWorkerOperationAdmission((request, grant) => {
if (
request.stage !== "prepare" &&
request.stage !== "transaction" &&
request.stage !== "commit"
) {
throw new Error("Session watch operation requires worker admission");
}
context.admission.assertCurrent();
assertSessionEntriesCurrentAdmission(request, sessionEntriesCurrent);
assertCurrent();
grant();
}),
}),
});
}
/** Ack only the frozen notice watermark; advancing to head would lose an interleaved event. */
export function acknowledgeSessionStateNotices(
export async function acknowledgeSessionStateNotices(
watcherSessionKey: string,
targetSessionKeys: readonly string[],
options: OpenClawStateDatabaseOptions & { now?: number } = {},
): void {
const now = options.now ?? Date.now();
const followups: SessionStateNotice[] = [];
notices: readonly SessionStateWatchAddress[],
options: SessionWatchOptions = {},
): Promise<void> {
try {
runOpenClawStateWriteTransaction(({ db }) => {
for (const targetSessionKey of new Set(targetSessionKeys)) {
const row = readCursor(db, watcherSessionKey, targetSessionKey);
if (!row || !isSystemEventStoreCurrent(watcherSessionKey, row.watcher_store_path ?? null)) {
continue;
}
const notified = normalizeSqliteNumber(row.notified_sequence) ?? 0;
const material = normalizeSqliteNumber(row.material_sequence) ?? 0;
const nextNotified = material > notified ? material : notified;
executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.updateTable("session_watch_cursors")
.set({
last_seen_sequence: notified,
notified_sequence: nextNotified,
updated_at: now,
})
.where("watcher_session_key", "=", watcherSessionKey)
.where("target_session_key", "=", targetSessionKey),
);
if (material > notified) {
followups.push({
watcherSessionKey,
watcherStorePath: row.watcher_store_path ?? null,
targetSessionKey,
lastSeenSequence: notified,
queueOnly: isAmbientGroupWatchCursor(row),
});
const context = captureOpenClawStateWorkerContext(options);
const now = options.now ?? Date.now();
const isStoreCurrent = captureSystemEventStoreCurrentCheck(watcherSessionKey);
const cursors = [
...new Map(
notices
.filter((notice) => isStoreCurrent(notice.watcherStorePath))
.map((notice) => [notice.targetSessionKey, { ...notice }]),
).values(),
];
const assertCurrent = () => {
options.assertCurrent?.();
for (const cursor of cursors) {
if (!isStoreCurrent(cursor.watcherStorePath)) {
throw new Error("Session watch acknowledgment lost its system-event store");
}
}
}, options);
for (const followup of followups) {
enqueueSessionStateNotice(followup);
}
};
await runSessionWatchOperation(
context,
async (scope) => {
if (cursors.length === 0) {
return;
}
const followups = await scope.execute({
type: "sessionState.acknowledge",
input: {
watcherSessionKey,
cursors,
now,
sessionEntryCurrentSources: options.sessionEntriesCurrent?.sources,
},
});
for (const followup of followups) {
enqueueSessionStateNotice(followup);
}
},
assertCurrent,
options.sessionEntriesCurrent,
);
} catch (error) {
log.warn(`failed to acknowledge session state notices: ${String(error)}`);
}
@ -556,122 +563,111 @@ export function listAmbientGroupWatchTargets(
}
}
/** Register an explicit watcher (e.g. a sessions_send coordinator) for a target session. */
export function registerSessionStateWatch(
async function registerWatch(
params: { watcherSessionKey: string; targetSessionKey: string; targetAgentId?: string },
options: OpenClawStateDatabaseOptions & { now?: number } = {},
): boolean {
provenance: SessionWatchCursorProvenance,
options: SessionWatchRegistrationOptions,
): Promise<boolean> {
if (
params.watcherSessionKey === params.targetSessionKey ||
!isNotifiableWatcherKey(params.watcherSessionKey)
) {
return false;
}
const now = options.now ?? Date.now();
let prepared: PreparedSessionWatchCaller | undefined;
try {
const watcherStorePath = resolvePhysicalSessionStorePath({
sessionKey: params.watcherSessionKey,
env: options.env,
});
let registered = false;
runOpenClawStateWriteTransaction(({ db }) => {
// Re-watching must not clobber pending-notice cursor state.
const existing = readCursor(db, params.watcherSessionKey, params.targetSessionKey);
if (existing?.watcher_store_path === watcherStorePath) {
if (existing.provenance !== SESSION_WATCH_PROVENANCE_EXPLICIT) {
executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.updateTable("session_watch_cursors")
.set({ provenance: SESSION_WATCH_PROVENANCE_EXPLICIT })
.where("watcher_session_key", "=", params.watcherSessionKey)
.where("target_session_key", "=", params.targetSessionKey),
);
}
registered = true;
return;
const context = captureOpenClawStateWorkerContext(options);
const isStoreCurrent = captureSystemEventStoreCurrentCheck(params.watcherSessionKey);
const input = {
...params,
targetAgentId: params.targetAgentId ?? resolveAgentIdFromSessionKey(params.targetSessionKey),
provenance,
now: options.now ?? Date.now(),
};
const watcherStorePath = await (prepareSystemEventStorePath(input.watcherSessionKey) ??
preparePhysicalSessionStorePath({
sessionKey: input.watcherSessionKey,
env: context.initializationEnvironment,
}));
context.admission.assertCurrent();
prepared = await options.prepareCurrent?.();
const caller = prepared ?? options;
context.admission.assertCurrent();
const assertCurrent = () => {
caller.assertCurrent?.();
if (!isStoreCurrent(watcherStorePath)) {
throw new Error("Session watch registration lost its system-event store");
}
const agentId = params.targetAgentId ?? resolveAgentIdFromSessionKey(params.targetSessionKey);
const sequence = readSessionStateSequence(db, params.targetSessionKey, agentId);
// Seed at the current head: the watcher is synced now; only future changes notify.
upsertSeedCursor({
db,
watcherSessionKey: params.watcherSessionKey,
watcherStorePath,
targetSessionKey: params.targetSessionKey,
sequence,
now,
});
registered = true;
}, options);
return registered;
};
return await runSessionWatchOperation(
context,
async (scope) => {
const registered = await scope.execute({
type: "sessionState.registerWatch",
input: {
...input,
watcherStorePath,
sessionEntryCurrentSources: caller.sessionEntriesCurrent?.sources,
},
});
assertCurrent();
return registered;
},
assertCurrent,
caller.sessionEntriesCurrent,
);
} catch (error) {
log.warn(`failed to register session state watch: ${String(error)}`);
return false;
} finally {
prepared?.release();
}
}
/** Register an explicit watcher (e.g. a sessions_send coordinator) for a target session. */
export function registerSessionStateWatch(
params: { watcherSessionKey: string; targetSessionKey: string; targetAgentId?: string },
options: SessionWatchRegistrationOptions = {},
): Promise<boolean> {
return registerWatch(params, SESSION_WATCH_PROVENANCE_EXPLICIT, options);
}
/** Register the agent's main session to observe one routed group session. */
export function registerMainSessionGroupWatch(
export async function registerMainSessionGroupWatch(
params: {
sessionKey: string;
agentId: string;
entry?: SessionEntry;
mainKey?: string;
isSystemEvent?: boolean;
inputProvenance?: InputProvenance;
signal?: AbortSignal;
},
options: OpenClawStateDatabaseOptions & { now?: number } = {},
): boolean {
if (classifySessionKind(params.sessionKey, params.entry) !== "group") {
options: SessionWatchOptions = {},
): Promise<boolean> {
if (
params.isSystemEvent ||
classifySessionStateActor(params).actorType !== "human" ||
classifySessionKind(params.sessionKey, params.entry) !== "group"
) {
return false;
}
const watcherSessionKey = buildAgentMainSessionKey({
agentId: params.agentId,
mainKey: params.mainKey,
});
// groupScope already chose the routed key: "main" is the watcher itself,
// while every distinct group key is a per-group target. dmScope is orthogonal.
if (params.sessionKey === watcherSessionKey) {
return false;
}
const now = options.now ?? Date.now();
try {
const watcherStorePath = resolvePhysicalSessionStorePath({
sessionKey: watcherSessionKey,
env: options.env,
});
const { db: readDb } = openOpenClawStateDatabase(options);
// This runs on every human group turn. Keep the steady-state path read-only;
// the transaction below is only for first registration and its race recheck.
const current = readCursor(readDb, watcherSessionKey, params.sessionKey);
if (current?.watcher_store_path === watcherStorePath) {
return true;
}
let registered = false;
runOpenClawStateWriteTransaction(({ db }) => {
const existing = readCursor(db, watcherSessionKey, params.sessionKey);
if (existing?.watcher_store_path === watcherStorePath) {
// An explicit watch already owns this pair. Do not downgrade it when
// later human group turns revisit registration.
registered = true;
return;
}
const sequence = readSessionStateSequence(db, params.sessionKey, params.agentId);
upsertSeedCursor({
db,
watcherSessionKey,
watcherStorePath,
targetSessionKey: params.sessionKey,
sequence,
now,
provenance: SESSION_WATCH_PROVENANCE_AMBIENT_GROUP,
});
registered = true;
}, options);
return registered;
} catch (error) {
log.warn(`failed to register ambient group watch: ${String(error)}`);
return false;
}
// A group routed into main already shares its conversation; dmScope is orthogonal.
return registerWatch(
{ watcherSessionKey, targetSessionKey: params.sessionKey, targetAgentId: params.agentId },
SESSION_WATCH_PROVENANCE_AMBIENT_GROUP,
{
...options,
assertCurrent: () => {
params.signal?.throwIfAborted();
options.assertCurrent?.();
},
},
);
}
export async function recordSessionHumanDirectMessage(

View file

@ -0,0 +1,15 @@
import type { SessionStateActorType, SessionStateEventKind } from "./session-state-event-kinds.js";
export type SessionStateEventRecord = {
sequence: number;
sessionKey: string;
sessionId?: string;
agentId: string;
kind: SessionStateEventKind;
actorType: SessionStateActorType;
actorId?: string;
runId?: string;
occurredAt: number;
summary: string;
payload?: Record<string, unknown>;
};

View file

@ -0,0 +1,435 @@
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, expect, it, vi } from "vitest";
import { createDeferred } from "../../test/helpers/promise.js";
import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/io.js";
import { upsertSessionEntryCore } from "../config/sessions/session-accessor.js";
import {
publishSystemEventStoreConfig,
resolvePhysicalSessionStorePath,
} from "../config/sessions/session-store-path.js";
import * as workerAdmission from "../infra/sqlite-worker-operation-admission.js";
import { publishSystemEventStoreResolver } from "../infra/system-event-ownership.js";
import { enqueueSystemEvent, peekSystemEventEntries } from "../infra/system-events.js";
import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js";
import { captureOpenClawStateWorkerContext } from "../state/openclaw-state-worker-context.js";
import { runOpenClawStateWorkerOperation } from "../state/openclaw-state-worker-store.js";
import { observeMainThreadSql } from "../test-utils/main-thread-sql-spies.test-support.js";
import {
acknowledgeSessionStateNotices,
getSessionStateVersion,
getSessionStateVersions,
listSessionStateEventsSince,
recordSessionStateEvent,
registerMainSessionGroupWatch,
registerSessionStateWatch,
} from "./session-state-events.js";
import {
child,
cleanupSessionStateTestState,
createDatabaseOptions,
eventInput,
nestedWatcher,
readCursor,
watcher,
} from "./session-state-events.test-support.js";
import * as notices from "./session-state-notices.js";
afterEach(async () => {
vi.restoreAllMocks();
publishSystemEventStoreResolver(undefined);
clearRuntimeConfigSnapshot();
await cleanupSessionStateTestState();
});
it("discovers a cold custom watcher store without caller-thread SQL", async () => {
const database = createDatabaseOptions();
const storePath = path.join(database.env.OPENCLAW_STATE_DIR, "custom", "sessions.json");
const cfg = { session: { store: storePath } };
await upsertSessionEntryCore(
{ sessionKey: watcher, storePath, env: database.env },
{ sessionId: "custom-watcher", updatedAt: 1 },
);
setRuntimeConfigSnapshot(cfg);
publishSystemEventStoreConfig(cfg);
const sql = observeMainThreadSql();
try {
sql.calibrate();
expect(
await registerSessionStateWatch(
{ watcherSessionKey: watcher, targetSessionKey: child },
database,
),
).toBe(true);
expect(sql.count()).toBe(0);
expect(
await registerMainSessionGroupWatch(
{ sessionKey: "agent:main:telegram:group:custom", agentId: "main" },
database,
),
).toBe(true);
expect(sql.count()).toBe(0);
} finally {
sql.restore();
}
expect(
openOpenClawStateDatabase(database)
.db.prepare("SELECT DISTINCT watcher_store_path FROM session_watch_cursors")
.all(),
).toEqual([{ watcher_store_path: path.join(path.dirname(storePath), "openclaw-agent.sqlite") }]);
});
it("does not acknowledge a replacement store from an older consumed notice", async () => {
const database = createDatabaseOptions();
const originalStore = resolvePhysicalSessionStorePath({
sessionKey: nestedWatcher,
env: database.env,
});
let currentStore = originalStore;
publishSystemEventStoreResolver(() => currentStore);
expect(
await registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey: child },
database,
),
).toBe(true);
recordSessionStateEvent(eventInput({ watcherSessionKeys: [] }), database);
const before = readCursor(database, nestedWatcher);
const entered = createDeferred();
const release = createDeferred();
const blocking = runOpenClawStateWorkerOperation(
captureOpenClawStateWorkerContext(database),
async () => {
entered.resolve();
await release.promise;
},
);
let draining: Promise<string | undefined> | undefined;
try {
await entered.promise;
draining = drainFormattedSystemEvents({
cfg: {},
agentId: "main",
sessionKey: nestedWatcher,
isMainSession: false,
isNewSession: false,
});
expect(peekSystemEventEntries(nestedWatcher)).toHaveLength(0);
currentStore = `${originalStore}.replacement`;
openOpenClawStateDatabase(database)
.db.prepare(
"UPDATE session_watch_cursors SET watcher_store_path = ? WHERE watcher_session_key = ?",
)
.run(currentStore, nestedWatcher);
release.resolve();
await blocking;
expect(await draining).toBeUndefined();
expect(readCursor(database, nestedWatcher)).toEqual(before);
expect(peekSystemEventEntries(nestedWatcher)).toHaveLength(0);
} finally {
release.resolve();
await blocking;
await draining;
}
});
it("preserves consumed events across a same-store resolver handoff while acknowledgment waits", async () => {
const database = createDatabaseOptions();
const storePath = resolvePhysicalSessionStorePath({
sessionKey: nestedWatcher,
env: database.env,
});
publishSystemEventStoreResolver(() => storePath);
expect(
await registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey: child },
database,
),
).toBe(true);
recordSessionStateEvent(eventInput({ watcherSessionKeys: [] }), database);
enqueueSystemEvent("ordinary queued event", { sessionKey: nestedWatcher });
const entered = createDeferred();
const release = createDeferred();
const blocking = runOpenClawStateWorkerOperation(
captureOpenClawStateWorkerContext(database),
async () => {
entered.resolve();
await release.promise;
},
);
let draining: Promise<string | undefined> | undefined;
try {
await entered.promise;
draining = drainFormattedSystemEvents({
cfg: {},
agentId: "main",
sessionKey: nestedWatcher,
isMainSession: false,
isNewSession: false,
});
expect(peekSystemEventEntries(nestedWatcher)).toHaveLength(0);
publishSystemEventStoreResolver(() => storePath);
release.resolve();
await blocking;
const formatted = await draining;
expect(formatted).toContain("ordinary queued event");
expect(formatted).toContain(`Session "${child}" changed`);
} finally {
release.resolve();
await blocking;
await draining;
}
});
it("reads session state and commits watch registration and acknowledgment without caller-thread SQL", async () => {
const database = createDatabaseOptions();
const group = "agent:main:telegram:group:worker-boundary";
const events = Array.from({ length: 201 }, (_, index) =>
expectDefined(
recordSessionStateEvent(
eventInput({
sessionKey: "global",
watcherSessionKeys: [],
summary: `main event ${index}`,
}),
database,
),
"seeded main event",
),
);
const mainHead = expectDefined(events.at(-1), "main head");
const opsHead = expectDefined(
recordSessionStateEvent(
eventInput({ sessionKey: "global", agentId: "ops", watcherSessionKeys: [] }),
database,
),
"seeded ops event",
);
const constructorHead = expectDefined(
recordSessionStateEvent(
eventInput({ sessionKey: "global", agentId: "constructor", watcherSessionKeys: [] }),
database,
),
"seeded constructor-agent event",
);
const sql = observeMainThreadSql();
const measure = async <T>(label: string, operation: () => T | Promise<T>): Promise<T> => {
sql.clear();
const result = await operation();
expect.soft(sql.count(), label).toBe(0);
return result;
};
try {
sql.calibrate();
expect(
await measure("single session version", () =>
getSessionStateVersion("global", "main", database),
),
).toBe(mainHead.sequence);
expect(
await measure("prototype-named agent version", () =>
getSessionStateVersion("global", "constructor", database),
),
).toBe(constructorHead.sequence);
expect(
await measure("composite session versions", () =>
getSessionStateVersions(
[
{ sessionKey: "global", agentId: "main" },
{ sessionKey: "global", agentId: "ops" },
{ sessionKey: "global", agentId: "constructor" },
],
database,
),
),
).toEqual({
main: { global: mainHead.sequence },
ops: { global: opsHead.sequence },
constructor: { global: constructorHead.sequence },
});
const page = await measure("bounded event page", () =>
listSessionStateEventsSince("global", "main", 0, 500, database),
);
expect(page.events).toHaveLength(200);
expect(page.events[0]?.summary).toBe("main event 0");
expect(page.events.at(-1)?.summary).toBe("main event 199");
expect(page.truncated).toBe(true);
expect(page.historyGap).toBe(false);
expect(
await measure("explicit watch registration", () =>
registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey: child },
database,
),
),
).toBe(true);
const frozen = expectDefined(
recordSessionStateEvent(eventInput({ watcherSessionKeys: [] }), database),
"frozen child notification",
);
const interleaved = expectDefined(
recordSessionStateEvent(eventInput({ watcherSessionKeys: [] }), database),
"interleaved child event",
);
const watcherStorePath = peekSystemEventEntries(nestedWatcher)[0]?.sessionStorePath ?? null;
await measure("explicit watch acknowledgment", () =>
acknowledgeSessionStateNotices(
nestedWatcher,
[{ targetSessionKey: child, watcherStorePath }],
database,
),
);
expect(readCursor(database, nestedWatcher)).toEqual({
last_seen_sequence: frozen.sequence,
notified_sequence: interleaved.sequence,
material_sequence: interleaved.sequence,
});
for (const label of ["initial group watch", "existing group watch"]) {
expect(
await measure(label, () =>
registerMainSessionGroupWatch({ sessionKey: group, agentId: "main" }, database),
),
).toBe(true);
}
const groupFrozen = expectDefined(
recordSessionStateEvent(eventInput({ sessionKey: group, watcherSessionKeys: [] }), database),
"frozen group notification",
);
const groupInterleaved = expectDefined(
recordSessionStateEvent(eventInput({ sessionKey: group, watcherSessionKeys: [] }), database),
"interleaved group event",
);
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
expect(
await measure("system-event drain acknowledgment", () =>
drainFormattedSystemEvents({
cfg: {},
agentId: "main",
sessionKey: watcher,
isMainSession: false,
isNewSession: false,
}),
),
).toContain(`Session "${group}" changed`);
expect(readCursor(database, watcher, group)).toEqual({
last_seen_sequence: groupFrozen.sequence,
notified_sequence: groupInterleaved.sequence,
material_sequence: groupInterleaved.sequence,
});
expect(peekSystemEventEntries(watcher)).toHaveLength(1);
expect(peekSystemEventEntries(watcher)[0]?.text).toContain(
`changesSince ${groupFrozen.sequence}`,
);
} finally {
sql.restore();
}
});
it("rolls back watch writes when the system-event store changes at transaction or commit admission", async () => {
const database = createDatabaseOptions();
const originalStore = resolvePhysicalSessionStorePath({
sessionKey: nestedWatcher,
env: database.env,
});
let currentStore = originalStore;
publishSystemEventStoreResolver(() => currentStore);
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
for (const operation of ["register", "acknowledge"] as const) {
for (const stage of ["transaction", "commit"] as const) {
currentStore = originalStore;
const targetSessionKey = `${child}-${operation}-${stage}`;
if (operation === "acknowledge") {
expect(
await registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey },
database,
),
).toBe(true);
for (let index = 0; index < 2; index++) {
recordSessionStateEvent(
eventInput({ sessionKey: targetSessionKey, watcherSessionKeys: [] }),
database,
);
}
}
const before = readCursor(database, nestedWatcher, targetSessionKey);
const notice = vi.spyOn(notices, "enqueueSessionStateNotice");
let witnessed = false;
const admission = vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission((request, grant) => {
if (request.stage === stage) {
witnessed = true;
currentStore = `${originalStore}.replacement`;
}
admit(request, grant);
}, attachment),
);
try {
if (operation === "register") {
expect(
await registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey },
database,
),
).toBe(false);
} else {
await acknowledgeSessionStateNotices(
nestedWatcher,
[{ targetSessionKey, watcherStorePath: originalStore }],
database,
);
}
expect(witnessed, `${operation} ${stage} grant`).toBe(true);
expect(readCursor(database, nestedWatcher, targetSessionKey)).toEqual(before);
expect(notice).not.toHaveBeenCalled();
} finally {
admission.mockRestore();
notice.mockRestore();
}
}
}
});
it("refuses a replaced owner before invoking its cold store discovery at commit", async () => {
const database = createDatabaseOptions();
openOpenClawStateDatabase(database);
const originalStore = resolvePhysicalSessionStorePath({
sessionKey: nestedWatcher,
env: database.env,
});
publishSystemEventStoreResolver(() => originalStore);
const replacementDiscovery = vi.fn(() => {
throw new Error("A retired admission must not invoke replacement store discovery");
});
const createAdmission = workerAdmission.createSqliteWorkerOperationAdmission;
let witnessed = false;
const admission = vi
.spyOn(workerAdmission, "createSqliteWorkerOperationAdmission")
.mockImplementation((admit, attachment) =>
createAdmission((request, grant) => {
if (request.stage === "commit") {
witnessed = true;
publishSystemEventStoreResolver(replacementDiscovery);
}
admit(request, grant);
}, attachment),
);
try {
expect(
await registerSessionStateWatch(
{ watcherSessionKey: nestedWatcher, targetSessionKey: child },
database,
),
).toBe(false);
expect(witnessed).toBe(true);
expect(replacementDiscovery).not.toHaveBeenCalled();
expect(readCursor(database, nestedWatcher)).toBeUndefined();
} finally {
admission.mockRestore();
}
});

View file

@ -1,5 +1,6 @@
import type { AcpSessionControlConstraint } from "../acp/runtime/session-meta-control.types.js";
import type { SessionEntryCurrentSource } from "../config/sessions/session-entry-current.types.js";
import type { SessionWatchCursorProvenance } from "../state/session-watch-cursor-provenance.js";
import type {
SessionStateEventInput,
SessionStateEventRow,
@ -7,7 +8,33 @@ import type {
} from "./session-state-events.kernel.js";
import type { SessionUpstreamLink } from "./session-upstream-links.kernel.js";
export type SessionStateWatchAddress = {
targetSessionKey: string;
watcherStorePath: string | null;
};
export type SessionStateWorkerOperations = {
"sessionState.registerWatch": {
input: {
watcherSessionKey: string;
watcherStorePath: string;
targetSessionKey: string;
targetAgentId: string;
provenance: SessionWatchCursorProvenance;
now: number;
sessionEntryCurrentSources?: readonly SessionEntryCurrentSource[];
};
output: boolean;
};
"sessionState.acknowledge": {
input: {
watcherSessionKey: string;
cursors: readonly SessionStateWatchAddress[];
now: number;
sessionEntryCurrentSources?: readonly SessionEntryCurrentSource[];
};
output: SessionStateNotice[];
};
"sessionState.record": {
input: {
event: SessionStateEventInput;

View file

@ -1,23 +1,129 @@
import { readAcpSessionControlInWorker } from "../acp/runtime/session-meta-source.worker.js";
import { requestSessionEntryCurrentAdmission } from "../config/sessions/session-entry-current-admission.worker.js";
import {
requestSessionEntriesCurrentAdmission,
requestSessionEntryCurrentAdmission,
} from "../config/sessions/session-entry-current-admission.worker.js";
import { executeSqliteQuerySync } from "../infra/kysely-sync.js";
import { normalizeSqliteNumber } from "../infra/sqlite-number.js";
import type { SqliteWorkerCommand } from "../infra/sqlite-worker-contract.js";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
} from "../state/openclaw-state-db.js";
import { SESSION_WATCH_PROVENANCE_EXPLICIT } from "../state/session-watch-cursor-provenance.js";
import {
getSessionStateKysely,
hasSessionStateWatchersInDatabase,
isAmbientGroupWatchCursor,
isSessionStateUpstreamCurrentInDatabase,
pruneSessionStateEventsInDatabase,
readCursor,
recordSessionStateEventInDatabase,
upsertSeedCursor,
type SessionStateNotice,
} from "./session-state-events.kernel.js";
import { readSessionStateSequence } from "./session-state-events.read.worker.js";
import type { SessionStateWorkerOperations } from "./session-state-events.worker-contract.js";
export function executeSessionStateCommand(
command: SqliteWorkerCommand<SessionStateWorkerOperations>,
options: OpenClawStateDatabaseOptions & { database: OpenClawStateDatabase },
): SessionStateWorkerOperations[keyof SessionStateWorkerOperations]["output"] {
if (command.type === "sessionState.registerWatch") {
const input = command.input;
const admit = (stage: "prepare" | "transaction" | "commit") =>
requestSessionEntriesCurrentAdmission(input.sessionEntryCurrentSources, {
stage,
facts: undefined,
});
const current = readCursor(
options.database.db,
input.watcherSessionKey,
input.targetSessionKey,
);
// Every human group turn reaches registration; an unchanged watch stays write-free.
if (
current?.watcher_store_path === input.watcherStorePath &&
(input.provenance !== SESSION_WATCH_PROVENANCE_EXPLICIT ||
current.provenance === input.provenance)
) {
if (input.sessionEntryCurrentSources?.length) {
admit("prepare");
}
return true;
}
return runOpenClawStateWriteTransaction(({ db }) => {
admit("transaction");
const existing = readCursor(db, input.watcherSessionKey, input.targetSessionKey);
if (existing?.watcher_store_path === input.watcherStorePath) {
if (
input.provenance === SESSION_WATCH_PROVENANCE_EXPLICIT &&
existing.provenance !== input.provenance
) {
executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.updateTable("session_watch_cursors")
.set({ provenance: input.provenance })
.where("watcher_session_key", "=", input.watcherSessionKey)
.where("target_session_key", "=", input.targetSessionKey),
);
}
} else {
upsertSeedCursor({
db,
...input,
sequence: readSessionStateSequence(db, input.targetSessionKey, input.targetAgentId),
});
}
admit("commit");
return true;
}, options);
}
if (command.type === "sessionState.acknowledge") {
const { watcherSessionKey, cursors, now } = command.input;
const admit = (stage: "transaction" | "commit") =>
requestSessionEntriesCurrentAdmission(command.input.sessionEntryCurrentSources, {
stage,
facts: undefined,
});
return runOpenClawStateWriteTransaction(({ db }) => {
admit("transaction");
const followups: SessionStateNotice[] = [];
for (const { targetSessionKey, watcherStorePath } of cursors) {
const row = readCursor(db, watcherSessionKey, targetSessionKey);
if (!row || row.watcher_store_path !== watcherStorePath) {
continue;
}
const notified = normalizeSqliteNumber(row.notified_sequence) ?? 0;
const material = normalizeSqliteNumber(row.material_sequence) ?? 0;
executeSqliteQuerySync(
db,
getSessionStateKysely(db)
.updateTable("session_watch_cursors")
.set({
last_seen_sequence: notified,
notified_sequence: Math.max(material, notified),
updated_at: now,
})
.where("watcher_session_key", "=", watcherSessionKey)
.where("target_session_key", "=", targetSessionKey),
);
if (material > notified) {
followups.push({
watcherSessionKey,
watcherStorePath,
targetSessionKey,
lastSeenSequence: notified,
queueOnly: isAmbientGroupWatchCursor(row),
});
}
}
admit("commit");
return followups;
}, options);
}
const admit = (stage: "transaction" | "commit") =>
requestSessionEntryCurrentAdmission(command.input.sessionEntryCurrentSource, {
stage,

View file

@ -62,13 +62,13 @@ describe("session upstream links", () => {
upsertLink(watched, "claude", database);
upsertLink(unwatched, "codex", database);
expect(
registerSessionStateWatch(
await registerSessionStateWatch(
{ watcherSessionKey: "agent:main:main", targetSessionKey: watched },
database,
),
).toBe(true);
expect(
registerSessionStateWatch(
await registerSessionStateWatch(
{ watcherSessionKey: "agent:other:main", targetSessionKey: watched },
database,
),
@ -91,7 +91,7 @@ describe("session upstream links", () => {
),
).toBe(true);
expect(
registerSessionStateWatch(
await registerSessionStateWatch(
{ watcherSessionKey: "agent:main:main", targetSessionKey: ambiguous },
database,
),
@ -174,7 +174,7 @@ describe("session upstream links", () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:refresh";
upsertLink(sessionKey, "claude", database);
registerSessionStateWatch(
await registerSessionStateWatch(
{ watcherSessionKey: "agent:main:main", targetSessionKey: sessionKey },
database,
);

View file

@ -40,7 +40,7 @@ function createDatabaseOptions() {
return { env: { ...process.env, OPENCLAW_STATE_DIR: stateDir } };
}
function createLink(
async function createLink(
sessionKey: string,
catalogId: string,
database: ReturnType<typeof createDatabaseOptions>,
@ -60,7 +60,7 @@ function createLink(
database,
);
if (watched) {
registerSessionStateWatch({ watcherSessionKey, targetSessionKey: sessionKey }, database);
await registerSessionStateWatch({ watcherSessionKey, targetSessionKey: sessionKey }, database);
}
}
@ -87,7 +87,7 @@ afterEach(async () => {
describe("session upstream monitor", () => {
it("discards discovery after the monitor is aborted", async () => {
const database = createDatabaseOptions();
createLink("agent:main:adopted:aborted-discovery", "claude", database);
await createLink("agent:main:adopted:aborted-discovery", "claude", database);
const lifecycle = new AbortController();
const loadEntry = vi.fn(() => ({ sessionId: "session-aborted", updatedAt: 100 }));
const check = vi.fn(async () => []);
@ -116,8 +116,8 @@ describe("session upstream monitor", () => {
const database = createDatabaseOptions();
const stale = "agent:main:adopted:a-stale";
const healthy = "agent:main:adopted:b-healthy";
createLink(stale, "claude", database);
createLink(healthy, "claude", database);
await createLink(stale, "claude", database);
await createLink(healthy, "claude", database);
const settlement = vi
.spyOn(upstreamRuntime, "settleSessionUpstreamLink")
.mockRejectedValueOnce(new Error("Upstream observation lost its idle session owner"));
@ -148,7 +148,7 @@ describe("session upstream monitor", () => {
it("keeps the upstream marker available when its durable event insert fails", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:failed-event";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
openOpenClawStateDatabase(database).db.exec(`
CREATE TRIGGER reject_upstream_event BEFORE INSERT ON session_state_events
BEGIN SELECT RAISE(FAIL, 'synthetic event write failure'); END;
@ -171,15 +171,17 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => [],
});
expect(readSessionUpstreamLink(sessionKey, "main", database)?.marker).toEqual({ offset: 0 });
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
});
it("records watched activity once and advances its marker without host SQL", async () => {
const database = createDatabaseOptions();
const watched = "agent:main:adopted:watched";
const unwatched = "agent:main:adopted:unwatched";
createLink(watched, "claude", database);
createLink(unwatched, "claude", database, false);
await createLink(watched, "claude", database);
await createLink(unwatched, "claude", database, false);
const checkUpstreamActivity = vi.fn(async (probes: SessionUpstreamProbe[]) =>
probes.map((probe) => ({
kind: "activity" as const,
@ -221,7 +223,7 @@ describe("session upstream monitor", () => {
[expect.objectContaining({ sessionKey: watched, marker: { offset: 8 } })],
{ allowProcessHomeFallback: false },
);
const events = listSessionStateEventsSince(watched, "main", 0, 20, database).events;
const events = (await listSessionStateEventsSince(watched, "main", 0, 20, database)).events;
expect(events).toHaveLength(1);
expect(events[0]).toEqual(
expect.objectContaining({
@ -242,7 +244,7 @@ describe("session upstream monitor", () => {
it("records one upstream-missing event after three misses and removes the link", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const check = vi.fn(async (probes: SessionUpstreamProbe[]) =>
probes.map((probe) => ({ kind: "missing" as const, sessionKey: probe.sessionKey })),
);
@ -263,20 +265,22 @@ describe("session upstream monitor", () => {
expect(check).toHaveBeenCalledTimes(3);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeUndefined();
expect(missingCounts.size).toBe(0);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([
expect.objectContaining({
kind: "upstream_missing",
actorType: "system",
summary: "upstream missing via claude",
payload: { channel: "claude" },
}),
]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[
expect.objectContaining({
kind: "upstream_missing",
actorType: "system",
summary: "upstream missing via claude",
payload: { channel: "claude" },
}),
],
);
});
it("defers a third missing result when a run starts during the provider scan", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-active-race";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let active = false;
let scan = 0;
const check = vi.fn(async () => {
@ -300,7 +304,9 @@ describe("session upstream monitor", () => {
await runSessionUpstreamMonitorTick(options, missingCounts);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeDefined();
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect([...missingCounts.values()].map((counter) => counter.count)).toEqual([2]);
active = false;
@ -308,15 +314,15 @@ describe("session upstream monitor", () => {
expect(check).toHaveBeenCalledTimes(4);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeUndefined();
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([
expect.objectContaining({ kind: "upstream_missing" }),
]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[expect.objectContaining({ kind: "upstream_missing" })],
);
});
it("resets a missing streak when the session is replaced during the provider scan", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-session-replaced";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let sessionId = "session-before";
let scan = 0;
const check = vi.fn(async () => {
@ -341,7 +347,9 @@ describe("session upstream monitor", () => {
expect(missingCounts.size).toBe(0);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeDefined();
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
await runSessionUpstreamMonitorTick(options, missingCounts);
await runSessionUpstreamMonitorTick(options, missingCounts);
@ -357,7 +365,7 @@ describe("session upstream monitor", () => {
const scheduler = createTestGatewayScheduler(clock.clock);
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-stopped";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const thirdResult = createDeferred<Array<{ kind: "missing"; sessionKey: string }>>();
const scanStarted = [createDeferred(), createDeferred(), createDeferred()] as const;
let scan = 0;
@ -405,7 +413,9 @@ describe("session upstream monitor", () => {
expect(readSessionUpstreamLink(sessionKey, "main", database)).toMatchObject({
marker: { offset: 0 },
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect(
(await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events,
).toEqual([]);
if (stopOwner === "monitor") {
const sibling = vi.fn();
scheduler.schedule({ id: "sibling", delayMs: 1, run: sibling });
@ -424,7 +434,7 @@ describe("session upstream monitor", () => {
it("resets consecutive misses on activity", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-reset";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let scan = 0;
const check = vi.fn(async () => {
scan += 1;
@ -452,7 +462,9 @@ describe("session upstream monitor", () => {
}
expect(check).toHaveBeenCalledTimes(5);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeDefined();
expect([...missingCounts.values()].map((counter) => counter.count)).toEqual([2]);
});
@ -460,7 +472,7 @@ describe("session upstream monitor", () => {
it("breaks a missing streak when a successful probe has no missing outcome", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-quiet";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let scan = 0;
const check = vi.fn(async () => {
scan += 1;
@ -478,7 +490,9 @@ describe("session upstream monitor", () => {
await runSessionUpstreamMonitorTick(options, missingCounts);
}
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeDefined();
expect([...missingCounts.values()].map((counter) => counter.count)).toEqual([2]);
});
@ -486,7 +500,7 @@ describe("session upstream monitor", () => {
it("starts a fresh streak when Continue refreshes the same source", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-same-source";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const check = vi.fn(async () => [{ kind: "missing" as const, sessionKey }]);
const options = {
...database,
@ -513,7 +527,9 @@ describe("session upstream monitor", () => {
);
await runSessionUpstreamMonitorTick(options, missingCounts);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toBeDefined();
expect([...missingCounts.values()].map((counter) => counter.count)).toEqual([1]);
});
@ -521,7 +537,7 @@ describe("session upstream monitor", () => {
it("aborts missing record and deletion when Continue changes the source", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-refreshed";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let scan = 0;
const check = vi.fn(async () => {
scan += 1;
@ -554,7 +570,9 @@ describe("session upstream monitor", () => {
await runSessionUpstreamMonitorTick(options, missingCounts);
await runSessionUpstreamMonitorTick(options, missingCounts);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect(readSessionUpstreamLink(sessionKey, "main", database)).toEqual(
expect.objectContaining({ threadId: "thread-refreshed", marker: { offset: 999 } }),
);
@ -564,7 +582,7 @@ describe("session upstream monitor", () => {
it("prunes missing counters when a link leaves the watched set", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:missing-pruned";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const check = vi.fn(async () => [{ kind: "missing" as const, sessionKey }]);
const options = {
...database,
@ -586,7 +604,7 @@ describe("session upstream monitor", () => {
it("clamps skewed upstream event times without touching bookkeeping clocks", async () => {
const database = createDatabaseOptions();
const watched = "agent:main:adopted:clamped";
createLink(watched, "claude", database);
await createLink(watched, "claude", database);
const now = 100 * 24 * 60 * 60_000;
const ancient = 1_000; // far beyond the 24h clamp window
const claude = provider("claude", async (probes: SessionUpstreamProbe[]) =>
@ -608,7 +626,7 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => [],
});
const events = listSessionStateEventsSince(watched, "main", 0, 20, database).events;
const events = (await listSessionStateEventsSince(watched, "main", 0, 20, database)).events;
expect(events).toHaveLength(1);
// Event time is clamped into [now - 24h, now]; cursor rows keep the local clock
// so a skewed upstream timestamp cannot age watch state into retention pruning.
@ -622,7 +640,7 @@ describe("session upstream monitor", () => {
it("skips recording and marker writes when the link was refreshed mid-scan", async () => {
const database = createDatabaseOptions();
const watched = "agent:main:adopted:refreshed";
createLink(watched, "claude", database);
await createLink(watched, "claude", database);
const claude = provider("claude", async (probes: SessionUpstreamProbe[]) => {
// Simulate a Continue refreshing the link while the scan is in flight.
upsertSessionUpstreamLink(
@ -656,7 +674,9 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => [],
});
expect(listSessionStateEventsSince(watched, "main", 0, 20, database).events).toHaveLength(0);
expect(
(await listSessionStateEventsSince(watched, "main", 0, 20, database)).events,
).toHaveLength(0);
const row = openOpenClawStateDatabase(database)
.db.prepare("SELECT last_marker_json FROM session_upstream_links WHERE session_key = ?")
.get(watched) as { last_marker_json: string };
@ -667,8 +687,8 @@ describe("session upstream monitor", () => {
const database = createDatabaseOptions();
const broken = "agent:main:adopted:broken";
const healthy = "agent:main:adopted:healthy";
createLink(broken, "claude", database);
createLink(healthy, "claude", database);
await createLink(broken, "claude", database);
await createLink(healthy, "claude", database);
const claude = provider("claude", async (probes: SessionUpstreamProbe[]) =>
probes.map((probe) => ({
kind: "activity" as const,
@ -693,14 +713,18 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => [],
});
expect(listSessionStateEventsSince(broken, "main", 0, 20, database).events).toHaveLength(0);
expect(listSessionStateEventsSince(healthy, "main", 0, 20, database).events).toHaveLength(1);
expect(
(await listSessionStateEventsSince(broken, "main", 0, 20, database)).events,
).toHaveLength(0);
expect(
(await listSessionStateEventsSince(healthy, "main", 0, 20, database)).events,
).toHaveLength(1);
});
it("preserves a coalesced upstream burst count in the event payload", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:burst";
createLink(sessionKey, "codex", database);
await createLink(sessionKey, "codex", database);
await runSessionUpstreamMonitorTick({
...database,
@ -720,19 +744,21 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => [],
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([
expect.objectContaining({
kind: "human_direct_message",
payload: { turns: 3 },
}),
]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[
expect.objectContaining({
kind: "human_direct_message",
payload: { turns: 3 },
}),
],
);
});
it("isolates provider failures", async () => {
const database = createDatabaseOptions();
const codexSession = "agent:main:adopted:codex";
createLink("agent:main:adopted:claude", "claude", database);
createLink(codexSession, "codex", database);
await createLink("agent:main:adopted:claude", "claude", database);
await createLink(codexSession, "codex", database);
const codexCheck = vi.fn(async () => [
{
kind: "activity" as const,
@ -757,15 +783,15 @@ describe("session upstream monitor", () => {
});
expect(codexCheck).toHaveBeenCalledOnce();
expect(listSessionStateEventsSince(codexSession, "main", 0, 20, database).events).toHaveLength(
1,
);
expect(
(await listSessionStateEventsSince(codexSession, "main", 0, 20, database)).events,
).toHaveLength(1);
});
it("defers active runs without advancing their marker", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:active";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const check = vi.fn(async (_probes: SessionUpstreamProbe[]) => []);
const claude = provider("claude", check);
@ -794,7 +820,7 @@ describe("session upstream monitor", () => {
it("defers activity when a run starts during the provider scan", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:active-race";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
let active = false;
const check = vi.fn(async (_probes: SessionUpstreamProbe[]) => {
active = true;
@ -828,7 +854,9 @@ describe("session upstream monitor", () => {
});
expect(check.mock.calls[1]?.[0]).toEqual([expect.objectContaining({ marker: { offset: 0 } })]);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
});
it.each([
@ -847,7 +875,7 @@ describe("session upstream monitor", () => {
])("defers activity when $change during final provenance I/O", async ({ mutate }) => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:provenance-race";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const state = { active: false, sessionId: "session-before" };
const provenanceReadStarted = createDeferred();
const provenanceResult = createDeferred<string[]>();
@ -887,7 +915,9 @@ describe("session upstream monitor", () => {
expect(readSessionUpstreamLink(sessionKey, "main", database)).toEqual(
expect.objectContaining({ marker: { offset: 0 } }),
);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
});
it("supplies provenance text so a matching upstream prompt advances without an event", async () => {
@ -913,7 +943,7 @@ describe("session upstream monitor", () => {
},
},
);
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
const check = vi.fn(async (probes: SessionUpstreamProbe[]) => [
{
kind: "activity" as const,
@ -933,7 +963,9 @@ describe("session upstream monitor", () => {
[expect.objectContaining({ ownRecentUserTexts: ["exact decorated prompt"] })],
{ allowProcessHomeFallback: false },
);
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[],
);
expect(readSessionUpstreamLink(sessionKey, "main", database)?.marker).toEqual({ offset: 20 });
});
@ -958,7 +990,7 @@ describe("session upstream monitor", () => {
agentId: "main",
config: {},
});
createLink(sessionKey, "pi", database);
await createLink(sessionKey, "pi", database);
const check = vi.fn(async (probes: SessionUpstreamProbe[]) => [
{
kind: "activity" as const,
@ -979,15 +1011,15 @@ describe("session upstream monitor", () => {
expect(check).toHaveBeenCalledWith([expect.objectContaining({ ownRecentUserTexts: [] })], {
allowProcessHomeFallback: false,
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toEqual([
expect.objectContaining({ kind: "human_direct_message", summary: "human message via pi" }),
]);
expect((await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events).toEqual(
[expect.objectContaining({ kind: "human_direct_message", summary: "human message via pi" })],
);
});
it("records an external prompt five seconds after OpenClaw activity", async () => {
const database = createDatabaseOptions();
const sessionKey = "agent:main:adopted:recent-external";
createLink(sessionKey, "claude", database);
await createLink(sessionKey, "claude", database);
await runSessionUpstreamMonitorTick({
...database,
@ -1008,6 +1040,8 @@ describe("session upstream monitor", () => {
loadOwnRecentUserTexts: async () => ["OpenClaw prompt"],
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20, database).events).toHaveLength(1);
expect(
(await listSessionStateEventsSince(sessionKey, "main", 0, 20, database)).events,
).toHaveLength(1);
});
});

View file

@ -2,13 +2,20 @@ import type { DatabaseSync } from "node:sqlite";
import type { RestartSentinelReadOperations } from "../infra/restart-sentinel.read.worker-contract.js";
import type { DiagnosticReadOperations } from "../infra/sqlite-audit-record.read-contract.js";
import type { SqliteWorkerCommand } from "../infra/sqlite-worker-contract.js";
import type { SessionStateReadOperations } from "../sessions/session-state-events.read.worker-contract.js";
import { createWorkerOperationRegistry } from "./worker-operation-registry.js";
type Operations = DiagnosticReadOperations & RestartSentinelReadOperations;
type Operations = DiagnosticReadOperations &
RestartSentinelReadOperations &
SessionStateReadOperations;
export type RegisteredStateReadCommand = SqliteWorkerCommand<Operations>;
export type RegisteredStateReadResult = Operations[keyof Operations]["output"];
export const stateReadRegistry = createWorkerOperationRegistry<Operations, DatabaseSync>({
sessionState: () =>
import("../sessions/session-state-events.read.worker.js").then(
(m) => m.sessionStateReadOperations,
),
diagnostic: () =>
import("../infra/sqlite-audit-record.kernel.js").then((m) => m.diagnosticReadOperations),
restartSentinel: () =>

View file

@ -6,6 +6,9 @@ import type {
} from "./openclaw-state-read.types.js";
export function captureCommand(command: OpenClawStateReadCommand): OpenClawStateReadCommand {
if (command.type === "sessionState.versions" || command.type === "sessionState.events") {
return structuredClone(command);
}
if (isWorkspaceJournalReadCommand(command)) {
return command.type === "placementJournals.owners"
? { ...command }
@ -202,6 +205,9 @@ export function captureCommand(command: OpenClawStateReadCommand): OpenClawState
}
function commandBytes(command: OpenClawStateReadRequest["command"]): number {
if (command.type === "sessionState.versions" || command.type === "sessionState.events") {
return Buffer.byteLength(JSON.stringify(command), "utf8");
}
if (isWorkspaceJournalReadCommand(command)) {
return Buffer.byteLength(JSON.stringify(command), "utf8");
}

View file

@ -28,6 +28,18 @@ export function isReadRequest(input: unknown): input is OpenClawStateReadRequest
((input.command.type === "deliveryQueue.outbound" &&
(input.command.id === undefined || typeof input.command.id === "string") &&
(input.command.mode === "pending" || input.command.mode === "unfinished")) ||
(input.command.type === "sessionState.versions" &&
Array.isArray(input.command.input) &&
input.command.input.every(
(ref) =>
isRecord(ref) && typeof ref.sessionKey === "string" && typeof ref.agentId === "string",
)) ||
(input.command.type === "sessionState.events" &&
isRecord(input.command.input) &&
typeof input.command.input.sessionKey === "string" &&
typeof input.command.input.agentId === "string" &&
typeof input.command.input.afterSequence === "number" &&
typeof input.command.input.limit === "number") ||
(input.command.type === "diagnostic.latest" &&
isRecord(input.command.input) &&
typeof input.command.input.scope === "string" &&

View file

@ -250,7 +250,12 @@ export function executeSharedStateCommand(
if (command.type === "sessionUpstream.current" || command.type === "sessionUpstream.settle") {
return executeSessionUpstreamCommand(command, writeOptions);
}
if (command.type === "sessionState.record" || command.type === "sessionState.prune") {
if (
command.type === "sessionState.record" ||
command.type === "sessionState.prune" ||
command.type === "sessionState.registerWatch" ||
command.type === "sessionState.acknowledge"
) {
return executeSessionStateCommand(command, writeOptions);
}
if (command.type === "subagents.persistChanges") {

View file

@ -1177,11 +1177,14 @@ describe("canonical descendant lifecycle through real owners", () => {
);
const child = expectDefined(fixture.native.threads.get(binding.threadId), "native child");
expect(child.thread.turns).toHaveLength(12);
registerSessionStateWatch({ watcherSessionKey: "agent:main:main", targetSessionKey: key });
const events = () => listSessionStateEventsSince(key, "main", 0).events;
const before = events();
await registerSessionStateWatch({
watcherSessionKey: "agent:main:main",
targetSessionKey: key,
});
const events = async () => (await listSessionStateEventsSince(key, "main", 0)).events;
const before = await events();
await runSessionUpstreamMonitorTick({ providers: [fixture.catalog] });
expect(events()).toEqual(before);
expect(await events()).toEqual(before);
const link = expectDefined(readSessionUpstreamLink(key, "main"), "child link");
const root = expectDefined(readSessionUpstreamLink(source.sessionKey, "main"), "root link");
expect(link).toMatchObject({
@ -1197,11 +1200,11 @@ describe("canonical descendant lifecycle through real owners", () => {
});
});
await runSessionUpstreamMonitorTick({ providers: [fixture.catalog] });
expect(events().slice(before.length)).toEqual([
expect((await events()).slice(before.length)).toEqual([
expect.objectContaining({ kind: "human_direct_message" }),
]);
await runSessionUpstreamMonitorTick({ providers: [fixture.catalog] });
expect(events()).toHaveLength(before.length + 1);
expect(await events()).toHaveLength(before.length + 1);
});
}, 180_000);

View file

@ -225,6 +225,7 @@ export const databaseWorkerCoreTestFiles = [
"src/agents/subagents/announce/subagent-announce.requester-cron-authority.test.ts",
"src/agents/subagents/announce/subagent-announce.requester-settle-results.test.ts",
"src/agents/subagents/announce/subagent-announce.requester-settle-wake.test.ts",
"src/agents/subagents/announce/subagent-announce.requester-settle-watch.test.ts",
"src/agents/subagents/registry/subagent-control.accounting.test.ts",
"src/agents/subagents/registry/subagent-announcement.worker.test.ts",
"src/agents/subagents/registry/subagent-announcement-delivery.worker.test.ts",
@ -588,6 +589,8 @@ export const databaseWorkerCoreTestFiles = [
"src/state/openclaw-agent-execution-cleanup.test.ts",
"src/sessions/session-diff-baseline.test.ts",
"src/sessions/session-state-events.test.ts",
"src/sessions/session-state-events.worker-boundary.test.ts",
"src/agents/watched-sessions-prompt.test.ts",
"src/state/openclaw-state-ownership.test.ts",
"src/sessions/session-created.test.ts",
"src/sessions/session-upstream-links.test.ts",

View file

@ -91,6 +91,7 @@ export const gatewayDatabaseWorkerTestFiles = [
"src/gateway/managed-image-attachments.worker-custody.test.ts",
"src/gateway/managed-image-record-store.test.ts",
"src/gateway/managed-outgoing-gc-availability.test.ts",
"src/gateway/mcp-http.completion-lineage.test.ts",
"src/gateway/mcp-http.exec-egress.test.ts",
"src/gateway/mention-directory.test.ts",
"src/gateway/mention-inbox.test.ts",