fix(orchestration): compare stale-dispatch timestamps with julianday (#8514)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-13 17:46:25 -07:00 committed by GitHub
parent dbe2481d57
commit 82573d70ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 5 deletions

View File

@ -6,6 +6,20 @@ import Database from '../../sqlite/sync-database'
import { OrchestrationDb } from './db'
import type { MessageType } from './db'
// Overwrites the datetime('now')-seeded timestamps with explicit fixture values
// so stale-detection assertions stay deterministic (no wall clock).
function setDispatchTimes(
d: OrchestrationDb,
id: string,
dispatchedAt: string,
heartbeatAt: string | null = null
): void {
const sqlite = (d as unknown as { db: Database.Database }).db
sqlite
.prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?')
.run(dispatchedAt, heartbeatAt, id)
}
describe('OrchestrationDb', () => {
let db: OrchestrationDb | undefined
@ -696,6 +710,67 @@ describe('OrchestrationDb', () => {
expect(stale.map((s) => s.id)).toEqual([ctxB.id])
})
// Regression for #8452: dispatched_at / last_heartbeat_at are written by
// datetime('now') (space-format, e.g. "2026-07-12 12:00:00") while the
// threshold is ISO ("...T11:55:00.000Z"). Raw TEXT ordering ranks the space
// (0x20) below the 'T' (0x54) at index 10, flagging fresh same-date rows.
it('getStaleDispatches ignores fresh SQLite space-format timestamps (#8452)', () => {
const d = createDb()
// Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both
// after the 11:55 threshold → NOT stale.
const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh')
setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00')
// Legacy ISO-format fresh row (mixed-format table) stays fresh too.
const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy')
setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z')
// Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold.
const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung')
setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00')
const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z')
expect(stale.map((s) => s.id)).toEqual([hung.id])
})
it('getStaleDispatches keeps a just-dispatched space-format row in the grace window (#8452)', () => {
const d = createDb()
// Space-format dispatched_at one minute after the threshold, no heartbeat
// yet → still inside the grace window, must not be flagged.
const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x')
setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00')
const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z')
expect(stale).toEqual([])
})
// Same-UTC-date midnight threshold: keeps the buggy space-vs-'T' compare in
// play so this guards the fix at a day boundary (#8452; idea from @KMGeon's #8453).
it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => {
const d = createDb()
const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight')
setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00')
const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z')
expect(stale).toEqual([])
})
// Guards the last_heartbeat_at half of the fix on its own: a worker
// dispatched long before the threshold (stale under either format) that
// just sent a fresh space-format heartbeat must stay fresh (#8452).
it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => {
const d = createDb()
const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live')
setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00')
const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z')
expect(stale).toEqual([])
})
it('getThreadMessagesFor returns only same-thread replies to a handle', () => {
const d = createDb()
const outbound = d.insertMessage({

View File

@ -798,17 +798,20 @@ export class OrchestrationDb {
// failed / circuit_broken row with an old-or-null last_heartbeat_at would
// warn every tick (warning storm). Without `dispatched_at < :threshold`,
// a freshly-dispatched worker would trip the warning during its first
// heartbeat interval (false positive). Callers supply the threshold as an
// ISO timestamp so the SQLite string-compare ordering works correctly
// (ISO-8601 compares lexicographically in time order).
// heartbeat interval (false positive). The stored columns are space-format
// (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so
// raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and
// flags fresh same-date rows as stale (#8452). julianday() parses both
// formats as UTC for a correct numeric comparison; a malformed timestamp
// yields NULL, so that row simply isn't flagged.
getStaleDispatches(thresholdIso: string): DispatchContextRow[] {
return this.db
.prepare(
`SELECT * FROM dispatch_contexts
WHERE status = 'dispatched'
AND dispatched_at IS NOT NULL
AND dispatched_at < ?
AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)`
AND julianday(dispatched_at) < julianday(?)
AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))`
)
.all(thresholdIso, thresholdIso) as DispatchContextRow[]
}