diff --git a/packages/daemon/src/lib/rpc-handlers/space-task-handlers.ts b/packages/daemon/src/lib/rpc-handlers/space-task-handlers.ts index e62b6576a..459c34db7 100644 --- a/packages/daemon/src/lib/rpc-handlers/space-task-handlers.ts +++ b/packages/daemon/src/lib/rpc-handlers/space-task-handlers.ts @@ -9,6 +9,7 @@ */ import { + isRateOrUsageLimited, isWorkflowRecoveryTransition, resolveNodeAgents, type CreateSpaceTaskParams, @@ -469,12 +470,10 @@ export function setupSpaceTaskHandlers( const fromActivePaused = currentTask.status === 'in_progress' || currentTask.status === 'blocked' || - currentTask.status === 'rate_limited' || - currentTask.status === 'usage_limited'; + isRateOrUsageLimited(currentTask.status); const toStopped = updateParams.status === 'open' || updateParams.status === 'cancelled'; const toBlockedFromPaused = - updateParams.status === 'blocked' && - (currentTask.status === 'rate_limited' || currentTask.status === 'usage_limited'); + updateParams.status === 'blocked' && isRateOrUsageLimited(currentTask.status); const shouldStopWorkflowForStatus = !!currentTask.workflowRunId && fromActivePaused && (toStopped || toBlockedFromPaused); // Reject bare transitions into `review`. Every task that lands in diff --git a/packages/daemon/src/lib/space/goals/goal-service.ts b/packages/daemon/src/lib/space/goals/goal-service.ts index e22167b81..f4ef328a3 100644 --- a/packages/daemon/src/lib/space/goals/goal-service.ts +++ b/packages/daemon/src/lib/space/goals/goal-service.ts @@ -11,6 +11,7 @@ import type { SpaceTask, UpdateSpaceGoalParams, } from '@hyperneo/shared'; +import { isRateOrUsageLimited } from '@hyperneo/shared'; import type { Database as BunDatabase } from 'bun:sqlite'; import type { SpaceRepository } from '../../../storage/repositories/space-repository'; import type { SpaceTaskRepository } from '../../../storage/repositories/space-task-repository'; @@ -568,8 +569,7 @@ function isActiveTaskStatus(status: SpaceTask['status']): boolean { // A task paused on a rate/usage cap is still the goal's active run — it // auto-resumes when the cap lifts. Treating it as inactive would let the // goal clear activeTaskId and spawn/claim a second concurrent task. - status === 'rate_limited' || - status === 'usage_limited' + isRateOrUsageLimited(status) ); } diff --git a/packages/daemon/src/lib/space/managers/space-task-manager.ts b/packages/daemon/src/lib/space/managers/space-task-manager.ts index e29ebeeec..dd3309f92 100644 --- a/packages/daemon/src/lib/space/managers/space-task-manager.ts +++ b/packages/daemon/src/lib/space/managers/space-task-manager.ts @@ -16,6 +16,7 @@ import type { SpaceTaskStatus, UpdateSpaceTaskParams, } from '@hyperneo/shared'; +import { isRateOrUsageLimited } from '@hyperneo/shared'; import type { ReactiveDatabase } from '../../../storage/reactive-database'; import { SpaceTaskRepository } from '../../../storage/repositories/space-task-repository'; import { Logger } from '../../logger'; @@ -749,8 +750,7 @@ export class SpaceTaskManager { // A dependent paused on a rate/usage cap must be cancelled too — // otherwise recoverRateLimitedTasks would later restore it to // in_progress and resume work despite the cancelled prerequisite. - t.status === 'rate_limited' || - t.status === 'usage_limited' + isRateOrUsageLimited(t.status) ) { const cancelled = await this.setTaskStatus(t.id, 'cancelled', { result: `Dependency task ${taskId} was cancelled`, diff --git a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts index 131ce3d41..9041d14ab 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime-service.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime-service.ts @@ -21,6 +21,7 @@ import type { SpaceWorkflowRun, UpdateSpaceTaskParams, } from '@hyperneo/shared'; +import { isRateOrUsageLimited } from '@hyperneo/shared'; import type { MessageRecord, ActorRef } from '../../../../../messaging/src/types'; import { canonicalAgentHandle, SpaceActorRegistryAdapter } from '../actor-registry'; import type { ExternalEventPublishedPayload } from '../../external-events/external-event-service'; @@ -1072,11 +1073,7 @@ export class SpaceRuntimeService { const activeTasks = taskRepo .listBySpace(spaceId) .filter( - (t) => - t.status === 'in_progress' || - t.status === 'open' || - t.status === 'rate_limited' || - t.status === 'usage_limited' + (t) => t.status === 'in_progress' || t.status === 'open' || isRateOrUsageLimited(t.status) ); await Promise.allSettled( diff --git a/packages/daemon/src/lib/space/runtime/space-runtime.ts b/packages/daemon/src/lib/space/runtime/space-runtime.ts index 6e479ee59..c3a4b5f68 100644 --- a/packages/daemon/src/lib/space/runtime/space-runtime.ts +++ b/packages/daemon/src/lib/space/runtime/space-runtime.ts @@ -37,6 +37,7 @@ import type { SDKMessage } from '@hyperneo/shared/sdk'; import { computeGateDefaults, isChannelCyclic, + isRateOrUsageLimited, isWorkflowRunSucceeded, isWorkflowRunWaiting, MAX_SPACE_CONCURRENT_TASKS, @@ -7616,7 +7617,7 @@ export class SpaceRuntime { // `recoverRateLimitedTasks()` later restores the task to `in_progress` // (reset time passed), the next tick re-enters the normal path and the // execution is re-driven then. - if (canonicalTask.status === 'rate_limited' || canonicalTask.status === 'usage_limited') { + if (isRateOrUsageLimited(canonicalTask.status)) { return; } @@ -9986,8 +9987,7 @@ export class SpaceRuntime { (task) => task.status === 'in_progress' || task.status === 'approved' || - task.status === 'rate_limited' || - task.status === 'usage_limited' + isRateOrUsageLimited(task.status) ).length; } diff --git a/packages/daemon/src/lib/space/runtime/task-agent-manager.ts b/packages/daemon/src/lib/space/runtime/task-agent-manager.ts index 5373a1416..a2cb2887b 100644 --- a/packages/daemon/src/lib/space/runtime/task-agent-manager.ts +++ b/packages/daemon/src/lib/space/runtime/task-agent-manager.ts @@ -33,7 +33,7 @@ * registered `onComplete` callbacks are fired. */ -import { generateUUID, resolveNodeAgents } from '@hyperneo/shared'; +import { generateUUID, isRateOrUsageLimited, resolveNodeAgents } from '@hyperneo/shared'; import type { Space, SpaceTask, @@ -695,7 +695,7 @@ export class TaskAgentManager { // overwriting those would lose the approval/review lifecycle and the // post-approval route. The session-level cooldown still holds regardless — // this guard only protects the task row. - const isLimited = task.status === 'rate_limited' || task.status === 'usage_limited'; + const isLimited = isRateOrUsageLimited(task.status); if (task.status !== 'in_progress' && !isLimited) { return; } @@ -748,7 +748,7 @@ export class TaskAgentManager { private async restoreTaskFromRateLimit(taskId: string): Promise { const task = this.config.taskRepo.getTask(taskId); if (!task) return; - if (task.status !== 'rate_limited' && task.status !== 'usage_limited') return; + if (!isRateOrUsageLimited(task.status)) return; this.config.taskRepo.updateTask(taskId, { status: 'in_progress', restrictions: null }); this.emitTaskUpdatedEvent(taskId); } @@ -3641,8 +3641,7 @@ export class TaskAgentManager { // paused status until the cross-restart sweep restores it — gate on it too. const parentTaskId = this.findParentTaskIdForSubSession(sessionId); const parentTask = parentTaskId ? this.config.taskRepo.getTask(parentTaskId) : null; - const parentLimited = - parentTask?.status === 'rate_limited' || parentTask?.status === 'usage_limited'; + const parentLimited = parentTask ? isRateOrUsageLimited(parentTask.status) : false; if ((deliveryMode === 'defer' && isBusy) || inRateLimitCooldown || parentLimited) { const dbId = this.config.db.saveUserMessage(sessionId, sdkUserMessage, 'deferred', origin); return dbId; diff --git a/packages/daemon/src/lib/space/runtime/workflow-node-execution-validation.ts b/packages/daemon/src/lib/space/runtime/workflow-node-execution-validation.ts index ca77effdd..08ee39c95 100644 --- a/packages/daemon/src/lib/space/runtime/workflow-node-execution-validation.ts +++ b/packages/daemon/src/lib/space/runtime/workflow-node-execution-validation.ts @@ -1,5 +1,5 @@ import type { NodeExecution, SpaceTask, SpaceWorkflow } from '@hyperneo/shared'; -import { resolveNodeAgents } from '@hyperneo/shared'; +import { isRateOrUsageLimited, resolveNodeAgents } from '@hyperneo/shared'; export type ExecutionWorkflowValidationResult = | { valid: true } @@ -107,7 +107,7 @@ export function validateTaskAllowsSpawn(task: SpaceTask): void { // gates the out-of-band activation path (external-event / peer-handoff // spawns via activateTargetSessionsForMessage), which bypasses the tick loop's // paused-task guard in processRunTick. - if (task.status === 'rate_limited' || task.status === 'usage_limited') { + if (isRateOrUsageLimited(task.status)) { // Transient (NOT permanent): the runtime leaves the execution `pending` and // re-attempts on a later tick once recoverRateLimitedTasks restores the // task. A PermanentSpawnError here would cancel + unregister the execution, diff --git a/packages/daemon/src/storage/repositories/space-task-repository.ts b/packages/daemon/src/storage/repositories/space-task-repository.ts index 1ac4da124..e8afa4c6c 100644 --- a/packages/daemon/src/storage/repositories/space-task-repository.ts +++ b/packages/daemon/src/storage/repositories/space-task-repository.ts @@ -5,7 +5,7 @@ */ import type { Database as BunDatabase } from 'bun:sqlite'; -import { generateUUID } from '@hyperneo/shared'; +import { generateUUID, isRateOrUsageLimited } from '@hyperneo/shared'; import type { SpaceTask, SpaceBlockReason, @@ -524,8 +524,7 @@ export class SpaceTaskRepository { if ( params.restrictions === undefined && params.status !== undefined && - params.status !== 'rate_limited' && - params.status !== 'usage_limited' + !isRateOrUsageLimited(params.status) ) { fields.push('restrictions = ?'); values.push(null); diff --git a/packages/shared/src/types/space-utils.ts b/packages/shared/src/types/space-utils.ts index 89b944bb2..e8cfe9c7e 100644 --- a/packages/shared/src/types/space-utils.ts +++ b/packages/shared/src/types/space-utils.ts @@ -89,6 +89,29 @@ export function isWorkflowRunWaiting(status: WorkflowRunStatus | 'failed'): stat return status === 'blocked'; } +// ============================================================================ +// Space task rate/usage-limit paused-status predicate +// ============================================================================ + +/** + * The task statuses that pause a run on an API rate/usage cap. + * + * A task in one of these statuses still holds its concurrency slot, is not + * spawnable, counts as action-required, and is treated as active by the goal + * runtime — but it normally auto-resumes once the cap lifts. + * + * Single source of truth: every call site that asks "is this a paused-on-cap + * status?" routes through here, so the membership of the paused set is defined + * in one place. Adding or removing such a status is a one-line edit that every + * consumer picks up in lockstep — no duplicated `'rate_limited' || + * 'usage_limited'` literals to keep in sync across files. + */ +export function isRateOrUsageLimited( + status: SpaceTaskStatus +): status is 'rate_limited' | 'usage_limited' { + return status === 'rate_limited' || status === 'usage_limited'; +} + // ============================================================================ // Space task workflow recovery transitions // ============================================================================ @@ -109,7 +132,7 @@ export function isWorkflowRecoveryTransition( (from === 'done' && to === 'in_progress') || (from === 'blocked' && (to === 'open' || to === 'in_progress')) || (from === 'cancelled' && (to === 'open' || to === 'in_progress')) || - ((from === 'rate_limited' || from === 'usage_limited') && to === 'in_progress') + (isRateOrUsageLimited(from) && to === 'in_progress') ); } diff --git a/packages/shared/tests/space-utils.test.ts b/packages/shared/tests/space-utils.test.ts index b77dfbaf9..6a6059f5c 100644 --- a/packages/shared/tests/space-utils.test.ts +++ b/packages/shared/tests/space-utils.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'bun:test'; import type { + SpaceTaskStatus, SpaceWorkerAgent, SpaceWorkflow, WorkflowChannel, @@ -16,6 +17,7 @@ import { isWorkflowRunSucceeded, isWorkflowRunTerminal, isWorkflowRunWaiting, + isRateOrUsageLimited, isWorkflowRecoveryTransition, } from '../src/types/space-utils.ts'; @@ -380,6 +382,57 @@ describe('getChannelsToNode', () => { }); }); +// ============================================================================ +// isRateOrUsageLimited — single source of truth for paused-on-cap statuses +// ============================================================================ + +describe('isRateOrUsageLimited — single source of truth for paused-on-cap statuses', () => { + const ALL_SPACE_TASK_STATUSES = [ + 'draft', + 'open', + 'in_progress', + 'review', + 'approved', + 'done', + 'blocked', + 'cancelled', + 'archived', + 'rate_limited', + 'usage_limited', + ] as const satisfies readonly SpaceTaskStatus[]; + + // Compile-time exhaustiveness: typecheck error if any SpaceTaskStatus is + // absent from the array above. A newly-added status that isn't listed here + // makes `true` unassignable to `never`, turning a silent coverage hole into a + // build failure — which is the guarantee this test exists to provide. + type _ExhaustiveSpaceTaskStatuses = + SpaceTaskStatus extends (typeof ALL_SPACE_TASK_STATUSES)[number] ? true : never; + const _assertExhaustive: _ExhaustiveSpaceTaskStatuses = true; + + test('returns true exactly for rate_limited and usage_limited', () => { + const paused = ALL_SPACE_TASK_STATUSES.filter(isRateOrUsageLimited); + expect([...paused].sort()).toEqual(['rate_limited', 'usage_limited']); + }); + + test('isWorkflowRecoveryTransition derives its paused arm from the predicate', () => { + // Every consumer of the paused set routes through isRateOrUsageLimited, so + // the recovery transition's rate/usage arm must agree with the predicate + // across the full status set. If a status is added to the predicate, this + // consumer (and every other) picks it up automatically — no per-site + // literal to update. The web consumer (isActionRequired) is covered + // exhaustively in task-filters.test.ts; the daemon consumers by the + // rate-limit integration tests. + for (const status of ALL_SPACE_TASK_STATUSES) { + expect(isWorkflowRecoveryTransition(status, 'in_progress')).toBe( + isRateOrUsageLimited(status) || + status === 'done' || + status === 'blocked' || + status === 'cancelled' + ); + } + }); +}); + // ============================================================================ // isWorkflowRecoveryTransition — paused-task manual resume/cancel routing // ============================================================================ diff --git a/packages/web/src/lib/task-filters.ts b/packages/web/src/lib/task-filters.ts index 3923720e3..287585e24 100644 --- a/packages/web/src/lib/task-filters.ts +++ b/packages/web/src/lib/task-filters.ts @@ -10,7 +10,7 @@ * computed signals, and unit tests. */ -import type { SpaceTask } from '@hyperneo/shared'; +import { isRateOrUsageLimited, type SpaceTask } from '@hyperneo/shared'; /** * Minimum shape needed by `isActionRequired` — a structural subset of @@ -31,12 +31,7 @@ export type ActionRequiredTaskInput = Pick; * even though it normally auto-resumes when the cap lifts). */ export function isActionRequired(task: ActionRequiredTaskInput): boolean { - return ( - task.status === 'blocked' || - task.status === 'review' || - task.status === 'rate_limited' || - task.status === 'usage_limited' - ); + return task.status === 'blocked' || task.status === 'review' || isRateOrUsageLimited(task.status); } /**