fix(schedule): close review gaps
This commit is contained in:
@@ -40,7 +40,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text, replaced-item count, and estimated shadowed-token count come from the checkpoint's cited `compact/summary` event; a window cut that left that event outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.
|
||||
|
||||
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by the durable event type. Tail loading and true gap repair continue to use the existing `liveBuffer`. Ordinary `loadOlder` leaves live-tail appends in the current window and prepends its page after the await, while an overlapping late sidecar upgrades immediately. Reconnect advances the generation and clears page or repair ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
|
||||
A Host may redeliver the same Session event seq with a new or changed non-persistent view after the event reaches its presentation commit point. `Session` first requires deep event identity, then upgrades only the sidecar; a generic event view becomes one `PresentedEventNode` keyed by the durable event type. Tail loading and true gap repair continue to use the existing `liveBuffer`; repair continues while each accepted snapshot advances the tail and a buffered gap remains, while an identity-conflicting snapshot triggers a full resync. Ordinary `loadOlder` leaves live-tail appends in the current window and prepends its page after the await, while an overlapping late sidecar upgrades immediately. Reconnect advances the generation and clears page or repair ownership, so an older request's result or `finally` cannot mutate or block the rebuilt window.
|
||||
|
||||
## Request inspection
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点引用的 `compact/summary` 事件;窗口切分把该事件留在窗口外时这些字段不可用,后续包含该事件的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能约定:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按持久事件类型形成一个 `PresentedEventNode`。`liveBuffer` 仍只用于尾部加载与真正的 gap repair。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 page/repair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
|
||||
一个 Session event 到达其 presentation 提交点后,Host 可以用同一 seq 重新投递完全相同的事件,并携带新增或变化的非持久 view。`Session` 会先要求事件深度一致,再只升级 sidecar;通用 event view 会按持久事件类型形成一个 `PresentedEventNode`。`liveBuffer` 仍只用于尾部加载与真正的 gap repair;每当已接受的快照推进 tail 后仍留有已缓冲的 gap,repair 就会继续;身份冲突的快照则会触发全量重新同步。普通 `loadOlder` 会将 live-tail 追加项留在当前窗口中,并在 await 后前插所取页面;重叠的迟到 sidecar 则会立即升级。重连会推进 generation 并清除 page/repair 的所有权,因此旧请求的结果或 `finally` 既不能改写,也不能阻塞重建后的窗口。
|
||||
|
||||
## 请求检查
|
||||
|
||||
|
||||
@@ -700,11 +700,16 @@ export class Session implements SessionFace {
|
||||
* overwrite a newer push frame); the window events themselves are never
|
||||
* folded — the host is the only computation site.
|
||||
*/
|
||||
private installWindow(entries: HistoryEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.mergeWindow(entries)
|
||||
private installWindow(
|
||||
entries: HistoryEntry[],
|
||||
hasMore: boolean,
|
||||
projections?: ProjectionsBaseline,
|
||||
): { changed: boolean; hasGap: boolean } {
|
||||
const merged = this.mergeWindow(entries)
|
||||
this.hasMore = hasMore
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.notifier.markDirty()
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -918,16 +923,32 @@ export class Session implements SessionFace {
|
||||
if (this.stitching) return
|
||||
this.stitching = true
|
||||
const generation = this.openGeneration
|
||||
let retryGap = false
|
||||
let acceptedHistory = false
|
||||
try {
|
||||
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
|
||||
if (generation !== this.openGeneration || this.openState !== 'open') return
|
||||
if (result.ok) {
|
||||
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
|
||||
acceptedHistory = true
|
||||
const previousTail = this.windowTailSeq()
|
||||
const { hasGap } = this.installWindow(
|
||||
result.value.events,
|
||||
result.value.hasMore,
|
||||
result.value.projections,
|
||||
)
|
||||
const repairedTail = this.windowTailSeq()
|
||||
retryGap = hasGap && repairedTail !== null
|
||||
&& (previousTail === null || repairedTail > previousTail)
|
||||
} else {
|
||||
this.mergeWindow()
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.openGeneration) {
|
||||
if (acceptedHistory) {
|
||||
console.error('[web-runtime] gap repair snapshot failed validation:', error)
|
||||
void this.resync()
|
||||
return
|
||||
}
|
||||
console.error('[web-runtime] gap repair failed:', error)
|
||||
try {
|
||||
this.mergeWindow()
|
||||
@@ -940,6 +961,7 @@ export class Session implements SessionFace {
|
||||
if (generation === this.openGeneration) {
|
||||
this.stitching = false
|
||||
this.notifier.markDirty()
|
||||
if (retryGap) void this.repairGap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,6 +821,64 @@ describe('live event path', () => {
|
||||
const seqs = session.getSnapshot().nodes.map(n => n.seq)
|
||||
expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9
|
||||
})
|
||||
|
||||
it('continues repair when one tail snapshot leaves a later buffered gap', async () => {
|
||||
const initial = logRange(0, 6)
|
||||
const firstGap = ev.user(9, 'first repaired event')
|
||||
const laterGap = ev.user(12, 'later buffered event')
|
||||
const firstSnapshot = [...initial, ...logRange(6, 9), firstGap]
|
||||
const completeSnapshot = [...firstSnapshot, ...logRange(10, 12), laterGap]
|
||||
const firstRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const secondRepair = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
|
||||
const { api, session } = await opened(initial)
|
||||
let repairs = 0
|
||||
api.onHistory = () => ++repairs === 1 ? firstRepair.promise : secondRepair.promise
|
||||
|
||||
session.handleMuxEnvelope('first-gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: firstGap,
|
||||
})
|
||||
session.handleMuxEnvelope('later-gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: laterGap,
|
||||
})
|
||||
firstRepair.resolve(ok({ events: entries(firstSnapshot) as never[], hasMore: false }))
|
||||
|
||||
await vi.waitFor(() => { expect(repairs).toBe(2) })
|
||||
secondRepair.resolve(ok({ events: entries(completeSnapshot) as never[], hasMore: false }))
|
||||
await vi.waitFor(() => {
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9, 12])
|
||||
})
|
||||
})
|
||||
|
||||
it('resyncs when a successful gap snapshot conflicts with a buffered event identity', async () => {
|
||||
const initial = logRange(0, 6)
|
||||
const live = ev.user(9, 'live identity')
|
||||
const conflicting = ev.user(9, 'conflicting history identity')
|
||||
const consistent = [...initial, ...logRange(6, 9), live]
|
||||
const { api, session } = await opened(initial)
|
||||
let repairs = 0
|
||||
api.onHistory = () => {
|
||||
repairs++
|
||||
return repairs === 1
|
||||
? histResponse([...initial, ...logRange(6, 9), conflicting])
|
||||
: histResponse(consistent)
|
||||
}
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
try {
|
||||
session.handleMuxEnvelope('gap' as never, {
|
||||
type: 'session/event', sessionId: SID, event: live,
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(repairs).toBe(2)
|
||||
expect(session.getSnapshot().nodes.map(node => node.seq)).toEqual([9])
|
||||
})
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[web-runtime] gap repair snapshot failed validation:',
|
||||
expect.objectContaining({ message: 'session event identity mismatch at seq 9' }),
|
||||
)
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('paging', () => {
|
||||
|
||||
@@ -109,6 +109,8 @@ declare module 'cordis' {
|
||||
* Observe a successful durability checkpoint. `throughSeq` is the exclusive
|
||||
* event boundary captured when {@link SessionStore.flush} began; events
|
||||
* appended while its listeners run require a later successful checkpoint.
|
||||
* Concurrent checkpoints may publish their boundaries out of order, so a
|
||||
* consumer retaining progress must advance by the maximum observed value.
|
||||
* No notification is published when no durability listener participated or
|
||||
* any listener failed. Observer failures are logged and contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's
|
||||
|
||||
@@ -16,7 +16,7 @@ The package owns the strict version-1 `schedule/change` create, delete, and disp
|
||||
|
||||
Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It pairs a dispatch with the active create in the same ownership segment and returns `scheduleId`, prompt, occurrence, and `session-local` mode. A dispatch inside a persisted fork prefix folds that parent prefix for history display; a child-owned dispatch folds only the child suffix, so presentation never changes live ownership.
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It pairs a dispatch with the active create in the same ownership segment and returns `scheduleId`, prompt, occurrence, and `session-local` mode. A dispatch inside a persisted fork prefix folds from its nearest preceding `session/end-seed` boundary, so nested generations may reuse session-local ids without hiding ancestor receipts; a child-owned dispatch folds only the child suffix, so presentation never changes live ownership.
|
||||
|
||||
## Management tools
|
||||
|
||||
@@ -79,6 +79,7 @@ The reminder appends after existing history and preserves its reusable prefix. I
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Session-local delivery only** — a reminder runs on time only while its original session is live; a cold session receives no external notification and processes an overdue record only after resume.
|
||||
- **Activity-driven persistence retry** — a rejected due preflight leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute.
|
||||
- **After-only protocol** — version 1 rejects `at`, `every_seconds`, `cron`, and `time_zone`; those rules require later protocol variants rather than hidden compatibility fields.
|
||||
- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects.
|
||||
- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它把 dispatch 与同一 ownership segment 中的活动 create 配对,并返回 `scheduleId`、prompt、occurrence 和 `session-local` 模式。位于已持久 fork 前缀中的 dispatch 会折叠对应 parent 前缀用于 history 显示;child 自有 dispatch 只折叠 child 后缀,因此 presentation 绝不会改变 live ownership。
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它把 dispatch 与同一 ownership segment 中的活动 create 配对,并返回 `scheduleId`、prompt、occurrence 和 `session-local` 模式。位于已持久 fork 前缀中的 dispatch 会从最近的前置 `session/end-seed` 边界开始折叠,因此嵌套 generation 可以复用会话本地 id,而不会隐藏祖先回执;child 自有 dispatch 只折叠 child 后缀,因此 presentation 绝不会改变 live ownership。
|
||||
|
||||
## 管理工具
|
||||
|
||||
@@ -79,6 +79,7 @@ reminder_prompt_json: <JSON.stringify(prompt)>
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行;cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
|
||||
- **活动驱动的持久化重试**:到期 preflight 被拒绝后,overdue 记录仍保持活动,但不会启动私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 要求 owner 重新计算后,owner 会重试。
|
||||
- **仅支持 after 协议**:版本 1 拒绝 `at`、`every_seconds`、`cron` 和 `time_zone`;这些规则需要后续协议变体,而不是隐藏的兼容字段。
|
||||
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
|
||||
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。
|
||||
|
||||
@@ -289,10 +289,10 @@ export function scheduleView(record: AfterScheduleRecord, now: number): Schedule
|
||||
|
||||
/**
|
||||
* Derive the Web receipt for one dispatch from its owning stream segment.
|
||||
* A dispatch inside an inherited fork prefix folds that original prefix; a
|
||||
* child-owned dispatch folds only the child suffix, preserving the same
|
||||
* `seedLength` ownership rule as the live runtime while still allowing a
|
||||
* persisted parent receipt to render in child history.
|
||||
* A dispatch inside an inherited fork prefix folds from its nearest preceding
|
||||
* `session/end-seed` boundary; a child-owned dispatch folds only the child
|
||||
* suffix. Nested forks can therefore reuse session-local ids without hiding a
|
||||
* persisted ancestor receipt in descendant history.
|
||||
* @param events - Complete contiguous Session log.
|
||||
* @param dispatchSeq - Exact event seq to present.
|
||||
* @param seedLength - Inherited fork prefix length.
|
||||
@@ -317,7 +317,9 @@ export function scheduleReminderPresentation(
|
||||
const dispatch = decodeScheduleChange(event.data)
|
||||
if (dispatch.operation !== 'dispatch') return undefined
|
||||
|
||||
const segmentStart = dispatchSeq < seedLength ? 0 : seedLength
|
||||
const segmentStart = dispatchSeq < seedLength
|
||||
? events.slice(0, dispatchSeq).findLastIndex(candidate => candidate.type === 'session/end-seed') + 1
|
||||
: seedLength
|
||||
const before = foldScheduleEvents(events.slice(segmentStart, dispatchSeq))
|
||||
const record = before.active.find(candidate => candidate.id === dispatch.id)
|
||||
if (record === undefined) {
|
||||
|
||||
@@ -156,6 +156,22 @@ export class ScheduleOwner {
|
||||
)
|
||||
}
|
||||
|
||||
/** Fold the current exact owner suffix and contain a corrupt durable stream. */
|
||||
private readEarliest(): AfterScheduleRecord | undefined {
|
||||
try {
|
||||
const folded = foldScheduleEvents(
|
||||
this.agent.session.events,
|
||||
this.agent.session.header.seedLength ?? 0,
|
||||
)
|
||||
return 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 undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Preflight, fold, arm, or dispatch the next active one-shot reminder. */
|
||||
private async driveOnce(): Promise<void> {
|
||||
this.clearTimer()
|
||||
@@ -171,19 +187,7 @@ export class ScheduleOwner {
|
||||
// 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
|
||||
}
|
||||
const record = this.readEarliest()
|
||||
if (record === undefined) return
|
||||
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
@@ -193,47 +197,50 @@ export class ScheduleOwner {
|
||||
return
|
||||
}
|
||||
|
||||
const release = this.agent.reserveTurnAdmission()
|
||||
if (release === undefined) {
|
||||
this.waitForIdle()
|
||||
let maintenance: Promise<boolean>
|
||||
try {
|
||||
maintenance = this.agent.runMaintenance(() => {
|
||||
if (this.stopping || !this.isLive()) return Promise.resolve(false)
|
||||
const claimedRecord = this.readEarliest()
|
||||
if (claimedRecord === undefined) return Promise.resolve(false)
|
||||
const claimedTarget = Date.parse(claimedRecord.scheduledAt)
|
||||
const decisionNow = Date.now()
|
||||
if (decisionNow < claimedTarget) {
|
||||
this.arm(claimedTarget, decisionNow)
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
try {
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: renderReminderFraming(claimedRecord) }],
|
||||
source: { kind: 'plugin', plugin: 'tool-schedule' },
|
||||
})
|
||||
this.agent.followup(message)
|
||||
} catch (error: unknown) {
|
||||
if (this.isLive()) {
|
||||
this.ctx.logger.warn(`tool-schedule: framing or followup failed for agent "${this.agent.id}": ${renderThrown(error)}`)
|
||||
}
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
try {
|
||||
this.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: claimedRecord.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 Promise.resolve(false)
|
||||
}
|
||||
return Promise.resolve(true)
|
||||
})
|
||||
} catch (_busy: unknown) {
|
||||
// `runMaintenance` rejects synchronously only while another agent activity owns the idle phase.
|
||||
if (this.isLive()) 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()
|
||||
}
|
||||
if (!await maintenance) return
|
||||
|
||||
try {
|
||||
await flushSchedulePersistence(this.ctx, this.agent.session)
|
||||
|
||||
@@ -109,6 +109,19 @@ describe('version-1 Schedule decoding and folding', () => {
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
const nested = [
|
||||
scheduleEvent(createData('same-id', 'grandparent prompt'), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 1),
|
||||
{ type: 'session/end-seed', seq: 2, time: 1, data: {} } as SessionEvent,
|
||||
scheduleEvent(createData('same-id', 'parent prompt'), 3),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'same-id' }, 4),
|
||||
]
|
||||
expect(scheduleReminderPresentation(nested, 4, 5)).toEqual({
|
||||
scheduleId: 'same-id',
|
||||
prompt: 'parent prompt',
|
||||
occurrenceAt: '2026-08-05T12:00:00.000Z',
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(scheduleReminderPresentation(events, 2, 2)).toBeUndefined()
|
||||
expect(scheduleReminderPresentation([
|
||||
{ type: 'session/end-seed', seq: 0, time: 1, data: {} },
|
||||
|
||||
@@ -277,6 +277,30 @@ describe('Schedule timer and admission runtime', () => {
|
||||
expect(test.followed).toHaveLength(1)
|
||||
await owner.dispose()
|
||||
})
|
||||
|
||||
it('rechecks the durable fold after claiming maintenance', async () => {
|
||||
const test = await harness()
|
||||
appendAfter(test, 'schedule-1', 1, Date.now() - 1_000)
|
||||
test.controls.onReserve = () => {
|
||||
test.controls.onReserve = undefined
|
||||
test.agent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'delete',
|
||||
id: ScheduleId('schedule-1'),
|
||||
})
|
||||
}
|
||||
const owner = ownerFor(test)
|
||||
owner.start()
|
||||
await settle()
|
||||
|
||||
expect(test.controls.releaseCount).toBe(1)
|
||||
expect(test.followed).toEqual([])
|
||||
expect(test.agent.session.events.at(-1)?.data).toMatchObject({ operation: 'delete' })
|
||||
owner.requestDrive()
|
||||
await settle()
|
||||
expect(test.followed).toEqual([])
|
||||
await owner.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schedule runtime failure and teardown boundaries', () => {
|
||||
@@ -459,7 +483,7 @@ describe('Schedule runtime failure and teardown boundaries', () => {
|
||||
expect(unreadable.followed).toEqual([])
|
||||
})
|
||||
|
||||
it('contains owner startup and run failures', async () => {
|
||||
it('contains owner startup, maintenance, and framing failures', async () => {
|
||||
const startup = await harness()
|
||||
const startSpy = vi.spyOn(startup.ctx.agents, 'withoutInitiator')
|
||||
.mockImplementation(() => { throw new Error('initiator closing') })
|
||||
@@ -477,6 +501,29 @@ describe('Schedule runtime failure and teardown boundaries', () => {
|
||||
expect(departedStartup.controls.flushCount).toBe(0)
|
||||
departedStartSpy.mockRestore()
|
||||
|
||||
const maintenanceFailure = await harness()
|
||||
appendAfter(maintenanceFailure, 'schedule-1', 1, Date.now() - 1_000)
|
||||
const maintenanceSpy = vi.spyOn(maintenanceFailure.agent, 'runMaintenance')
|
||||
.mockImplementation(() => Promise.reject(new Error('maintenance failed')))
|
||||
const maintenanceOwner = ownerFor(maintenanceFailure)
|
||||
maintenanceOwner.start()
|
||||
await settle()
|
||||
expect(maintenanceFailure.followed).toEqual([])
|
||||
maintenanceOwner.requestDrive()
|
||||
await settle()
|
||||
expect(maintenanceSpy).toHaveBeenCalledOnce()
|
||||
|
||||
const departedMaintenance = await harness()
|
||||
appendAfter(departedMaintenance, 'schedule-1', 1, Date.now() - 1_000)
|
||||
vi.spyOn(departedMaintenance.agent, 'runMaintenance').mockImplementation(() => {
|
||||
departedMaintenance.disposeAgent()
|
||||
return Promise.reject(new Error('maintenance failed after detach'))
|
||||
})
|
||||
const departedMaintenanceOwner = ownerFor(departedMaintenance)
|
||||
departedMaintenanceOwner.start()
|
||||
await settle()
|
||||
expect(departedMaintenance.followed).toEqual([])
|
||||
|
||||
const runFailure = await harness()
|
||||
appendAfter(runFailure, 'schedule-1', 1, Date.now() - 1_000)
|
||||
const uuidSpy = vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => { throw 'message failed' })
|
||||
@@ -486,7 +533,7 @@ describe('Schedule runtime failure and teardown boundaries', () => {
|
||||
uuidSpy.mockRestore()
|
||||
failingOwner.requestDrive()
|
||||
await settle()
|
||||
expect(runFailure.followed).toEqual([])
|
||||
expect(runFailure.followed).toHaveLength(1)
|
||||
|
||||
const departedRun = await harness()
|
||||
appendAfter(departedRun, 'schedule-1', 1, Date.now() - 1_000)
|
||||
|
||||
Reference in New Issue
Block a user