fix(hub,cli): coerce null session activeAt so resume cannot 500 (#1026)

Legacy rows and inserts left sessions.active_at NULL while SessionSchema
required a number, so CLI GET /cli/sessions/:id failed Zod and resume
surfaced HTTP 500. Persist active_at on insert, harden hub read coerce,
and nullish-transform activeAt in SessionSchema (output stays number).

Fixes #1025

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-13 09:00:17 +08:00
committed by GitHub
co-authored by Debian Cursor
parent f6ad345339
commit 8ee04500b9
7 changed files with 213 additions and 3 deletions
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { configuration } from '@/configuration'
const axiosGetMock = vi.hoisted(() => vi.fn())
vi.mock('axios', () => ({
default: {
get: axiosGetMock,
post: vi.fn()
}
}))
vi.mock('@/api/auth', () => ({
getAuthToken: () => 'cli-token'
}))
import { ApiClient } from './api'
describe('ApiClient.getSession activeAt coerce', () => {
const now = 1_710_000_000_000
beforeEach(() => {
configuration._setApiUrl('https://hapi.example.com')
configuration._setExtraHeaders({})
axiosGetMock.mockReset()
})
it('accepts hub payloads with null activeAt without throwing', async () => {
axiosGetMock.mockResolvedValue({
data: {
session: {
id: '11111111-1111-4111-8111-111111111111',
namespace: 'default',
seq: 1,
createdAt: now,
updatedAt: now,
active: false,
activeAt: null,
metadata: {
path: '/tmp/project',
host: 'test-host'
},
metadataVersion: 1,
agentState: null,
agentStateVersion: 0,
thinking: false,
thinkingAt: now,
todos: [],
model: null,
modelReasoningEffort: null,
effort: null,
serviceTier: null
}
}
})
const client = await ApiClient.create()
const session = await client.getSession('11111111-1111-4111-8111-111111111111')
expect(session.activeAt).toBe(0)
expect(typeof session.activeAt).toBe('number')
})
})
+20
View File
@@ -12,6 +12,26 @@ function getMetadata(store: Store, id: string): Record<string, unknown> | null {
return (row?.metadata ?? null) as Record<string, unknown> | null
}
describe('getOrCreateSession: active_at', () => {
it('persists a non-null active_at on insert (never NULL)', () => {
const store = makeStore()
const created = store.sessions.getOrCreateSession(
'active-at-write',
{ path: '/tmp/project', host: 'localhost' },
null,
'default'
)
expect(typeof created.activeAt).toBe('number')
expect(created.activeAt).not.toBeNull()
expect(created.activeAt).toBe(created.createdAt)
const reloaded = store.sessions.getSession(created.id)
expect(reloaded?.activeAt).toBe(created.createdAt)
store.close()
})
})
describe('getOrCreateSession: requested identity', () => {
it('creates and idempotently reloads a client-requested id', () => {
const store = makeStore()
+4 -1
View File
@@ -236,7 +236,7 @@ export function getOrCreateSession(
@model_reasoning_effort,
@effort,
NULL, NULL,
0, NULL, 0
0, @active_at, 0
)
`).run({
id,
@@ -244,6 +244,9 @@ export function getOrCreateSession(
namespace,
created_at: now,
updated_at: now,
// Never persist NULL — CLI SessionSchema requires numeric activeAt.
// Legacy rows may still be NULL; sessionCache coerces on read.
active_at: now,
metadata: metadataJson,
agent_state: agentStateJson,
model: model ?? null,
+7 -1
View File
@@ -150,7 +150,13 @@ export class SessionCache {
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
active: existing?.active ?? stored.active,
activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt),
// Legacy / idle rows may still have active_at NULL in SQLite.
// Public Session.activeAt is always a number for CLI Zod parse.
activeAt: existing?.activeAt
?? stored.activeAt
?? stored.updatedAt
?? stored.createdAt
?? 0,
metadata,
metadataVersion: stored.metadataVersion,
agentState,
+68
View File
@@ -0,0 +1,68 @@
import { beforeAll, describe, expect, it } from 'bun:test'
import type { Database } from 'bun:sqlite'
import { Hono } from 'hono'
import { createConfiguration } from '../../configuration'
import { Store } from '../../store'
import { RpcRegistry } from '../../socket/rpcRegistry'
import { SyncEngine } from '../../sync/syncEngine'
import { createCliRoutes } from './cli'
function createEngine(store: Store): SyncEngine {
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
engine.stop()
return engine
}
function authHeaders() {
return {
authorization: 'Bearer test-token'
}
}
function nullOutActiveAt(store: Store, sessionId: string): void {
const db = (store as unknown as { db: Database }).db
db.prepare('UPDATE sessions SET active_at = NULL WHERE id = ?').run(sessionId)
}
beforeAll(async () => {
const config = await createConfiguration()
config._setCliApiToken('test-token', 'env', false)
})
describe('CLI GET /sessions/:id with null active_at', () => {
it('returns numeric activeAt coerced from createdAt', async () => {
const store = new Store(':memory:')
const engine = createEngine(store)
const created = engine.getOrCreateSession(
'null-active-at-cli',
{ path: '/tmp/project', host: 'localhost', flavor: 'claude' },
{ requests: {}, completedRequests: {} },
'default'
)
nullOutActiveAt(store, created.id)
expect(store.sessions.getSession(created.id)?.activeAt).toBeNull()
// Simulate hub restart so cache reloads from the NULL row.
const reloaded = createEngine(store)
const app = new Hono()
app.route('/cli', createCliRoutes(() => reloaded))
const response = await app.request(`/cli/sessions/${created.id}`, {
headers: authHeaders()
})
expect(response.status).toBe(200)
const body = await response.json() as {
session: { activeAt: unknown; createdAt: number; updatedAt: number }
}
expect(typeof body.session.activeAt).toBe('number')
expect(body.session.activeAt).toBe(created.createdAt)
store.close()
})
})
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { SessionSchema } from './schemas'
function baseSession(overrides: Record<string, unknown> = {}) {
return {
id: '11111111-1111-4111-8111-111111111111',
namespace: 'default',
seq: 0,
createdAt: 1_000,
updatedAt: 2_000,
active: false,
activeAt: 1_000,
metadata: null,
metadataVersion: 0,
agentState: null,
agentStateVersion: 0,
thinking: false,
thinkingAt: 0,
...overrides
}
}
describe('SessionSchema activeAt coerce', () => {
it('keeps a numeric activeAt unchanged', () => {
const parsed = SessionSchema.safeParse(baseSession({ activeAt: 42 }))
expect(parsed.success).toBe(true)
if (parsed.success) {
expect(parsed.data.activeAt).toBe(42)
}
})
it('coerces null activeAt to 0 without failing parse', () => {
const parsed = SessionSchema.safeParse(baseSession({ activeAt: null }))
expect(parsed.success).toBe(true)
if (parsed.success) {
expect(parsed.data.activeAt).toBe(0)
}
})
it('coerces missing activeAt to 0 without failing parse', () => {
const raw = baseSession()
delete (raw as { activeAt?: number }).activeAt
const parsed = SessionSchema.safeParse(raw)
expect(parsed.success).toBe(true)
if (parsed.success) {
expect(parsed.data.activeAt).toBe(0)
}
})
})
+2 -1
View File
@@ -212,7 +212,8 @@ export const SessionSchema = z.object({
createdAt: z.number(),
updatedAt: z.number(),
active: z.boolean(),
activeAt: z.number(),
// Hub may still emit null for legacy SQLite rows; keep output type number.
activeAt: z.number().nullish().transform((value) => value ?? 0),
metadata: MetadataSchema.nullable(),
metadataVersion: z.number(),
agentState: AgentStateSchema.nullable(),