feat(schedule): add durable after package

This commit is contained in:
pku-xht
2026-08-05 17:10:52 +08:00
committed by Tianyi Cui
parent 3499c59ba8
commit a229b42e24
18 changed files with 2797 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
/**
* Disposable live timer projection for one exact root agent.
* @module @deepseek-ai/dsh-tool-schedule
*/
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { AfterScheduleRecord } from './types.ts'
import { foldScheduleEvents, renderReminderFraming, ScheduleLogError } from './domain.ts'
import { flushSchedulePersistence } from './persistence.ts'
/** Largest delay that Node timers represent without clamping. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
/** Select the earliest target while preserving create order for ties. */
function earliest(records: readonly AfterScheduleRecord[]): AfterScheduleRecord | undefined {
let selected: AfterScheduleRecord | undefined
let selectedAt = Number.POSITIVE_INFINITY
for (const record of records) {
const target = Date.parse(record.scheduledAt)
if (target < selectedAt) {
selected = record
selectedAt = target
}
}
return selected
}
/** Render an unknown value for process-local diagnostics only. */
function renderThrown(value: unknown): string {
return value instanceof Error ? value.message : String(value)
}
/** One process-local, disposable projection of an exact agent's durable schedules. */
export class ScheduleOwner {
private timer: ReturnType<typeof setTimeout> | undefined
private idleWait: Promise<void> | undefined
private run: Promise<void> | undefined
private requested = false
private stopping = false
private faulted = false
private disposal: Promise<void> | undefined
/**
* Construct an inactive owner; {@link start} begins the first preflight.
* @param ctx - Global service context.
* @param agent - Exact live root agent.
*/
constructor(
private readonly ctx: Context,
private readonly agent: Agent,
) {}
/** Begin the initial durability preflight and timer derivation. */
start(): void {
this.requestDrive()
}
/** Recompute the live projection after a committed mutation or idle transition. */
requestDrive(): void {
if (this.stopping || this.faulted) return
this.clearTimer()
this.requested = true
if (this.run !== undefined) return
let run: Promise<void>
try {
run = this.ctx.agents.withoutInitiator(() => this.runRequested())
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: could not start owner for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
this.run = run
void run.then(
() => { this.retire(run) },
(error: unknown) => {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: owner failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
this.faulted = true
this.retire(run)
},
)
}
/** Stop future work, cancel timers, and await every outstanding owner promise. */
dispose(): Promise<void> {
return (this.disposal ??= (async () => {
this.stopping = true
this.requested = false
this.clearTimer()
const pending = [this.run, this.idleWait].filter((value): value is Promise<void> => value !== undefined)
await Promise.allSettled(pending)
})())
}
/** Drain coalesced triggers serially. */
private async runRequested(): Promise<void> {
while (this.requested && !this.stopping && !this.faulted) {
this.requested = false
await this.driveOnce()
}
}
/** Retire one exact run and honor a trigger that landed during its final microtask. */
private retire(run: Promise<void>): void {
/* v8 ignore next -- only the exact stored run installs this callback. */
if (this.run !== run) return
this.run = undefined
/* v8 ignore next -- covers a trigger in the promise-settlement microtask gap. */
if (this.requested && !this.stopping && !this.faulted) this.requestDrive()
}
/** Whether this exact root lifecycle remains authoritative. */
private isLive(): boolean {
return this.ctx.agents.get(this.agent.id) === this.agent
&& this.ctx.agents.roots().includes(this.agent)
}
/** Cancel the currently armed timer, if any. */
private clearTimer(): void {
if (this.timer === undefined) return
clearTimeout(this.timer)
this.timer = undefined
}
/** Arm one bounded timer segment; every wake rechecks the wall clock. */
private arm(target: number, now: number): void {
const delay = Math.min(target - now, MAX_TIMER_DELAY_MS)
this.timer = setTimeout(() => {
this.timer = undefined
this.requestDrive()
}, delay)
}
/** Await one public idle boundary without holding admission or creating a retry timer. */
private waitForIdle(): void {
if (this.idleWait !== undefined) return
const wait = this.agent.whenIdle()
this.idleWait = wait
void wait.then(
() => {
this.idleWait = undefined
this.requestDrive()
},
(error: unknown) => {
this.idleWait = undefined
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: idle wait failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
},
)
}
/** Preflight, fold, arm, or dispatch the next active one-shot reminder. */
private async driveOnce(): Promise<void> {
this.clearTimer()
if (this.stopping || !this.isLive()) return
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: preflight failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal or replacement can win while persistence is awaited.
if (this.stopping || !this.isLive()) return
let record: AfterScheduleRecord | undefined
try {
const folded = foldScheduleEvents(
this.agent.session.events,
this.agent.session.header.seedLength ?? 0,
)
record = earliest(folded.active)
} catch (error: unknown) {
this.faulted = true
const detail = error instanceof ScheduleLogError ? error.message : renderThrown(error)
this.ctx.logger.warn(`tool-schedule: corrupt schedule log for agent "${this.agent.id}": ${detail}`)
return
}
if (record === undefined) return
const target = Date.parse(record.scheduledAt)
const wakeNow = Date.now()
if (wakeNow < target) {
this.arm(target, wakeNow)
return
}
const release = this.agent.reserveTurnAdmission()
if (release === undefined) {
this.waitForIdle()
return
}
try {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- reservation can invalidate the owner.
if (this.stopping || !this.isLive()) return
const decisionNow = Date.now()
if (decisionNow < target) {
this.arm(target, decisionNow)
return
}
const message = createUserMessage({
content: [{ type: 'text', text: renderReminderFraming(record) }],
source: { kind: 'plugin', plugin: 'tool-schedule' },
})
try {
this.agent.followup(message)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: followup failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
try {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
})
} catch (error: unknown) {
this.faulted = true
this.clearTimer()
this.ctx.logger.warn(`tool-schedule: dispatch append failed for agent "${this.agent.id}": ${renderThrown(error)}`)
return
}
} finally {
release()
}
try {
await flushSchedulePersistence(this.ctx, this.agent.session)
} catch (error: unknown) {
if (this.isLive()) {
this.ctx.logger.warn(`tool-schedule: dispatch barrier failed for agent "${this.agent.id}": ${renderThrown(error)}`)
}
return
}
// oxlint-disable-next-line typescript/no-unnecessary-condition -- disposal can win while the barrier is awaited.
if (!this.stopping && this.isLive()) this.requestDrive()
}
}