Make cron_retrigger fire-and-forget so callers do not wait for the run.

Detached settle still finishes history and delivery in the background.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-11 20:54:40 +08:00
parent 89ab5f59af
commit a41a2b53c9
3 changed files with 102 additions and 9 deletions

View file

@ -716,6 +716,7 @@ export function createHostService(options = {}) {
* - recurring (cron): after_minutes must be 0/omitted → enable + immediate run-now (keeps cron next)
* - one-shot (at): consumed only; 0 → run-now; >0 → reschedule next
* Auto-enables paused jobs. Rejects when a run is already pending/running.
* Immediate run-now is fire-and-forget: returns once the session starts; settle/delivery continue in background.
*/
async function retriggerJob(jobId, opts = {}, identity = null) {
const afterRaw = opts.afterMinutes ?? opts.after_minutes
@ -811,10 +812,12 @@ export function createHostService(options = {}) {
}
}
const result = await dispatchRun(jobId, 'run-now')
const result = await dispatchRun(jobId, 'run-now', { wait: false })
const detached = result.detached === true
|| (result.run && ACTIVE_RUN_STATUSES.has(result.run.status))
return {
ok: true,
mode,
mode: detached ? 'started' : 'immediate',
job: result.job,
run: result.run,
decision: result.decision,
@ -945,6 +948,7 @@ export function createHostService(options = {}) {
async function dispatchRun(jobId, trigger, opts = {}) {
let claimed
const t = Number.isFinite(opts.at) ? opts.at : now()
const waitForCompletion = opts.wait !== false
await withState((current) => {
claimed = claimOccurrence(current, jobId, t, trigger, current.settings)
if (claimed.decision.action === 'wait') return current
@ -964,6 +968,19 @@ export function createHostService(options = {}) {
})
let run = (executed.runs || []).find((row) => row.id === claimed.run.id)
if (run?.status === 'running' && typeof sessionPort?.waitForTurn === 'function') {
if (!waitForCompletion) {
const runId = run.id
const sessionId = run.sessionId
const decision = claimed.decision
void trackDetachedSettle(jobId, runId, sessionId, decision)
const state = await snapshot()
return {
job: jobView(getJob(state, jobId), state.runs),
run: runView(run),
decision,
detached: true,
}
}
let terminal
try {
terminal = await sessionPort.waitForTurn(run.sessionId)
@ -978,6 +995,38 @@ export function createHostService(options = {}) {
return settleAndNotify(jobId, claimed.run.id, claimed.decision)
}
async function settleDetachedRun(jobId, runId, sessionId, claimedDecision) {
let terminal
try {
terminal = await sessionPort.waitForTurn(sessionId)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
terminal = { status: 'failed', error: message, summary: message }
}
try {
await store.mutate((current) => settleRun(current, runId, terminal, now()))
await settleAndNotify(jobId, runId, claimedDecision)
} catch (error) {
logger.warn?.(`[dsh-ops-cron] detached settle failed for ${runId}: ${error instanceof Error ? error.message : error}`)
}
}
const detachedSettles = new Set()
function trackDetachedSettle(jobId, runId, sessionId, claimedDecision) {
const task = settleDetachedRun(jobId, runId, sessionId, claimedDecision)
.catch(() => {})
.finally(() => { detachedSettles.delete(task) })
detachedSettles.add(task)
return task
}
async function flushDetachedRuns() {
while (detachedSettles.size) {
await Promise.allSettled([...detachedSettles])
}
}
async function tick() {
if (ticking) return []
ticking = true
@ -1023,9 +1072,11 @@ export function createHostService(options = {}) {
}
function stopTimer() {
if (!timer) return
clearInterval(timer)
timer = null
if (timer) {
clearInterval(timer)
timer = null
}
return flushDetachedRuns()
}
async function concealKnownSessions() {
@ -1613,6 +1664,7 @@ export function createHostService(options = {}) {
recover,
startTimer,
stopTimer,
flushDetachedRuns,
handleRequest,
snapshot,
getUdsAuth,