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:
CodeReclaimers
2026-05-22 14:11:20 -04:00
committed by GitHub
parent 77c3bdb3ca
commit 6f416537ee
3 changed files with 563 additions and 140 deletions
+1
View File
@@ -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.
+355 -2
View File
@@ -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
View File
@@ -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,