mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(gateway): preserve fresh agent session state
Fixes #5369. Preserve fresh session-store state when the agent handler observes a stale cached session entry, including model/provider overrides, send policy, delivery metadata, lifecycle timestamps, and fresh session rotations. Co-authored-by: CodeReclaimers <github@codereclaimers.com>
This commit is contained in:
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
|
||||
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
|
||||
- CLI/update: repair managed npm plugin `openclaw` peer links during post-core convergence and reject stale or wrong-target peer links before restart. (#83794) Thanks @fuller-stack-dev.
|
||||
- CLI/agents: default new omitted-account bindings to all accounts when the channel has multiple configured accounts, and clarify account-scope docs. (#49769) Thanks @Gcaufy.
|
||||
|
||||
@@ -40,7 +40,13 @@ const mocks = vi.hoisted(() => ({
|
||||
loadConfigReturn: {} as Record<string, unknown>,
|
||||
loadVoiceWakeRoutingConfig: vi.fn(),
|
||||
resolveVoiceWakeRouteByTrigger: vi.fn(),
|
||||
resolveSendPolicy: vi.fn(() => "allow"),
|
||||
resolveSendPolicy: vi.fn((_args?: { entry?: { sendPolicy?: string } }) => "allow"),
|
||||
resolveSessionLifecycleTimestamps: vi.fn(
|
||||
({ entry }: { entry?: { sessionStartedAt?: number; lastInteractionAt?: number } }) => ({
|
||||
sessionStartedAt: entry?.sessionStartedAt,
|
||||
lastInteractionAt: entry?.lastInteractionAt,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../session-utils.js", async () => {
|
||||
@@ -59,6 +65,7 @@ vi.mock("../../config/sessions.js", async () => {
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: mocks.updateSessionStore,
|
||||
resolveSessionLifecycleTimestamps: mocks.resolveSessionLifecycleTimestamps,
|
||||
resolveAgentIdFromSessionKey: (sessionKey: string) => {
|
||||
const m = /^agent:([^:]+):/.exec(sessionKey.trim());
|
||||
return m?.[1] ?? "main";
|
||||
@@ -497,6 +504,14 @@ describe("gateway agent handler", () => {
|
||||
mocks.resolveBareResetBootstrapFileAccess.mockReset().mockReturnValue(true);
|
||||
mocks.listAgentIds.mockReset().mockReturnValue(["main"]);
|
||||
mocks.resolveSendPolicy.mockReset().mockReturnValue("allow");
|
||||
mocks.resolveSessionLifecycleTimestamps
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
({ entry }: { entry?: { sessionStartedAt?: number; lastInteractionAt?: number } }) => ({
|
||||
sessionStartedAt: entry?.sessionStartedAt,
|
||||
lastInteractionAt: entry?.lastInteractionAt,
|
||||
}),
|
||||
);
|
||||
dateOnlyFakeClockActive = false;
|
||||
vi.useRealTimers();
|
||||
resetExecApprovalFollowupRuntimeHandoffsForTests();
|
||||
@@ -1021,6 +1036,344 @@ describe("gateway agent handler", () => {
|
||||
expect(capturedEntry.cliSessionIds).toEqual(existingCliSessionIds);
|
||||
expect(capturedEntry.claudeCliSessionId).toBe(existingClaudeCliSessionId);
|
||||
});
|
||||
// #5369: sessions.patch can write modelOverride to the session store between
|
||||
// when the agent handler reads its cached entry and when updateSessionStore
|
||||
// runs. The handler's loadSessionEntry may return the stale pre-patch entry
|
||||
// (no modelOverride), while the store-load inside updateSessionStore has the
|
||||
// fresh value. If the patch built from the stale entry carries modelOverride:
|
||||
// undefined, the merge {...fresh, ...patch} clobbers the fresh value.
|
||||
it("preserves fresh modelOverride when cached entry is stale (#5369)", async () => {
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
// modelOverride absent — stale pre-patch view
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:test-uuid",
|
||||
});
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:test-uuid": {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: Date.now(),
|
||||
modelOverride: "qwen3-coder:30b",
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:test-uuid"];
|
||||
return result;
|
||||
});
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:test-uuid",
|
||||
idempotencyKey: "test-5369-race",
|
||||
},
|
||||
{ reqId: "race-1" },
|
||||
);
|
||||
expect(capturedEntry?.modelOverride).toBe("qwen3-coder:30b");
|
||||
expect(capturedEntry?.providerOverride).toBe("ollama");
|
||||
});
|
||||
// Broader regression guard for the #5369 stale-writeback class: any field
|
||||
// that the patch blindly carries from the cached entry will clobber a fresh
|
||||
// concurrent write. The fix dropped all such fields from the patch; this
|
||||
// test ensures none get silently re-added. If a future change puts e.g.
|
||||
// `sendPolicy: entry?.sendPolicy` back into the patch, this test fails.
|
||||
it("preserves all fresh session fields when cached entry is stale (#5369 broader)", async () => {
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
// All fields below absent — stale pre-patch view
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:test-broader",
|
||||
});
|
||||
const freshFields = {
|
||||
sendPolicy: "allow",
|
||||
skillsSnapshot: { tools: ["bash"] },
|
||||
thinkingLevel: "high",
|
||||
fastMode: true,
|
||||
verboseLevel: "detailed",
|
||||
traceLevel: "info",
|
||||
reasoningLevel: "on",
|
||||
systemSent: true,
|
||||
spawnedWorkspaceDir: "/work/fresh",
|
||||
spawnDepth: 2,
|
||||
label: "fresh-label",
|
||||
spawnedBy: "agent:main:main",
|
||||
channel: "telegram",
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
accountId: "acct-1",
|
||||
threadId: 42,
|
||||
},
|
||||
lastChannel: "telegram",
|
||||
lastTo: "12345",
|
||||
lastAccountId: "acct-1",
|
||||
lastThreadId: 42,
|
||||
cliSessionIds: { "claude-cli": "fresh-cli-id" },
|
||||
cliSessionBindings: { "claude-cli": { sessionId: "fresh-binding" } },
|
||||
claudeCliSessionId: "fresh-cli-id",
|
||||
};
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:test-broader": {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: Date.now(),
|
||||
...freshFields,
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:test-broader"];
|
||||
return result;
|
||||
});
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:test-broader",
|
||||
idempotencyKey: "test-5369-broader",
|
||||
},
|
||||
{ reqId: "broader-1" },
|
||||
);
|
||||
for (const [field, expected] of Object.entries(freshFields)) {
|
||||
expect(capturedEntry?.[field]).toEqual(expected);
|
||||
}
|
||||
});
|
||||
it("checks delivery sendPolicy against the fresh store entry (#5369)", async () => {
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
// sendPolicy absent — stale pre-patch view
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:test-policy",
|
||||
});
|
||||
const freshUpdatedAt = Date.now();
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:test-policy": {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: freshUpdatedAt,
|
||||
sendPolicy: "deny",
|
||||
channel: "telegram",
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:test-policy"];
|
||||
return result;
|
||||
});
|
||||
mocks.resolveSendPolicy.mockImplementation((args?: { entry?: { sendPolicy?: string } }) =>
|
||||
args?.entry?.sendPolicy === "deny" ? "deny" : "allow",
|
||||
);
|
||||
mocks.agentCommand.mockClear();
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
const respond = vi.fn();
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:test-policy",
|
||||
channel: "telegram",
|
||||
to: "99999",
|
||||
deliver: true,
|
||||
idempotencyKey: "test-5369-policy",
|
||||
},
|
||||
{ reqId: "policy-1", respond },
|
||||
);
|
||||
expectRespondError(respond, { message: "send blocked by session policy" });
|
||||
const sendPolicyArgs = expectRecordFields(mockCallArg(mocks.resolveSendPolicy), {
|
||||
sessionKey: "agent:main:subagent:test-policy",
|
||||
});
|
||||
expectRecordFields(sendPolicyArgs.entry, { sendPolicy: "deny" });
|
||||
expectRecordFields(capturedEntry, {
|
||||
sessionId: "subagent-session-id",
|
||||
updatedAt: freshUpdatedAt,
|
||||
sendPolicy: "deny",
|
||||
channel: "telegram",
|
||||
deliveryContext: undefined,
|
||||
lastTo: undefined,
|
||||
});
|
||||
expect(mocks.agentCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not restore a stale session id over a fresh store rotation (#5369)", async () => {
|
||||
mocks.resolveSessionLifecycleTimestamps.mockImplementation(
|
||||
({ entry }: { entry?: { sessionId?: string; sessionStartedAt?: number } }) => ({
|
||||
sessionStartedAt: entry?.sessionId === "old-session-id" ? 123 : entry?.sessionStartedAt,
|
||||
lastInteractionAt: undefined,
|
||||
}),
|
||||
);
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "old-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:test-rotation",
|
||||
});
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:test-rotation": {
|
||||
sessionId: "fresh-session-id",
|
||||
updatedAt: Date.now(),
|
||||
status: "running",
|
||||
startedAt: 111,
|
||||
sessionFile: "/tmp/fresh-session.jsonl",
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:test-rotation"];
|
||||
return result;
|
||||
});
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:test-rotation",
|
||||
idempotencyKey: "test-5369-rotation",
|
||||
},
|
||||
{ reqId: "rotation-1" },
|
||||
);
|
||||
|
||||
expectRecordFields(capturedEntry, {
|
||||
sessionId: "fresh-session-id",
|
||||
status: "running",
|
||||
startedAt: 111,
|
||||
sessionStartedAt: undefined,
|
||||
sessionFile: "/tmp/fresh-session.jsonl",
|
||||
});
|
||||
});
|
||||
// Upgrade-path self-heal: a legacy session entry may lack sessionStartedAt
|
||||
// because the field was added after the entry was first persisted. The
|
||||
// handler recovers it from the transcript JSONL header and writes it back,
|
||||
// but only when the fresh store still lacks the field — so a concurrent
|
||||
// writer that sets it cannot be clobbered (the #5369 stale-writeback class).
|
||||
it("self-heals missing sessionStartedAt from JSONL when fresh store also lacks it", async () => {
|
||||
// Use a value distinct from `now` but recent enough that
|
||||
// evaluateSessionFreshness — which also calls the mocked
|
||||
// resolveSessionLifecycleTimestamps — keeps this session fresh.
|
||||
const recoveredStartedAt = Date.now() - 5_000;
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "legacy-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
// sessionStartedAt absent — legacy schema
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:legacy",
|
||||
});
|
||||
mocks.resolveSessionLifecycleTimestamps.mockReturnValue({
|
||||
sessionStartedAt: recoveredStartedAt,
|
||||
lastInteractionAt: undefined,
|
||||
});
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:legacy": {
|
||||
sessionId: "legacy-session-id",
|
||||
updatedAt: Date.now(),
|
||||
// sessionStartedAt absent on disk too — self-heal should fire
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:legacy"];
|
||||
return result;
|
||||
});
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:legacy",
|
||||
idempotencyKey: "test-selfheal-write",
|
||||
},
|
||||
{ reqId: "selfheal-1" },
|
||||
);
|
||||
expect(capturedEntry?.sessionStartedAt).toBe(recoveredStartedAt);
|
||||
});
|
||||
it("does not clobber fresh sessionStartedAt with the recovered candidate", async () => {
|
||||
// See note in the prior test: keep both values recent so freshness
|
||||
// evaluation (which also reads the lifecycle mock) doesn't trip the
|
||||
// idle-reset path and turn this into an isNewSession path.
|
||||
const recoveredStartedAt = Date.now() - 5_000;
|
||||
const freshStartedAt = Date.now() - 2_500;
|
||||
mocks.loadSessionEntry.mockReturnValue({
|
||||
cfg: {},
|
||||
storePath: "/tmp/sessions.json",
|
||||
entry: {
|
||||
sessionId: "legacy-session-id",
|
||||
updatedAt: Date.now() - 1000,
|
||||
// sessionStartedAt absent in cached entry — would trigger recovery
|
||||
},
|
||||
canonicalKey: "agent:main:subagent:concurrent",
|
||||
});
|
||||
mocks.resolveSessionLifecycleTimestamps.mockReturnValue({
|
||||
sessionStartedAt: recoveredStartedAt,
|
||||
lastInteractionAt: undefined,
|
||||
});
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const freshStore: Record<string, Record<string, unknown>> = {
|
||||
"agent:main:subagent:concurrent": {
|
||||
sessionId: "legacy-session-id",
|
||||
updatedAt: Date.now(),
|
||||
// Concurrent writer set sessionStartedAt between cache load and lock
|
||||
sessionStartedAt: freshStartedAt,
|
||||
},
|
||||
};
|
||||
const result = await updater(freshStore);
|
||||
capturedEntry = freshStore["agent:main:subagent:concurrent"];
|
||||
return result;
|
||||
});
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: { durationMs: 100 },
|
||||
});
|
||||
await invokeAgent(
|
||||
{
|
||||
message: "hi",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:subagent:concurrent",
|
||||
idempotencyKey: "test-selfheal-noclobber",
|
||||
},
|
||||
{ reqId: "selfheal-2" },
|
||||
);
|
||||
expect(capturedEntry?.sessionStartedAt).toBe(freshStartedAt);
|
||||
});
|
||||
it("reactivates completed subagent sessions and broadcasts send updates", async () => {
|
||||
const childSessionKey = "agent:main:subagent:followup";
|
||||
const completedRun = {
|
||||
@@ -3471,7 +3824,7 @@ describe("gateway agent handler", () => {
|
||||
let capturedEntry: Record<string, unknown> | undefined;
|
||||
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
|
||||
const store: Record<string, unknown> = {
|
||||
[sessionKey]: { sessionId: "existing-session-id" },
|
||||
[sessionKey]: { sessionId: "existing-session-id", ...entry },
|
||||
};
|
||||
await updater(store);
|
||||
capturedEntry = store[sessionKey] as Record<string, unknown>;
|
||||
|
||||
+207
-138
@@ -1278,68 +1278,14 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
request.bootstrapContextRunKind !== "cron" &&
|
||||
request.bootstrapContextRunKind !== "heartbeat" &&
|
||||
!request.internalEvents?.length;
|
||||
const labelValue = normalizeOptionalString(request.label) || entry?.label;
|
||||
const pluginOwnerId =
|
||||
entry === undefined
|
||||
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
|
||||
: normalizeOptionalString(entry.pluginOwnerId);
|
||||
const sessionAgent = resolveAgentIdFromSessionKey(canonicalKey);
|
||||
spawnedByValue = canonicalizeSpawnedByForAgent(cfg, sessionAgent, entry?.spawnedBy);
|
||||
const storedGroup = normalizeTrustedGroupMetadata(entry);
|
||||
let inheritedGroup: TrustedGroupMetadata | undefined;
|
||||
if (
|
||||
spawnedByValue &&
|
||||
(!storedGroup.groupId || !storedGroup.groupChannel || !storedGroup.groupSpace)
|
||||
) {
|
||||
try {
|
||||
const parentEntry = loadSessionEntry(spawnedByValue)?.entry;
|
||||
inheritedGroup = normalizeTrustedGroupMetadata({
|
||||
groupId: parentEntry?.groupId,
|
||||
groupChannel: parentEntry?.groupChannel,
|
||||
groupSpace: parentEntry?.space,
|
||||
});
|
||||
} catch {
|
||||
inheritedGroup = undefined;
|
||||
}
|
||||
}
|
||||
const trustedGroup = resolveTrustedGroupMetadata({
|
||||
sessionKey: canonicalKey,
|
||||
spawnedBy: spawnedByValue,
|
||||
stored: storedGroup,
|
||||
inherited: inheritedGroup,
|
||||
});
|
||||
const validatedGroup = trustedGroup.groupId
|
||||
? resolveTrustedGroupId({
|
||||
groupId: trustedGroup.groupId,
|
||||
sessionKey: canonicalKey,
|
||||
spawnedBy: spawnedByValue,
|
||||
})
|
||||
: undefined;
|
||||
if (validatedGroup?.dropped) {
|
||||
resolvedGroupId = undefined;
|
||||
resolvedGroupChannel = undefined;
|
||||
resolvedGroupSpace = undefined;
|
||||
} else {
|
||||
const trustRequestSelectors =
|
||||
Boolean(trustedGroup.groupId) &&
|
||||
requestGroupMatchesTrusted({
|
||||
requestGroupId: normalizedSpawned.groupId,
|
||||
trustedGroupId: trustedGroup.groupId,
|
||||
});
|
||||
resolvedGroupId = trustedGroup.groupId;
|
||||
resolvedGroupChannel =
|
||||
trustedGroup.groupChannel ??
|
||||
(trustRequestSelectors ? normalizedSpawned.groupChannel : undefined);
|
||||
resolvedGroupSpace =
|
||||
trustedGroup.groupSpace ??
|
||||
(trustRequestSelectors ? normalizedSpawned.groupSpace : undefined);
|
||||
}
|
||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||
// When the session has no delivery context yet (e.g. a freshly-spawned subagent
|
||||
// with deliver: false), seed it from the request's channel/to/threadId params.
|
||||
// Without this, subagent sessions end up with a channel-only deliveryContext
|
||||
// and no `to`/`threadId`, which causes announce delivery to either target the
|
||||
// wrong channel (when the parent's lastTo drifts) or fail entirely.
|
||||
type AgentSessionPatchBuild = {
|
||||
patch: Partial<SessionEntry>;
|
||||
spawnedBy: string | undefined;
|
||||
groupId: string | undefined;
|
||||
groupChannel: string | undefined;
|
||||
groupSpace: string | undefined;
|
||||
};
|
||||
const requestDeliveryHint = normalizeDeliveryContext({
|
||||
channel: request.channel?.trim(),
|
||||
to: request.to?.trim(),
|
||||
@@ -1348,66 +1294,208 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
// string and numeric threadIds (e.g., Matrix uses integers).
|
||||
threadId: request.threadId,
|
||||
});
|
||||
const effectiveDelivery = mergeDeliveryContext(
|
||||
deliveryFields.deliveryContext,
|
||||
requestDeliveryHint,
|
||||
);
|
||||
const effectiveDeliveryFields = normalizeSessionDeliveryFields({
|
||||
route: deliveryFields.route,
|
||||
deliveryContext: effectiveDelivery,
|
||||
});
|
||||
const nextEntryPatch: SessionEntry = {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
sessionStartedAt: isNewSession
|
||||
? now
|
||||
: (entry?.sessionStartedAt ??
|
||||
resolveSessionLifecycleTimestamps({
|
||||
const buildSessionPatch = (
|
||||
freshEntry: SessionEntry | undefined,
|
||||
): AgentSessionPatchBuild => {
|
||||
const freshSpawnedBy = canonicalizeSpawnedByForAgent(
|
||||
cfg,
|
||||
sessionAgent,
|
||||
freshEntry?.spawnedBy,
|
||||
);
|
||||
const storedGroup = normalizeTrustedGroupMetadata(freshEntry);
|
||||
let inheritedGroup: TrustedGroupMetadata | undefined;
|
||||
if (
|
||||
freshSpawnedBy &&
|
||||
(!storedGroup.groupId || !storedGroup.groupChannel || !storedGroup.groupSpace)
|
||||
) {
|
||||
try {
|
||||
const parentEntry = loadSessionEntry(freshSpawnedBy)?.entry;
|
||||
inheritedGroup = normalizeTrustedGroupMetadata({
|
||||
groupId: parentEntry?.groupId,
|
||||
groupChannel: parentEntry?.groupChannel,
|
||||
groupSpace: parentEntry?.space,
|
||||
});
|
||||
} catch {
|
||||
inheritedGroup = undefined;
|
||||
}
|
||||
}
|
||||
const trustedGroup = resolveTrustedGroupMetadata({
|
||||
sessionKey: canonicalKey,
|
||||
spawnedBy: freshSpawnedBy,
|
||||
stored: storedGroup,
|
||||
inherited: inheritedGroup,
|
||||
});
|
||||
const validatedGroup = trustedGroup.groupId
|
||||
? resolveTrustedGroupId({
|
||||
groupId: trustedGroup.groupId,
|
||||
sessionKey: canonicalKey,
|
||||
spawnedBy: freshSpawnedBy,
|
||||
})
|
||||
: undefined;
|
||||
const nextGroup =
|
||||
validatedGroup?.dropped === true
|
||||
? {
|
||||
groupId: undefined,
|
||||
groupChannel: undefined,
|
||||
groupSpace: undefined,
|
||||
}
|
||||
: (() => {
|
||||
const trustRequestSelectors =
|
||||
Boolean(trustedGroup.groupId) &&
|
||||
requestGroupMatchesTrusted({
|
||||
requestGroupId: normalizedSpawned.groupId,
|
||||
trustedGroupId: trustedGroup.groupId,
|
||||
});
|
||||
return {
|
||||
groupId: trustedGroup.groupId,
|
||||
groupChannel:
|
||||
trustedGroup.groupChannel ??
|
||||
(trustRequestSelectors ? normalizedSpawned.groupChannel : undefined),
|
||||
groupSpace:
|
||||
trustedGroup.groupSpace ??
|
||||
(trustRequestSelectors ? normalizedSpawned.groupSpace : undefined),
|
||||
};
|
||||
})();
|
||||
|
||||
const deliveryFields = normalizeSessionDeliveryFields(freshEntry);
|
||||
// When the session has no delivery context yet (e.g. a freshly-spawned
|
||||
// subagent with deliver: false), seed it from request channel/to/threadId.
|
||||
const effectiveDelivery = mergeDeliveryContext(
|
||||
deliveryFields.deliveryContext,
|
||||
requestDeliveryHint,
|
||||
);
|
||||
const effectiveDeliveryFields = normalizeSessionDeliveryFields({
|
||||
route: deliveryFields.route,
|
||||
deliveryContext: effectiveDelivery,
|
||||
});
|
||||
const labelValue = normalizeOptionalString(request.label) || freshEntry?.label;
|
||||
const channelValue = freshEntry?.channel ?? request.channel?.trim();
|
||||
const pluginOwnerId =
|
||||
freshEntry === undefined
|
||||
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
|
||||
: undefined;
|
||||
const freshSessionRotatedSinceLoad = Boolean(
|
||||
entry?.sessionId && freshEntry?.sessionId && freshEntry.sessionId !== entry.sessionId,
|
||||
);
|
||||
const patchSessionId = freshSessionRotatedSinceLoad ? freshEntry?.sessionId : sessionId;
|
||||
const shouldClearRotatedState = rotatedSessionId && !freshSessionRotatedSinceLoad;
|
||||
const patch: Partial<SessionEntry> = {
|
||||
sessionId: patchSessionId,
|
||||
updatedAt: now,
|
||||
...(isNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: now } : {}),
|
||||
...(touchInteraction ? { lastInteractionAt: now } : {}),
|
||||
...(effectiveDeliveryFields.route ? { route: effectiveDeliveryFields.route } : {}),
|
||||
...(effectiveDeliveryFields.deliveryContext
|
||||
? { deliveryContext: effectiveDeliveryFields.deliveryContext }
|
||||
: {}),
|
||||
...(effectiveDeliveryFields.lastChannel
|
||||
? { lastChannel: effectiveDeliveryFields.lastChannel }
|
||||
: {}),
|
||||
...(effectiveDeliveryFields.lastTo ? { lastTo: effectiveDeliveryFields.lastTo } : {}),
|
||||
...(effectiveDeliveryFields.lastAccountId
|
||||
? { lastAccountId: effectiveDeliveryFields.lastAccountId }
|
||||
: {}),
|
||||
...(effectiveDeliveryFields.lastThreadId != null
|
||||
? { lastThreadId: effectiveDeliveryFields.lastThreadId }
|
||||
: {}),
|
||||
...(labelValue ? { label: labelValue } : {}),
|
||||
...(freshSpawnedBy ? { spawnedBy: freshSpawnedBy } : {}),
|
||||
...(channelValue ? { channel: channelValue } : {}),
|
||||
groupId: nextGroup.groupId,
|
||||
groupChannel: nextGroup.groupChannel,
|
||||
space: nextGroup.groupSpace,
|
||||
...(pluginOwnerId ? { pluginOwnerId } : {}),
|
||||
...(shouldClearRotatedState
|
||||
? {
|
||||
status: undefined,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
runtimeMs: undefined,
|
||||
abortedLastRun: undefined,
|
||||
sessionFile: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
patch,
|
||||
spawnedBy: freshSpawnedBy,
|
||||
groupId: nextGroup.groupId,
|
||||
groupChannel: nextGroup.groupChannel,
|
||||
groupSpace: nextGroup.groupSpace,
|
||||
};
|
||||
};
|
||||
let patchBuild = buildSessionPatch(entry);
|
||||
sessionEntry = mergeSessionEntry(entry, patchBuild.patch);
|
||||
resolvedSessionId = sessionEntry?.sessionId ?? sessionId;
|
||||
const canonicalSessionKey = canonicalKey;
|
||||
resolvedSessionKey = canonicalSessionKey;
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
// Legacy stores may lack sessionStartedAt entirely. Pre-compute a
|
||||
// JSONL-transcript-derived candidate outside the store lock; the
|
||||
// updater below only writes it when the freshly-loaded store still
|
||||
// lacks the field, so a concurrent writer that sets it cannot be
|
||||
// clobbered (the #5369 stale-writeback class).
|
||||
const recoveredSessionStartedAt: number | undefined =
|
||||
!isNewSession && entry !== undefined && entry.sessionStartedAt === undefined
|
||||
? resolveSessionLifecycleTimestamps({
|
||||
entry,
|
||||
storePath,
|
||||
agentId: resolveAgentIdFromSessionKey(canonicalKey),
|
||||
}).sessionStartedAt),
|
||||
lastInteractionAt: touchInteraction ? now : entry?.lastInteractionAt,
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
fastMode: entry?.fastMode,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
traceLevel: entry?.traceLevel,
|
||||
reasoningLevel: entry?.reasoningLevel,
|
||||
systemSent: entry?.systemSent,
|
||||
sendPolicy: entry?.sendPolicy,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
route: effectiveDeliveryFields.route,
|
||||
deliveryContext: effectiveDeliveryFields.deliveryContext,
|
||||
lastChannel: effectiveDeliveryFields.lastChannel ?? entry?.lastChannel,
|
||||
lastTo: effectiveDeliveryFields.lastTo ?? entry?.lastTo,
|
||||
lastAccountId: effectiveDeliveryFields.lastAccountId ?? entry?.lastAccountId,
|
||||
lastThreadId: effectiveDeliveryFields.lastThreadId ?? entry?.lastThreadId,
|
||||
modelOverride: entry?.modelOverride,
|
||||
providerOverride: entry?.providerOverride,
|
||||
label: labelValue,
|
||||
spawnedBy: spawnedByValue,
|
||||
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
|
||||
spawnDepth: entry?.spawnDepth,
|
||||
channel: entry?.channel ?? request.channel?.trim(),
|
||||
groupId: resolvedGroupId,
|
||||
groupChannel: resolvedGroupChannel,
|
||||
space: resolvedGroupSpace,
|
||||
...(pluginOwnerId ? { pluginOwnerId } : {}),
|
||||
...(rotatedSessionId
|
||||
? {
|
||||
status: undefined,
|
||||
startedAt: undefined,
|
||||
endedAt: undefined,
|
||||
runtimeMs: undefined,
|
||||
abortedLastRun: undefined,
|
||||
sessionFile: undefined,
|
||||
}
|
||||
: { sessionFile: entry?.sessionFile }),
|
||||
cliSessionIds: entry?.cliSessionIds,
|
||||
cliSessionBindings: entry?.cliSessionBindings,
|
||||
claudeCliSessionId: entry?.claudeCliSessionId,
|
||||
};
|
||||
sessionEntry = mergeSessionEntry(entry, nextEntryPatch);
|
||||
agentId,
|
||||
}).sessionStartedAt
|
||||
: undefined;
|
||||
if (storePath) {
|
||||
const requestedStoreKey = requestedSessionKey;
|
||||
let deniedBySendPolicy = false;
|
||||
const persisted = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key: requestedStoreKey,
|
||||
store,
|
||||
});
|
||||
const freshEntry = store[primaryKey];
|
||||
patchBuild = buildSessionPatch(freshEntry);
|
||||
const effectivePatch =
|
||||
recoveredSessionStartedAt !== undefined &&
|
||||
freshEntry?.sessionStartedAt === undefined &&
|
||||
freshEntry?.sessionId === entry?.sessionId
|
||||
? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt }
|
||||
: patchBuild.patch;
|
||||
const merged = mergeSessionEntry(freshEntry, effectivePatch);
|
||||
const sendPolicy =
|
||||
request.deliver === true
|
||||
? resolveSendPolicy({
|
||||
cfg,
|
||||
entry: merged,
|
||||
sessionKey: canonicalKey,
|
||||
channel: merged?.channel,
|
||||
chatType: merged?.chatType,
|
||||
})
|
||||
: "allow";
|
||||
if (sendPolicy === "deny") {
|
||||
deniedBySendPolicy = true;
|
||||
return merged;
|
||||
}
|
||||
store[primaryKey] = merged;
|
||||
return merged;
|
||||
});
|
||||
if (persisted) {
|
||||
sessionEntry = persisted;
|
||||
resolvedSessionId = sessionEntry.sessionId;
|
||||
}
|
||||
if (deniedBySendPolicy) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
spawnedByValue = patchBuild.spawnedBy;
|
||||
resolvedGroupId = patchBuild.groupId;
|
||||
resolvedGroupChannel = patchBuild.groupChannel;
|
||||
resolvedGroupSpace = patchBuild.groupSpace;
|
||||
if (request.deliver === true) {
|
||||
const sendPolicy = resolveSendPolicy({
|
||||
cfg,
|
||||
@@ -1425,25 +1513,6 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
}
|
||||
resolvedSessionId = sessionId;
|
||||
const canonicalSessionKey = canonicalKey;
|
||||
resolvedSessionKey = canonicalSessionKey;
|
||||
const agentId = resolveAgentIdFromSessionKey(canonicalSessionKey);
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
if (storePath) {
|
||||
const requestedStoreKey = requestedSessionKey;
|
||||
const persisted = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key: requestedStoreKey,
|
||||
store,
|
||||
});
|
||||
const merged = mergeSessionEntry(store[primaryKey], nextEntryPatch);
|
||||
store[primaryKey] = merged;
|
||||
return merged;
|
||||
});
|
||||
sessionEntry = persisted;
|
||||
}
|
||||
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
|
||||
context.addChatRun(idem, {
|
||||
sessionKey: canonicalSessionKey,
|
||||
|
||||
Reference in New Issue
Block a user