diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts index 9cf7ad73d0c..5f7bc54efdb 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts @@ -132,6 +132,7 @@ describe('AgentInputCoordinator Orca priority queue transactions', () => { expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith( sid, expect.objectContaining({ clientId: 'q2' }), + 'queue-merge', ); expect(h.emitProjection).toHaveBeenCalledTimes(1); }); @@ -4032,6 +4033,7 @@ describe('AgentInputCoordinator send transaction', () => { expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith( sid, expect.objectContaining({ clientId: 'q-1' }), + 'queue-replaced', ); }); @@ -4286,7 +4288,11 @@ describe('AgentInputCoordinator send transaction', () => { }, ); expect(h.onUserMessagePersisted).not.toHaveBeenCalled(); - expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith(sid, expect.objectContaining(first)); + expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith( + sid, + expect.objectContaining(first), + 'session-clear', + ); expect(latestProjection(h.projections).pendingQueue).toEqual([]); }); @@ -10572,6 +10578,7 @@ describe('AgentInputCoordinator 中断自动续跑', () => { autoResume: true, origin: schedulerItem.origin, }), + 'queue-replaced', ); expect(h.onUserEnqueue).toHaveBeenCalledWith(sid); @@ -10610,6 +10617,7 @@ describe('AgentInputCoordinator 中断自动续跑', () => { expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith( sid, expect.objectContaining({ autoResume: true, origin: schedulerItem.origin }), + 'internal-cleanup', ); expect(latestProjection(h.projections).pendingQueue).toEqual([]); expect(h.sendToAgent).toHaveBeenCalledTimes(1); @@ -10749,6 +10757,7 @@ describe('AgentInputCoordinator 中断自动续跑', () => { expect(h.onDiscardedQueuedMessage).toHaveBeenCalledWith( sid, expect.objectContaining({ autoResume: true }), + 'internal-cleanup', ); expect(latestProjection(h.projections).pendingQueue).toEqual([]); diff --git a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts index 6263bc58324..095ece58527 100644 --- a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts +++ b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts @@ -247,6 +247,19 @@ export type AutoRetryOutcome = 'resumed' | 'superseded' | 'no-progress'; export type AgentInputHostSendFailureCode = HostSendFailureCode; +/** 队列项跨过 vendor dispatch 前被移除的结构化来源;不得用错误文本反推。 */ +export type AgentInputQueuedMessageDiscardReason = + | 'user-remove' + | 'session-stop' + | 'session-clear' + | 'hook-blocked' + | 'queue-replaced' + | 'queue-merge' + | 'queue-restore-stale' + | 'scheduler-abort' + | 'dispatch-timeout' + | 'internal-cleanup'; + export type AgentInputSendResult = | HostSendOutcome | { kind: 'session-dispatch'; source: string; dispatched: true } @@ -482,7 +495,11 @@ export interface AgentInputCoordinatorDeps { * host 用它释放按 clientId 暂存的 accepted 副作用(如 orca 排队消息的回调表), * 否则被丢弃项的暂存条目会永久泄漏。 */ - onDiscardedQueuedMessage?: (sessionId: string, item: AgentInputQueuedMessage) => void; + onDiscardedQueuedMessage?: ( + sessionId: string, + item: AgentInputQueuedMessage, + reason: AgentInputQueuedMessageDiscardReason, + ) => void; /** * 这个会话的失败 turn 正在重试;`source` 区分人工操作与自动续跑。 * @@ -1210,7 +1227,7 @@ export class AgentInputCoordinator { this.restoredQueueSessions.add(sessionId); this.queueRestorePromises.delete(sessionId); for (const item of items) { - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'internal-cleanup'); } this.maybePersistQueueSnapshot(sessionId); return; @@ -1247,7 +1264,7 @@ export class AgentInputCoordinator { }); if (staleClearItems.length > 0) { for (const item of staleClearItems) { - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'session-clear'); } log.info('dropped pre-clear queue item(s) from crash snapshot', { sessionId, @@ -1277,7 +1294,7 @@ export class AgentInputCoordinator { const restored = restorable.filter((item) => item.origin?.kind !== 'scheduler'); if (staleSchedulerItems.length > 0) { for (const item of staleSchedulerItems) { - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'queue-restore-stale'); } log.info('dropped stale scheduler heartbeat item(s) from crash snapshot', { sessionId, @@ -1524,7 +1541,7 @@ export class AgentInputCoordinator { // pre-vendor await。用户此时接管不能只作废 host waiter:那会让隐藏 Continue // 继续派发、而新输入排在它后面。复用 Stop 的 generation 取消边界,先确认 // 旧续跑已被 coordinator 丢弃,再发布用户接管信号。 - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'queue-replaced'); } if (isUiContinuationItem(item)) { // queue-head recovery 时**跳过** onUiRetry:那条消息在派发前就失败了, @@ -1603,7 +1620,7 @@ export class AgentInputCoordinator { // 与 remove() 同口径:摘除消息时同步清其 edit lock,否则留下指向不存在 // clientId 的孤儿锁,shouldQueueNewTurn 会把后续新输入误导向排队。 state.queueEditLocks = state.queueEditLocks.filter((id) => id !== abandonedClientId); - this.deps.onDiscardedQueuedMessage?.(sessionId, abandoned); + this.deps.onDiscardedQueuedMessage?.(sessionId, abandoned, 'queue-replaced'); // 脱敏:只记 id/布尔,不记消息文本(白名单方向,见 log-upload-and-redaction)。 log.info('explicit user input abandoned queue-head message (never accepted)', { sessionId, @@ -1689,7 +1706,7 @@ export class AgentInputCoordinator { item = stampHostAcceptedAt(item, state.clearBoundaryMs); this.rememberEnqueuedClientId(state, item.clientId); - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'queue-replaced'); this.deps.onAutomaticEnqueue?.(sessionId); state.autoResumePending = null; state.autoResumeAttemptToken = null; @@ -1726,7 +1743,7 @@ export class AgentInputCoordinator { // 手动 /compact 是用户接管,与 composer 新消息同语义。先撤掉尚未跨过 // vendor dispatch 的隐藏 scheduler 续跑,再让 host 终止对应 run waiter; // 否则 compact 自己的 text/done 可能被旧 schedule run 误收。 - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'queue-replaced'); this.deps.onUserEnqueue?.(sessionId); // 手动压缩与发送新消息一样,都是用户对失败 turn 的明确后续选择: // 放弃 active-turn retry,让 /compact 在真实 dispatch boundary 空闲时立即执行, @@ -1974,7 +1991,7 @@ export class AgentInputCoordinator { } if (!isSchedulerOriginItem(item)) { - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'queue-replaced'); } if (!this.isTurnSteerable(sessionId, state)) { @@ -2062,7 +2079,7 @@ export class AgentInputCoordinator { } this.deps.onUserMessageBlocked?.(sessionId, item, verdict); this.notifyRejectedUserTurn(sessionId, item); - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'hook-blocked'); this.emit(sessionId); this.scheduleDrain(sessionId, 'steer-ghost-blocked'); // 普通 UI steer 的 true 表示“已处置”,避免 renderer 把被策略拦截的内容 @@ -2460,7 +2477,7 @@ export class AgentInputCoordinator { const state = this.getState(sessionId); const preserveQueue = opts?.keepQueue === true; this.supersedePendingAutoResumeRecoveries(sessionId); - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'session-stop'); this.abortInputBoundary(sessionId); this.abortSteerTransactions(sessionId); this.clearAbortReconcileRetry(state); @@ -2478,7 +2495,7 @@ export class AgentInputCoordinator { senderLabel: item.origin.senderLabel, }); } - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'session-stop'); } // 整批丢弃的排队消息同样从未派发:从弱网重发幂等窗口遗忘,允许再次入队。 const droppedIds = new Set(droppedQueue.map((q) => q.clientId)); @@ -2498,7 +2515,7 @@ export class AgentInputCoordinator { clearErrorProjectionSignals(state); state.stickyError = null; state.recovery = null; - this.cancelPreSendActiveTurn(sessionId, state, preserveQueue); + this.cancelPreSendActiveTurn(sessionId, state, preserveQueue, 'session-stop'); // 用户显式 Stop 立即结束续跑行的 vendor-turn 归属。已跨过 vendor dispatch // 的 activeTurn 仍需保留到 abort/terminal 收口,以维持队列边界;这里只清 owner, // 让本次 stop projection 不再把「重新连接中」误判为仍在飞。 @@ -2856,7 +2873,7 @@ export class AgentInputCoordinator { clearError(sessionId: string): AgentInputProjection { const state = this.getState(sessionId); if (state.autoResumePending) this.deps.onUserEnqueue?.(sessionId); - this.cancelPreparedAutoResume(sessionId, state); + this.cancelPreparedAutoResume(sessionId, state, 'queue-replaced'); const shouldDrainTail = state.recovery?.kind === 'active-turn'; state.error = null; clearErrorProjectionSignals(state); @@ -2873,13 +2890,17 @@ export class AgentInputCoordinator { return this.getProjection(sessionId); } - remove(sessionId: string, clientId: string): AgentInputProjection { + remove( + sessionId: string, + clientId: string, + reason: AgentInputQueuedMessageDiscardReason = 'user-remove', + ): AgentInputProjection { const state = this.getState(sessionId); if (state.steeringQueueClientIds.includes(clientId)) return this.getProjection(sessionId); const before = state.pendingQueue.length; const removed = state.pendingQueue.find((q) => q.clientId === clientId); state.pendingQueue = state.pendingQueue.filter((q) => q.clientId !== clientId); - if (removed) this.deps.onDiscardedQueuedMessage?.(sessionId, removed); + if (removed) this.deps.onDiscardedQueuedMessage?.(sessionId, removed, reason); // 显式移除的消息从未派发:该 clientId 允许被合法地重新入队(重排/再发都是 // 既有产品流),必须从弱网重发幂等窗口里遗忘,否则再入队会被误吞。 if (removed) { @@ -3079,7 +3100,7 @@ export class AgentInputCoordinator { state.queueEditLocks = state.queueEditLocks.filter((id) => !removedIds.has(id)); for (const item of removed) { this.removePendingCompactWaitClientId(state, item.clientId); - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'queue-merge'); } this.emit(sessionId); this.scheduleDrain(sessionId, 'merge-queued-messages'); @@ -3193,9 +3214,9 @@ export class AgentInputCoordinator { this.abortInputBoundary(sessionId); this.abortSteerTransactions(sessionId); for (const item of prev.pendingQueue) { - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'session-clear'); } - this.cancelPreSendActiveTurn(sessionId, prev, false); + this.cancelPreSendActiveTurn(sessionId, prev, false, 'session-clear'); // 显式清上下文:强制开启持久化闸门,让 emit 写出空快照(删行), // 即使此前该会话从未触发恢复(否则旧快照残留,下次打开会诈尸)。 this.restoredQueueSessions.add(sessionId); @@ -4245,7 +4266,7 @@ export class AgentInputCoordinator { this.getState(sessionId).activeTurn = null; this.deps.onUserMessageBlocked?.(sessionId, head, verdict); this.notifyRejectedUserTurn(sessionId, head); - this.deps.onDiscardedQueuedMessage?.(sessionId, head); + this.deps.onDiscardedQueuedMessage?.(sessionId, head, 'hook-blocked'); this.emit(sessionId); this.scheduleDrain(sessionId, 'ghost-hook-blocked'); return; @@ -4965,6 +4986,7 @@ export class AgentInputCoordinator { sessionId: string, state: SessionInputState, preserveQueue: boolean, + discardReason: AgentInputQueuedMessageDiscardReason, ): void { const active = state.activeTurn; if (!active || !isActiveTurnBeforeVendorDispatch(active)) return; @@ -4984,7 +5006,7 @@ export class AgentInputCoordinator { return; } if (!preserveQueue) { - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, discardReason); return; } if (!state.pendingQueue.some((q) => q.clientId === item.clientId)) { @@ -5036,7 +5058,7 @@ export class AgentInputCoordinator { // This item has already left pendingQueue but never crossed vendor dispatch. Reuse the // existing host cleanup boundary; register owns the single recovery/finalize operation. this.autoResumeDispatchAttempts.delete(item.clientId); - this.deps.onDiscardedQueuedMessage?.(sessionId, item); + this.deps.onDiscardedQueuedMessage?.(sessionId, item, 'internal-cleanup'); } /** 自动续跑项已真正进入 vendor,之后不再需要 pre-vendor 回滚信息。 */ @@ -5097,12 +5119,18 @@ export class AgentInputCoordinator { * 持久化前走 discard 回调,持久化后走 undispatched 回调,两条既有 host 边界都会结算 * suppressed error 与 guard pending 状态。 */ - private cancelPreparedAutoResume(sessionId: string, state: SessionInputState): boolean { + private cancelPreparedAutoResume( + sessionId: string, + state: SessionInputState, + discardReason: AgentInputQueuedMessageDiscardReason, + ): boolean { const queuedClientIds = state.pendingQueue .filter((item) => item.autoResume) .map((item) => item.clientId); this.supersedePendingAutoResumeRecoveries(sessionId); - for (const clientId of queuedClientIds) this.remove(sessionId, clientId); + for (const clientId of queuedClientIds) { + this.remove(sessionId, clientId, discardReason); + } const active = state.activeTurn; const item = active?.item; @@ -5110,7 +5138,7 @@ export class AgentInputCoordinator { return queuedClientIds.length > 0; } const persisted = active.persisted; - this.cancelPreSendActiveTurn(sessionId, state, false); + this.cancelPreSendActiveTurn(sessionId, state, false, discardReason); this.emit(sessionId); this.scheduleDrain(sessionId, 'auto-resume-cancelled'); log.info('cancelled prepared auto-resume', { @@ -5649,7 +5677,11 @@ export class AgentInputCoordinator { const hadPendingTakeover = state.autoResumePending !== null; state.autoResumePending = null; state.autoResumeAttemptToken = null; - const cancelledPrepared = this.cancelPreparedAutoResume(sessionId, state); + const cancelledPrepared = this.cancelPreparedAutoResume( + sessionId, + state, + 'internal-cleanup', + ); const surfacedMessage = Boolean(message && state.recovery); if (surfacedMessage) { state.error = message ?? null; diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index deb1b58a698..f0d45a6c908 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -592,7 +592,10 @@ import { } from './piPackageMutationIpc.js'; import { dbToMakerAgentKind, makerToDbAgentKind } from '../../shared/agentKindConversion.js'; import { readWorkflowProgressForSession } from '../workflow-progress/reader.js'; -import { AgentInputCoordinator } from './agent-input-coordinator.js'; +import { + AgentInputCoordinator, + type AgentInputQueuedMessageDiscardReason, +} from './agent-input-coordinator.js'; import { clearPromptPredictionSessionStopped, notePromptPredictionSessionStopped, @@ -3199,8 +3202,8 @@ export interface SchedulerQueuedPromptRequest { onAccepted: () => void | Promise; /** 派发已 accept 但最终未成为运行 turn(取消/回滚)时回调。 */ onAcceptedRollback?: () => void | Promise; - /** 排队项未派发即被丢弃(用户删除队列行 / stop 清队列 / 会话清理)时回调。 */ - onDiscarded?: () => void; + /** 排队项未派发即被丢弃时回调;reason 来自 coordinator 的实际移除入口。 */ + onDiscarded?: (reason: AgentInputQueuedMessageDiscardReason) => void; } /** @@ -3214,14 +3217,21 @@ interface SchedulerQueueBridge { isSessionBusy(sessionId: string): boolean; hasQueuedPrompt(sessionId: string, scheduleId: string): boolean; enqueuePrompt(req: SchedulerQueuedPromptRequest): Promise; - removeQueuedPrompt(sessionId: string, clientId: string): void; + removeQueuedPrompt( + sessionId: string, + clientId: string, + reason: AgentInputQueuedMessageDiscardReason, + ): void; /** 排队项(含派发中 / 可重试 recovery)是否仍被 coordinator 跟踪 —— runner 派发等待的存活探测。 */ isPromptTracked(sessionId: string, clientId: string): boolean; } let schedulerQueueBridgeHolder: SchedulerQueueBridge | null = null; /** 排队心跳的 discard 监听(clientId → 通知 runner 收尾)。派发/丢弃后清条目。 */ -const schedulerQueuedPromptDiscardWatchers = new Map void>(); +const schedulerQueuedPromptDiscardWatchers = new Map< + string, + (reason: AgentInputQueuedMessageDiscardReason) => void +>(); export function isSchedulerTargetSessionBusy(sessionId: string): boolean { return schedulerQueueBridgeHolder?.isSessionBusy(sessionId) ?? false; @@ -3245,8 +3255,12 @@ export function isSchedulerPromptTracked(sessionId: string, clientId: string): b return schedulerQueueBridgeHolder?.isPromptTracked(sessionId, clientId) ?? true; } -export function removeQueuedSchedulerPrompt(sessionId: string, clientId: string): void { - schedulerQueueBridgeHolder?.removeQueuedPrompt(sessionId, clientId); +export function removeQueuedSchedulerPrompt( + sessionId: string, + clientId: string, + reason: AgentInputQueuedMessageDiscardReason, +): void { + schedulerQueueBridgeHolder?.removeQueuedPrompt(sessionId, clientId, reason); } function sessionMetaForIsland(session: { @@ -13423,7 +13437,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) }); }, // 队列项未派发即被丢弃(stop/remove/clearSession) → 释放暂存的 accepted 副作用, 防回调表泄漏。 - onDiscardedQueuedMessage: (sessionId, item) => { + onDiscardedQueuedMessage: (sessionId, item, reason) => { rollbackAgentIslandUserPrompt(sessionId, item.clientId, 'discarded'); discardQueuedAttachmentOwnership(sessionId, item.clientId); orcaInterAgentDispatcher.discardQueuedOrcaInterAgentAcceptedCallback(item.clientId); @@ -13435,12 +13449,22 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) finalizeUndispatchedClaimedRetry(sessionId, item, 'cancelled'); // 持久化中的项在这里被取消后不会再走 onUndispatchedUserTurn,复用同一结算出口。 if (!autoResume) settleUndispatchedInterruptedAutoResume(sessionId, item); - // 排队心跳被丢弃 → 通知 runner 按 aborted 收尾对应 run,不让 fire 永久挂起。 + if (item.origin?.kind === 'scheduler') { + log.info('scheduler queued prompt discarded before dispatch', { + sessionId, + clientId: item.clientId, + scheduleId: item.origin.scheduleId, + runId: item.origin.runId, + discardReason: reason, + queueStage: 'before-dispatch', + }); + } + // 排队心跳被丢弃 → 把结构化原因交给 runner 收尾对应 run,不让 fire 永久挂起。 const watcher = schedulerQueuedPromptDiscardWatchers.get(item.clientId); if (watcher) { schedulerQueuedPromptDiscardWatchers.delete(item.clientId); try { - watcher(); + watcher(reason); } catch (err) { log.warn('scheduler queued prompt discard watcher threw', { clientId: item.clientId, @@ -13614,10 +13638,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } return { clientId }; }, - removeQueuedPrompt: (sessionId, clientId) => { + removeQueuedPrompt: (sessionId, clientId, reason) => { // remove 只作用于 pending 行;已进入派发(activeTurn)的项 no-op —— 调用方 // (runner abort 路径)对此已有兜底(转 session.abort)。 - inputCoordinator.remove(sessionId, clientId); + inputCoordinator.remove(sessionId, clientId, reason); }, // 存活探测刻意**不含** recovery:项转入 active-turn recovery 后,Retry 走 // "克隆已受理 turn"路径,不会再触发 onAcceptedQueuedMessage,排队方注册的 diff --git a/apps/desktop/src/main/scheduler-host/__tests__/runnerQueuedDispatch.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/runnerQueuedDispatch.test.ts index 6c113925a0d..43449e10d45 100644 --- a/apps/desktop/src/main/scheduler-host/__tests__/runnerQueuedDispatch.test.ts +++ b/apps/desktop/src/main/scheduler-host/__tests__/runnerQueuedDispatch.test.ts @@ -11,7 +11,7 @@ * origin=scheduler),不直发 session.send、不自行 createMessage * - accepted → 等 turn done → success run 带 resultText * - 同 schedule 已有排队项 → 顺延(deferred),不重复入队 - * - 排队项被丢弃(用户删除)→ run 以含 aborted 的错误收尾 + * - 用户删除排队项 → 结构化用户取消;其它 discard reason → 可诊断失败 * - pause/delete abort → removeQueuedPrompt 撤项 * - 会话空闲也走 coordinator,和普通聊天共享恢复生命周期 */ @@ -20,8 +20,12 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { AcceptedCallbackDispatchCancelled } from '../../maker-ipc/acceptedCallbackRunner.js'; import type { AgentEvent, Maker, Session, SessionSendResult } from '@cindy/maker-core'; -import { SCHEDULER_RUN_ID_VENDOR_OPTION } from '@cindy/maker-scheduler'; +import { + SCHEDULER_RUN_ID_VENDOR_OPTION, + ScheduleRunCancellationError, +} from '@cindy/maker-scheduler'; import type { FireContext, Logger, Notifier, Schedule, ScheduleRun } from '@cindy/maker-scheduler'; +import type { AgentInputQueuedMessageDiscardReason } from '../../maker-ipc/agent-input-coordinator.js'; const mocks = vi.hoisted(() => ({ createMessage: vi.fn(), @@ -236,12 +240,16 @@ function enqueueLast(queue: QueueHarness): Parameters[0]>; - removeCalls: Array<{ sessionId: string; clientId: string }>; + removeCalls: Array<{ + sessionId: string; + clientId: string; + reason: AgentInputQueuedMessageDiscardReason; + }>; cancelAutoResumeCalls: Array<{ sessionId: string; runId: string }>; /** 模拟 drain 派发:触发最近一次入队项的 onAccepted。 */ accept(): Promise; /** 模拟排队项被丢弃(用户删除 / abort 撤项)。 */ - discard(): void; + discard(reason?: AgentInputQueuedMessageDiscardReason): void; /** 模拟普通自动续跑最终仍失败。 */ failAutoResume(): void; } @@ -283,12 +291,12 @@ function createQueueHarness(opts: { if (opts.acceptBeforeEnqueueResolves) await req.onAccepted(); return { clientId: `client-${enqueueCalls.length}` }; }), - removeQueuedPrompt: (sessionId, clientId) => { - removeCalls.push({ sessionId, clientId }); + removeQueuedPrompt: (sessionId, clientId, reason) => { + removeCalls.push({ sessionId, clientId, reason }); // 与真实 coordinator.remove 对齐:pending 项被移除触发 onDiscarded; // 项已转 activeTurn/recovery 时 remove 是 no-op(removeTriggersDiscard=false)。 if (opts.removeTriggersDiscard !== false) { - enqueueCalls.at(-1)?.onDiscarded?.(); + enqueueCalls.at(-1)?.onDiscarded?.(reason); } }, isPromptTracked: () => (opts.tracked ? opts.tracked() : true), @@ -304,8 +312,8 @@ function createQueueHarness(opts: { async accept() { await enqueueCalls.at(-1)?.onAccepted(); }, - discard() { - enqueueCalls.at(-1)?.onDiscarded?.(); + discard(reason = 'user-remove') { + enqueueCalls.at(-1)?.onDiscarded?.(reason); }, failAutoResume() { for (const listener of [...autoResumeFailureListeners]) listener(); @@ -1013,16 +1021,32 @@ describe('MakerScheduleRunner queued dispatch (busy bound session)', () => { expect(harness.send).not.toHaveBeenCalled(); }); - it('settles the run as aborted-style failure when the queued prompt is discarded', async () => { + it('uses a structured cancellation when the user removes the queued prompt', async () => { const harness = createSessionHarness(async () => ({ accepted: true })); const queue = createQueueHarness({ busy: true }); const { runner } = createRunnerHarness(harness.session, queue.deps); const firePromise = runner.fire(heartbeatSchedule(), createFireContext()); await vi.waitFor(() => expect(queue.enqueueCalls.length).toBe(1)); - queue.discard(); + queue.discard('user-remove'); - await expect(firePromise).rejects.toThrow(/aborted/i); + await expect(firePromise).rejects.toBeInstanceOf(ScheduleRunCancellationError); + expect(harness.listenerCount()).toBe(0); + expect(isHeadlessGhostSetupTurn(SESSION_ID)).toBe(false); + }); + + it('preserves a non-user discard reason as a diagnosable failure', async () => { + const harness = createSessionHarness(async () => ({ accepted: true })); + const queue = createQueueHarness({ busy: true }); + const { runner } = createRunnerHarness(harness.session, queue.deps); + + const firePromise = runner.fire(heartbeatSchedule(), createFireContext()); + await vi.waitFor(() => expect(queue.enqueueCalls.length).toBe(1)); + queue.discard('queue-replaced'); + + await expect(firePromise).rejects.toThrow( + 'queued heartbeat prompt removed before dispatch (queue-replaced)', + ); expect(harness.listenerCount()).toBe(0); expect(isHeadlessGhostSetupTurn(SESSION_ID)).toBe(false); }); @@ -1038,7 +1062,9 @@ describe('MakerScheduleRunner queued dispatch (busy bound session)', () => { ctx.abortController.abort(); await expect(firePromise).rejects.toThrow(/aborted/i); - expect(queue.removeCalls).toEqual([{ sessionId: SESSION_ID, clientId: 'client-1' }]); + expect(queue.removeCalls).toEqual([ + { sessionId: SESSION_ID, clientId: 'client-1', reason: 'scheduler-abort' }, + ]); expect(isHeadlessGhostSetupTurn(SESSION_ID)).toBe(false); }); @@ -1579,7 +1605,9 @@ describe('MakerScheduleRunner queued dispatch: slot accounting and wait cap', () ); await expect(firePromise).resolves.toMatchObject({ deferred: true }); - expect(queue.removeCalls).toEqual([{ sessionId: SESSION_ID, clientId: 'client-1' }]); + expect(queue.removeCalls).toEqual([ + { sessionId: SESSION_ID, clientId: 'client-1', reason: 'dispatch-timeout' }, + ]); // 离开等待必须配对上报(reclaimSlot=false),否则引擎侧的槽位记账会漏 expect(started).toBe(1); expect(ends).toEqual([false]); @@ -1660,7 +1688,9 @@ describe('MakerScheduleRunner queued dispatch: slot accounting and wait cap', () QUEUED_DISPATCH_MAX_WAIT_MS + QUEUED_DISPATCH_TRACK_POLL_MS, ); await settled; - expect(queue.removeCalls).toEqual([{ sessionId: SESSION_ID, clientId: 'client-1' }]); + expect(queue.removeCalls).toEqual([ + { sessionId: SESSION_ID, clientId: 'client-1', reason: 'dispatch-timeout' }, + ]); } finally { vi.useRealTimers(); } @@ -1683,7 +1713,9 @@ describe('MakerScheduleRunner queued dispatch: slot accounting and wait cap', () ); await rejection; - expect(queue.removeCalls).toEqual([{ sessionId: SESSION_ID, clientId: 'client-1' }]); + expect(queue.removeCalls).toEqual([ + { sessionId: SESSION_ID, clientId: 'client-1', reason: 'dispatch-timeout' }, + ]); expect(notifier.notify).toHaveBeenCalled(); } finally { vi.useRealTimers(); diff --git a/apps/desktop/src/main/scheduler-host/runner.ts b/apps/desktop/src/main/scheduler-host/runner.ts index 99026572426..d32c87e6e3c 100644 --- a/apps/desktop/src/main/scheduler-host/runner.ts +++ b/apps/desktop/src/main/scheduler-host/runner.ts @@ -43,7 +43,10 @@ import type { } from '@cindy/maker-core'; import { clampEffortToSupported } from '@cindy/model-providers'; import { shouldApplyExclusiveProviderRerouteLive } from '../maker-host/model-route-guard-live.js'; -import { SCHEDULER_RUN_ID_VENDOR_OPTION } from '@cindy/maker-scheduler'; +import { + SCHEDULER_RUN_ID_VENDOR_OPTION, + ScheduleRunCancellationError, +} from '@cindy/maker-scheduler'; import type { Schedule, ScheduleRun, @@ -54,6 +57,7 @@ import type { FireResult, Scheduler, } from '@cindy/maker-scheduler'; +import type { AgentInputQueuedMessageDiscardReason } from '../maker-ipc/agent-input-coordinator.js'; import { createMessage } from '../localDb/ipc/messages.js'; import { getSessionRowSnapshot, touchUserSendInDb } from '../localDb/ipc/sessions.js'; @@ -203,9 +207,13 @@ export interface SchedulerQueueDeps { origin: { kind: 'scheduler'; scheduleId: string; scheduleName: string; runId: string }; onAccepted: () => void | Promise; onAcceptedRollback?: () => void | Promise; - onDiscarded?: () => void; + onDiscarded?: (reason: AgentInputQueuedMessageDiscardReason) => void; }): Promise<{ clientId: string } | { duplicate: true } | { retry: true }>; - removeQueuedPrompt(sessionId: string, clientId: string): void; + removeQueuedPrompt( + sessionId: string, + clientId: string, + reason: AgentInputQueuedMessageDiscardReason, + ): void; /** * 排队项(含派发中 / 可重试 recovery)是否仍被 coordinator 跟踪。派发等待的 * 存活探测:coordinator 存在不经 onDiscarded 的静默放弃路径(新输入顶掉 @@ -1883,10 +1891,26 @@ export class MakerScheduleRunner implements ScheduleRunner { failAfterAccept(err); failDispatch(err); }, - onDiscarded: () => { - // 排队项未派发即被移除(用户删队列行 / stop 清队列 / pause-delete 撤项) - // → run 按 aborted 收尾(引擎按 /abort/i 识别错误文案)。 - failDispatch(new Error('queued heartbeat prompt removed before dispatch (aborted)')); + onDiscarded: (reason) => { + this.deps.logger.info?.('[runner] queued heartbeat discarded before dispatch', { + scheduleId: schedule.id, + runId: ctx.runId, + sessionId, + discardReason: reason, + signalAborted: ctx.signal.aborted, + queueStage: 'before-dispatch', + }); + if (reason === 'user-remove') { + failDispatch( + new ScheduleRunCancellationError( + 'cancelled by user (queued automation input removed)', + ), + ); + return; + } + failDispatch( + new Error(`queued heartbeat prompt removed before dispatch (${reason})`), + ); }, }); if ('retry' in enqueueResult) { @@ -1937,12 +1961,14 @@ export class MakerScheduleRunner implements ScheduleRunner { this.deps.logger.info?.( `[runner] ctx.signal aborted while heartbeat queued, cleaning up for ${sessionId}`, ); - sq.removeQueuedPrompt(sessionId, clientId); - sq.cancelAutoResume?.(sessionId, ctx.runId); // 这两个 promise 分别覆盖 accept 前与 accept 后;重复 reject 安全。不能只等 // vendor terminal event:退避期没有活动 turn,Session.abort() 不会产生终态。 failDispatch(abortError); failAfterAccept(abortError); + // 先让 signal 的权威错误收口等待门,再同步撤项。remove 会触发带 reason 的 + // onDiscarded;它的重复 failDispatch 是 no-op,不能反过来抢先覆盖 abortError。 + sq.removeQueuedPrompt(sessionId, clientId, 'scheduler-abort'); + sq.cancelAutoResume?.(sessionId, ctx.runId); if (dispatched) { const live = this.deps.maker.getSession(sessionId); if (live) { @@ -2003,8 +2029,8 @@ export class MakerScheduleRunner implements ScheduleRunner { maxWaitMs: QUEUED_DISPATCH_MAX_WAIT_MS, }); // 顺序要紧:先置位超时错误,再撤项。coordinator 撤掉 **pending** 项会同步回调 - // onDiscarded → 那里也 failDispatch(一条含 "aborted" 的错误),先撤项就会让 - // dispatchGate 被它抢先 settle,本轮被记成"用户中断"而不是走顺延。 + // onDiscarded → 那里也 failDispatch,先撤项会让结构化 discard 抢先 settle, + // 本轮无法走 QueuedDispatchTimeoutError 的顺延分支。 // 反过来则安全:dispatchGate 已 settle,onDiscarded 的 failDispatch 是 no-op。 // 置位取消标志:撤项对已转 activeTurn 的项是 no-op,coordinator 仍可能在之后 // 调 onAccepted —— 那里读这个标志把迟到的 turn 杀掉。 @@ -2014,7 +2040,7 @@ export class MakerScheduleRunner implements ScheduleRunner { `queued heartbeat was not dispatched within ${Math.round(QUEUED_DISPATCH_MAX_WAIT_MS / 60_000)}min`, ), ); - sq.removeQueuedPrompt(sessionId, clientId); + sq.removeQueuedPrompt(sessionId, clientId, 'dispatch-timeout'); }, QUEUED_DISPATCH_TRACK_POLL_MS); trackPoll.unref?.(); diff --git a/packages/maker-scheduler/src/__tests__/scheduler.test.ts b/packages/maker-scheduler/src/__tests__/scheduler.test.ts index 116a0c2125a..c67b35d1185 100644 --- a/packages/maker-scheduler/src/__tests__/scheduler.test.ts +++ b/packages/maker-scheduler/src/__tests__/scheduler.test.ts @@ -13,7 +13,12 @@ import type { ListFilter, } from '../types.js'; import type { ScheduleStorage } from '../interfaces/schedule-storage.js'; -import type { FireContext, FireResult, ScheduleRunner } from '../interfaces/schedule-runner.js'; +import { + ScheduleRunCancellationError, + type FireContext, + type FireResult, + type ScheduleRunner, +} from '../interfaces/schedule-runner.js'; import type { Logger } from '../interfaces/logger.js'; class InMemoryStorage implements ScheduleStorage { @@ -540,19 +545,93 @@ describe('Scheduler', () => { expect(after?.nextFireAt).toBe(Date.UTC(2026, 0, 1, 0, 2, 0)); // 照常重排,不停摆 }); - it("agent 任务错误文本 /abort/i 兜底保持原语义(runner 没接 signal 的存量路径)", async () => { + it("agent 任务错误文本含 'abort' 时不误判成用户取消", async () => { const local = makeHarness({ runnerImpl: async () => { - throw new Error('session aborted'); + throw new Error('session aborted by remote server'); }, }); const sch = await local.scheduler.create({ ...baseInput }); local.clock.setTo(Date.UTC(2026, 0, 1, 0, 1, 5)); await local.scheduler.tick(); const runs = await local.scheduler.listRuns(sch.id); - expect(runs[0].status).toBe('aborted'); - // aborted 分支不重排(schedule 大概率已被 delete/pause;resume 会自己重算) + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe('failed'); + expect(runs[0].errorMsg).toBe('session aborted by remote server'); + const after = await local.storage.get(sch.id); + expect(after?.status).toBe('active'); + expect(after?.nextFireAt).toBe(Date.UTC(2026, 0, 1, 0, 2, 0)); + }); + + it('结构化 runner 取消保留独立文案并重排 active recurring schedule', async () => { + let fireCount = 0; + const local = makeHarness({ + runnerImpl: async () => { + fireCount += 1; + if (fireCount === 1) { + throw new ScheduleRunCancellationError( + 'cancelled by user (queued automation input removed)', + ); + } + return { sessionId: 'sess-next-cycle' }; + }, + }); + const sch = await local.scheduler.create({ ...baseInput }); + local.clock.setTo(Date.UTC(2026, 0, 1, 0, 1, 5)); + + await local.scheduler.tick(); + + const runs = await local.scheduler.listRuns(sch.id); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + status: 'aborted', + errorMsg: 'cancelled by user (queued automation input removed)', + }); + expect(runs[0].readAt).toBe(runs[0].finishedAt); const after = await local.storage.get(sch.id); + expect(after?.status).toBe('active'); + expect(after?.nextFireAt).toBe(Date.UTC(2026, 0, 1, 0, 2, 0)); + + // 不只锁字段:推进到下一周期,确认这条 active schedule 真的会再次运行。 + local.clock.setTo(Date.UTC(2026, 0, 1, 0, 2, 5)); + await local.scheduler.tick(); + expect(fireCount).toBe(2); + const afterNextCycle = await local.storage.get(sch.id); + expect(afterNextCycle?.nextFireAt).toBe(Date.UTC(2026, 0, 1, 0, 3, 0)); + }); + + it('pause signal 与结构化 runner 取消竞态时优先使用 pause/delete 文案且不重排', async () => { + const local = makeHarness({ + runnerImpl: (_schedule, ctx) => + new Promise<{ sessionId: string }>((_resolve, reject) => { + ctx.signal.addEventListener( + 'abort', + () => reject( + new ScheduleRunCancellationError( + 'cancelled by user (queued automation input removed)', + ), + ), + { once: true }, + ); + }), + }); + const sch = await local.scheduler.create({ ...baseInput }); + local.clock.setTo(Date.UTC(2026, 0, 1, 0, 1, 5)); + + const tickPromise = local.scheduler.tick(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await local.scheduler.pause(sch.id); + await tickPromise; + + const runs = await local.scheduler.listRuns(sch.id); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + status: 'aborted', + errorMsg: 'cancelled by user (schedule deleted or paused)', + }); + expect(runs[0].readAt).toBe(runs[0].finishedAt); + const after = await local.storage.get(sch.id); + expect(after?.status).toBe('paused'); expect(after?.nextFireAt).toBeUndefined(); }); diff --git a/packages/maker-scheduler/src/engine/scheduler.ts b/packages/maker-scheduler/src/engine/scheduler.ts index 98bebaaf68b..b99738aa1e0 100644 --- a/packages/maker-scheduler/src/engine/scheduler.ts +++ b/packages/maker-scheduler/src/engine/scheduler.ts @@ -19,7 +19,11 @@ import type { import { SCRIPT_CAPABILITIES } from '../types.js'; import { isLegalPhaseTransition } from './attemptLifecycle.js'; import type { ScheduleStorage } from '../interfaces/schedule-storage.js'; -import type { ChildRunInput, ScheduleRunner } from '../interfaces/schedule-runner.js'; +import { + ScheduleRunCancellationError, + type ChildRunInput, + type ScheduleRunner, +} from '../interfaces/schedule-runner.js'; import type { Clock } from '../interfaces/clock.js'; import type { Logger } from '../interfaces/logger.js'; import { nextCronOrMonthlyFire } from './monthlyClamp.js'; @@ -763,6 +767,7 @@ export class Scheduler extends EventEmitter { let sessionId: string | undefined; let resultText: string | undefined; let runError: string | undefined; + let runCancellationMessage: string | undefined; let deferred = false; let deferRetryMs: number | undefined; let skipped = false; @@ -793,6 +798,9 @@ export class Scheduler extends EventEmitter { skipped = result.skipped ?? false; } catch (err) { runError = err instanceof Error ? err.message : String(err); + if (err instanceof ScheduleRunCancellationError) { + runCancellationMessage = err.message; + } this.logger?.warn?.('schedule fire failed', { scheduleId: schedule.id, runId, error: runError }); } finally { knownSessionId = this.resolveTerminalSessionId(runId, sessionId, schedule.targetSessionId); @@ -808,9 +816,14 @@ export class Scheduler extends EventEmitter { return; } - // 如果是被 delete/pause 主动 abort 的,把 run 标 'aborted' 而非 'failed' —— 让 UI - // 用 RunHistoryCard 渲染时能区分"用户中断"和"agent 自爆"。判定见 wasRunAborted。 - const wasAborted = this.wasRunAborted(schedule, controller.signal, runError); + // schedule pause/delete 的 signal 和 runner 的结构化取消都会把 run 标为 + // aborted;但两者对 schedule 的收口语义不同,下方重排时必须分开。 + // 两者竞态时真实 signal 优先,不让先 settle 的队列删除文案覆盖 pause/delete。 + const scheduleAborted = controller.signal.aborted; + const wasAborted = this.wasRunAborted(controller.signal, runCancellationMessage !== undefined); + const cancellationMessage = scheduleAborted + ? 'cancelled by user (schedule deleted or paused)' + : (runCancellationMessage ?? 'cancelled by user (schedule deleted or paused)'); // 守卫 abort 与用户 abort 的收口语义相反(见 wasStallAborted):必须在写 run 行与 // 决定是否重排之前把两者分开。 const stallAborted = this.wasStallAborted(runId); @@ -878,7 +891,7 @@ export class Scheduler extends EventEmitter { await this.storage.updateRun(runId, { status: 'aborted', finishedAt, - errorMsg: 'cancelled by user (schedule deleted or paused)', + errorMsg: cancellationMessage, // 系统/用户主动收口,不是要处理的失败;生而已读,侧栏不涂红。 readAt: finishedAt, ...(knownSessionId ? { sessionId: knownSessionId } : {}), @@ -943,13 +956,15 @@ export class Scheduler extends EventEmitter { } this.silencedRuns.delete(runId); - // Aborted 路径不更新 schedule 行 —— schedule 大概率已被 delete(行已不存在,update - // 是 no-op)或 pause(status='paused',activeSchedules 已被摘出)。重排 nextFireAt - // 会引入幽灵下次触发,resume 时 resume() 自己会重算,这里跳过最干净。 + // 真实 schedule abort 路径不更新 schedule 行 —— schedule 大概率已被 + // delete(行已不存在,update 是 no-op)或 pause(status='paused',activeSchedules + // 已被摘出)。重排 nextFireAt 会引入幽灵下次触发,resume 时会自己重算。 + // runner 结构化取消不能走这个短路:active recurring schedule 的自动认领已经 + // 清空 nextFireAt,本轮虽记 aborted,仍必须按当前 schedule 重排下一周期。 // // **例外:守卫 abort**。此时 schedule 既没被删也没被停,只是这一轮卡死了; // claimDueFire 已清空 nextFireAt,必须往下走重排,否则任务永久停摆(review P1)。 - if (wasAborted && !stallAborted) { + if (scheduleAborted && !stallAborted) { return; } @@ -1068,6 +1083,7 @@ export class Scheduler extends EventEmitter { let finishedAt = firedAt; let runError: string | undefined; + let runCancellationMessage: string | undefined; let runSessionId: string | undefined; let runResultText: string | undefined; let deferred = false; @@ -1100,6 +1116,9 @@ export class Scheduler extends EventEmitter { skipped = result.skipped ?? false; } catch (err) { runError = err instanceof Error ? err.message : String(err); + if (err instanceof ScheduleRunCancellationError) { + runCancellationMessage = err.message; + } } finally { knownSessionId = this.resolveTerminalSessionId(runId, runSessionId, schedule.targetSessionId); this.unregisterInflight(schedule.id, runId); @@ -1113,7 +1132,11 @@ export class Scheduler extends EventEmitter { return { runId }; } - const wasAborted = this.wasRunAborted(schedule, controller.signal, runError); + const scheduleAborted = controller.signal.aborted; + const wasAborted = this.wasRunAborted(controller.signal, runCancellationMessage !== undefined); + const cancellationMessage = scheduleAborted + ? 'cancelled by user (schedule deleted or paused)' + : (runCancellationMessage ?? 'cancelled by user (schedule deleted or paused)'); const stallAborted = this.wasStallAborted(runId); // 顺延(手动触发撞忙也礼让,与 cron 路径一致):撤销预插的 running run、不通知。 @@ -1168,7 +1191,7 @@ export class Scheduler extends EventEmitter { await this.storage.updateRun(runId, { status: 'aborted', finishedAt, - errorMsg: 'cancelled by user (schedule deleted or paused)', + errorMsg: cancellationMessage, readAt: finishedAt, ...(knownSessionId ? { sessionId: knownSessionId } : {}), }); @@ -2084,19 +2107,13 @@ export class Scheduler extends EventEmitter { } /** - * 一次 fire 是否属于"被 delete/pause 主动中断"(fireOne/runNow 共用判定)。 - * controller.signal.aborted 是 source of truth;runError 文本里的 'abort' 只是 - * **agent 模式**的兜底(runner 没接 signal 但 Session.abort 已经触发了——agent - * 侧错误文本由我们自己产生,可控)。script 模式**绝不做文本匹配**:script - * runner 第一方接了 signal(真 abort 时 signal 必已置位),而它的失败消息携带 - * 脚本自己的 stderr,任意文本(如 "operation aborted by remote server")都可能 - * 撞上 /abort/i——误判成 aborted 会走"不重排 nextFireAt"的分支,而 claimDueFire - * 已把 nextFireAt 清空,recurring 任务就此静默停摆到重启(codex review #966)。 + * 一次 fire 是否属于用户主动取消(fireOne/runNow 共用判定)。 + * AbortSignal 是 schedule pause/delete 的 source of truth;signal 之外只接受 runner + * 抛出的 ScheduleRunCancellationError。普通错误文本不参与协议,避免把第三方错误、 + * 队列清理或内部竞争误报成用户暂停/删除。 */ - private wasRunAborted(schedule: Schedule, signal: AbortSignal, runError: string | undefined): boolean { - if (signal.aborted) return true; - if ((schedule.executionMode ?? 'agent') === 'script') return false; - return runError !== undefined && /abort/i.test(runError); + private wasRunAborted(signal: AbortSignal, explicitlyCancelled: boolean): boolean { + return signal.aborted || explicitlyCancelled; } /** 注册一次 fire 的 controller(fireOne/runNow 顶部调用)。两个 map 都要写。 */ diff --git a/packages/maker-scheduler/src/interfaces/schedule-runner.ts b/packages/maker-scheduler/src/interfaces/schedule-runner.ts index 7d65c1b7639..bcd18f6ac4e 100644 --- a/packages/maker-scheduler/src/interfaces/schedule-runner.ts +++ b/packages/maker-scheduler/src/interfaces/schedule-runner.ts @@ -1,5 +1,20 @@ import type { PreRunHookRunResult, RunStatus, Schedule } from '../types.js'; +/** + * Runner 在没有触发 schedule AbortSignal 的情况下确认了显式用户取消。 + * + * 普通 Error(即使 message 含 abort)始终属于失败;只有这个结构化类型或真实 + * AbortSignal 才能把 run 记为 aborted,避免依赖错误文案猜测取消来源。 + */ +export class ScheduleRunCancellationError extends Error { + readonly code = 'SCHEDULE_RUN_CANCELLED'; + + constructor(message: string) { + super(message); + this.name = 'ScheduleRunCancellationError'; + } +} + export interface ChildRunInput { sessionId?: string; status: RunStatus; @@ -17,8 +32,10 @@ export interface FireContext { * Runner 约定: * - 监听 signal.aborted / addEventListener('abort', ...),收到后立刻让底层 agent 停止 * (主 runner: Session.abort();多 session runner: 当前 issue 的 session.abort() + 跳过剩余)。 - * - 因 abort 而退出的 fire 应抛出 DOMException('aborted', 'AbortError') 或 message 含 - * "abort"/"aborted" 的 Error,Scheduler 据此把对应 run 行标 status='aborted'。 + * - 因 signal abort 而退出的 fire 可以抛任意 Error;Scheduler 以 signal 本身为 + * source of truth,把对应 run 行标 status='aborted'。 + * - signal 未触发但存在另一条明确的用户取消路径时,抛 + * ScheduleRunCancellationError;普通错误文本不得充当取消协议。 * - 即便不响应 abort,Scheduler 也只等 5s,过期后 schedule 还是会被 delete/pause —— 但 * in-flight session 会继续烧 token,所以 runner 必须接 abort。 */