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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-10 15:46:27 +08:00
parent 6ed7187754
commit 8cc3bed5f1
2 changed files with 37 additions and 7 deletions

View file

@ -252,13 +252,14 @@ export function settleRun(state, runId, terminal, now) {
const status = terminal?.status && !ACTIVE_RUN_STATUSES.has(terminal.status) const status = terminal?.status && !ACTIVE_RUN_STATUSES.has(terminal.status)
? terminal.status ? terminal.status
: 'succeeded' : '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, { let next = patchRun(state, runId, {
status, status,
summary: terminal?.summary || '', summary: terminal?.summary || '',
error: terminal?.error || null, error: terminal?.error || null,
actualAt: now, actualAt: run.actualAt || now,
stateEnteredAt: prevStatus === status ? (run.stateEnteredAt || now) : now, stateEnteredAt: run.stateEnteredAt || run.actualAt || now,
exitedAt: now, exitedAt: now,
sessionId: run.sessionId, sessionId: run.sessionId,
...terminal?.outputRef ? { outputRef: terminal.outputRef } : {}, ...terminal?.outputRef ? { outputRef: terminal.outputRef } : {},
@ -389,8 +390,8 @@ export function interruptActiveRuns(state, now, reason = 'host_interrupted') {
status: 'failed', status: 'failed',
error: reason, error: reason,
summary: 'Host stopped before this run finished', summary: 'Host stopped before this run finished',
actualAt: now, actualAt: run.actualAt || now,
stateEnteredAt: now, stateEnteredAt: run.stateEnteredAt || run.actualAt || now,
exitedAt: now, exitedAt: now,
}) })
const job = getJob(next, run.jobId) const job = getJob(next, run.jobId)

View file

@ -15,11 +15,40 @@ import {
selectJobsByWatch, selectJobsByWatch,
} from '../lib/monitor.js' } from '../lib/monitor.js'
import { createJobRecord, emptyState, listHistoryPage, pruneRuns } from '../lib/store.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 { createHostService } from '../lib/host.js'
import { cronToolDefinitions } from '../lib/tools.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' } const job = { role: 'worker', task: 'theory', owner: 'alice' }
assert.equal(labelsMatch(job, { role: 'worker', task: 'theory' }, 'all'), true) assert.equal(labelsMatch(job, { role: 'worker', task: 'theory' }, 'all'), true)
assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'all'), false) assert.equal(labelsMatch(job, { role: 'worker', owner: 'bob' }, 'all'), false)