mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
f02d16e758
commit
7f232d4e64
68 changed files with 2655 additions and 780 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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"] = (
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
9
src/agents/tools/gateway-caller-receipt.types.ts
Normal file
9
src/agents/tools/gateway-caller-receipt.types.ts
Normal 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;
|
||||
}>;
|
||||
};
|
||||
55
src/agents/tools/session-status-state-changes.ts
Normal file
55
src/agents/tools/session-status-state-changes.ts
Normal 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)}
|
||||
\`\`\``;
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
src/config/sessions/session-entry-capability-facts.ts
Normal file
20
src/config/sessions/session-entry-capability-facts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
20
src/sessions/session-state-events.read.worker-contract.ts
Normal file
20
src/sessions/session-state-events.read.worker-contract.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
106
src/sessions/session-state-events.read.worker.ts
Normal file
106
src/sessions/session-state-events.read.worker.ts
Normal 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>;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
15
src/sessions/session-state-events.types.ts
Normal file
15
src/sessions/session-state-events.types.ts
Normal 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>;
|
||||
};
|
||||
435
src/sessions/session-state-events.worker-boundary.test.ts
Normal file
435
src/sessions/session-state-events.worker-boundary.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue