From 8cc3bed5f1eac9d49af88365731cb4319998fe64 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 10 Sep 2026 15:46:27 +0800 Subject: [PATCH] Preserve run start timestamps when settling terminal status. cron_runs entered/exited were identical because settleRun rewrote stateEnteredAt and actualAt to the finish instant. Co-authored-by: Cursor --- lib/fire.js | 11 ++++++----- test/monitor.test.js | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/fire.js b/lib/fire.js index c3b269f..7cbe2ba 100644 --- a/lib/fire.js +++ b/lib/fire.js @@ -252,13 +252,14 @@ export function settleRun(state, runId, terminal, now) { const status = terminal?.status && !ACTIVE_RUN_STATUSES.has(terminal.status) ? terminal.status : 'succeeded' - const prevStatus = run.status + // Keep stateEnteredAt / actualAt from queued→running so cron_runs duration is real. + // Only stamp exitedAt (and lastRunAt on the job) at settle time. let next = patchRun(state, runId, { status, summary: terminal?.summary || '', error: terminal?.error || null, - actualAt: now, - stateEnteredAt: prevStatus === status ? (run.stateEnteredAt || now) : now, + actualAt: run.actualAt || now, + stateEnteredAt: run.stateEnteredAt || run.actualAt || now, exitedAt: now, sessionId: run.sessionId, ...terminal?.outputRef ? { outputRef: terminal.outputRef } : {}, @@ -389,8 +390,8 @@ export function interruptActiveRuns(state, now, reason = 'host_interrupted') { status: 'failed', error: reason, summary: 'Host stopped before this run finished', - actualAt: now, - stateEnteredAt: now, + actualAt: run.actualAt || now, + stateEnteredAt: run.stateEnteredAt || run.actualAt || now, exitedAt: now, }) const job = getJob(next, run.jobId) diff --git a/test/monitor.test.js b/test/monitor.test.js index d7db75c..25a6bb6 100644 --- a/test/monitor.test.js +++ b/test/monitor.test.js @@ -15,11 +15,40 @@ import { selectJobsByWatch, } from '../lib/monitor.js' import { createJobRecord, emptyState, listHistoryPage, pruneRuns } from '../lib/store.js' -import { claimOccurrence, publicJob } from '../lib/fire.js' +import { claimOccurrence, publicJob, settleRun } from '../lib/fire.js' import { createHostService } from '../lib/host.js' import { cronToolDefinitions } from '../lib/tools.js' -test('labelsMatch any/all', () => { +test('settleRun keeps running enter time and stamps exitedAt separately', () => { + const now = Date.parse('2026-09-10T07:00:00.000Z') + const started = now - 60_000 + let state = emptyState() + const job = createJobRecord({ + name: 'dur', + prompt: 'x', + schedule: { kind: 'at', at: new Date(now + 60_000).toISOString(), timezone: 'Asia/Shanghai' }, + }, state, now) + state = { ...state, jobs: [job], runs: [{ + id: 'r1', + jobId: job.id, + status: 'running', + scheduledAt: started, + actualAt: started, + stateEnteredAt: started, + exitedAt: null, + summary: '', + error: null, + sessionId: 's1', + }] } + state = settleRun(state, 'r1', { status: 'succeeded', summary: 'ok' }, now) + const run = state.runs.find((row) => row.id === 'r1') + assert.equal(run.status, 'succeeded') + assert.equal(run.stateEnteredAt, started) + assert.equal(run.actualAt, started) + assert.equal(run.exitedAt, now) + assert.ok(run.exitedAt > run.stateEnteredAt) +}) + const job = { role: 'worker', task: 'theory', owner: 'alice' } assert.equal(labelsMatch(job, { role: 'worker', task: 'theory' }, 'all'), true) assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'all'), false)