/** * Task Pool Service — Unified task entry and queue management * * The Task Pool holds execution-ready WorkItems. Agents claim items * from the pool, and the Reconciler returns unclaimed or failed items. * * CEO Decision: Pool only holds execution-ready WorkItems, not all ProjectTasks. * * @module services/task-pool/task-pool.service */ import { PoolStorage } from './pool-storage.js'; import { ClaimService, type HeartbeatResult, type ExtendLeaseResult, type ExpiredClaimsSummary } from './claim.service.js'; import { type TeamBudgetGateService } from '../budget/team-budget-gate.service.js'; import type { WorkItem, WorkItemStatus, WorkItemType, WorkItemOwner } from '../../types/v2/work-item.types.js'; import type { WorkItemDisposition, WorkItemBlockSource, TransitionActorInput } from '../../types/v2/work-item.types.js'; import { type TaskClaim } from '../../types/v2/claim.types.js'; import type { EventBusService } from '../event-bus/event-bus.service.js'; import { type ClaimTicketLookup } from './ticket-claim-policy.js'; import { type TurnOrigin } from '../orc/orc-reply-route.service.js'; import { type WorkItemOrigin } from '../orc/work-item-destination.js'; import { type OwnerRequestContext } from '../orc/owner-request-context.js'; import { type LeadReport } from '../orc/team-lead-report.js'; /** * Narrow Request-link contract consumed by {@link TaskPoolService.addToPool}. * * Defined as a duck-typed interface (rather than importing the full * `RequestService` class) so tests can supply fakes without depending on * the RequestService transitive imports, and to keep the dependency * direction one-way: TaskPool → Request, never the reverse. * * P1 Bug B (Pool umbrella WI 72ca743a-3a66-4d0e-a6cf-1a861f849dbd): * the production `linkWorkItem` is idempotent — it short-circuits when * `workItemId` is already in `request.workItemIds[]` — so the * intrinsic-link path here is safe to coexist with downstream * subscriber-driven linking (request-sla.subscriber, V3DataService). */ export interface IRequestWorkItemLinker { /** * Idempotently link a WorkItem id to a Request's workItemIds[]. * * @param requestId - The parent Request id (already on the WI). * @param workItemId - The WorkItem id to link. * @returns The updated Request, or null if the Request was not found. * `addToPool` ignores the return value — link failure is best-effort. */ linkWorkItem(requestId: string, workItemId: string): Promise; } /** Result of {@link TaskPoolService.removeFromPool}. */ export interface RemoveFromPoolResult { /** `true` when the WorkItem existed and was removed. */ removed: boolean; /** The deleted WorkItem (only when removed === true). */ workItem?: WorkItem; /** `true` when an active claim was found at delete time. */ hadActiveClaim?: boolean; /** Cause when removed === false. */ reason?: 'not_found'; } /** * Structured error thrown by {@link TaskPoolService.removeFromPool} * when an active claim exists and `force: true` was not passed. */ export declare class WorkItemClaimedError extends Error { readonly workItemId: string; readonly claimId: string; readonly claimedBy: string; constructor(args: { workItemId: string; claimId: string; claimedBy: string; }); } /** * Filters for claiming work items from the pool. */ export interface PoolFilters { /** Only match items of these types */ types?: WorkItemType[]; /** Only match items with this owner role */ owner?: WorkItemOwner; /** Only match items targeting a specific agent/team */ target?: string; /** Only match items belonging to this mission */ missionId?: string; } /** * Statistics snapshot of the pool's current state. */ export interface PoolSnapshot { /** Total items in the pool (all statuses) */ total: number; /** Items available for claiming (queued, no active claim) */ available: number; /** Items currently claimed by an agent */ claimed: number; /** Average wait time in ms for items that have been claimed */ avgWaitTimeMs: number; /** Breakdown by WorkItem type */ byType: Record; /** Breakdown by status */ byStatus: Record; /** ISO8601 timestamp of this snapshot */ timestamp: string; } /** * Result returned when claiming a work item. * * `alreadyHeld` (Steve 2026-05-15, issue #513): true when the caller * already had an active claim — we return that claim + its WorkItem * instead of returning null. Callers (skill scripts, controller) can * distinguish "fresh claim" from "you already own this" and route * accordingly. Default `false` for new claims so existing callers * stay source-compat. */ export interface ClaimResult { /** The claimed work item */ workItem: WorkItem; /** The claim object tracking the lease */ claim: TaskClaim; /** Set when the caller already held an active claim before this call */ alreadyHeld?: boolean; } /** * TaskPoolService manages the lifecycle of execution-ready WorkItems. * * Core operations: * - {@link addToPool} — enqueue an execution-ready WorkItem * - {@link claimFromPool} — agent claims the next available item * - {@link releaseBack} — release a claimed item back to the pool * - {@link getPoolStatus} — snapshot of pool statistics * * @example * ```typescript * const pool = TaskPoolService.getInstance(); * pool.addToPool(workItem); * const result = pool.claimFromPool('agent-leo', { types: ['delegate'] }); * if (result) { * // agent works on result.workItem * // when done: * pool.completeItem(result.workItem.id); * } * ``` */ /** * Options for {@link TaskPoolService.addToPool}. */ export interface AddToPoolOptions { /** * The agent session creating the item (the caller's X-Agent-Session). Used * to link the item to the ticket of that agent's current turn when it has * no `requestId`. */ creatorSession?: string; /** * Inherit only an OWNER origin from the creator (never its current ticket / * trigger work's): a lead assigning a project ticket while it handles the * owner's request (the ticket's own link already says which ticket it is). */ ownerOriginOnly?: boolean; /** * Whether an item the creator assigns to ITSELF inherits the creator's * current owner turn, owner context or current work's origin (default * true for harness-made items). An agent's own `create-task` passes false: * its item gets an owner place only when it names one * (`--thread` / `--conversation`), never the creator's last owner thread * (message-routing audit 2026-10-18). */ inheritCreatorTurn?: boolean; } /** * Options for {@link TaskPoolService.releaseBack}. */ /** Note put on an unassigned item when it is routed to a decider. */ export declare const UNASSIGNED_ROUTE_NOTE: string; /** Who decides unassigned work (owner, 2026-09-24). */ export interface UntargetedRouterDeps { /** First decider for an unassigned item */ decide(workItem: WorkItem, creatorSession?: string): Promise; /** One level up from the current decider; null at the top */ next(current: string): Promise; } /** Ticket claim policy wiring (ticket loop Phase 3). */ export interface TicketClaimPolicyDeps { /** Current tickets by Request id */ snapshot(): Promise; /** An agent self-claimed an untargeted item of this ticket */ onSelfClaimed?(requestId: string, agentId: string): Promise; } export interface ReleaseBackOptions { /** * When true, clear the item's `target` as well, returning it to the * unassigned pool. * * Defaults to false: a release ends an attempt, it does not revoke an * assignment. This must be requested BY NAME so that un-assignment is * always a deliberate act by a caller who means it, never a silent * side effect of a lease expiring. */ unassign?: boolean; } /** * Who a session belongs to, for task events that a team lead subscribes to by * team. Returns null for a session no member is bound to. */ export type SessionTeamResolver = (sessionName: string) => Promise<{ teamId: string; teamName: string; memberId: string; memberName: string; } | null>; export declare class TaskPoolService { private static instance; private readonly storage; private readonly claimService; private readonly logger; /** * Optional EventBus reference — wired via {@link setEventBusService} from * the boot path. When set, {@link addToPool} publishes a `workitem:queued` * event so subscribers (notably {@link RequestSlaSubscriber}) can react to * queue mutations. Optional because singleton callers (tests, CLI) bring * up the pool before the bus exists; missing bus is treated as a no-op * publish at warn level. */ private eventBus; /** * Optional Request-linker reference — wired via {@link setRequestService} * from the boot path. When set, {@link addToPool} pushes the new WI's id * into the parent `Request.workItemIds[]` immediately after the storage * flush, independent of any downstream subscriber. * * P1 Bug B (Pool umbrella WI 72ca743a) intrinsic-link contract: * - The pre-fix path relied on `workitem:queued` / `task:delegated` * subscribers (request-sla.subscriber, V3DataService) to backfill * `Request.workItemIds[]`. Manual / programmatic / cron callers that * bypass the standard event chain left Requests with * `workItemIds=[]` even though their WIs were in the pool. * - The intrinsic path here is the source-of-truth link and runs on * every `addToPool`. Subscriber-driven linking remains as * belt-and-suspenders (idempotent — see request.service.ts:328 * short-circuit on duplicate id). * - Optional because singleton callers (tests, CLI) bring up the pool * before RequestService exists; missing linker is a no-op debug log. */ private requestService; /** * Optional agent-liveness probe — wired via {@link setIsAgentActive} from * the boot path. When set, {@link claimFromPool} and {@link claimSpecificItem} * refuse to issue a claim if the requesting agent's PTY session is not alive. * * Why: the invariant is that a WI in `running` status is being executed by * a live agent. The orchestrator's `delegate-task` skill pre-claims a fresh * WI for its target before the target session is actually spawned (see * `config/skills/orchestrator/delegate-task/execute.sh` "self-heal fix #1" * 2026-04). That pushes the WI from `queued` to `running` while the target * is still inactive. 5 s later the reconciler's `detectStuckWorkItems` * rule sees `running` + dead agent and marks the WI `blocked` — and the * reconciler's `detectUnclaimedTasks` wake-rule only handles `queued` * items, so the agent never gets spawned and the WI is stuck forever. * * Rejecting the claim at the source keeps the WI in `queued`, letting the * wake-rule do its job (start the agent → agent auto-claims when it boots). * delegate-task's existing fallback path (line 277 of execute.sh — "if * claim fails (race, target locked, etc.) the work proceeds via the * async path") already handles a rejected pre-claim gracefully. * * Optional because singleton callers (tests, CLI) bring up the pool * before any agent-registration plumbing exists; missing probe = "trust * the caller" (current behavior preserved for those code paths). */ private isAgentActive; /** * Team budget gate consulted by {@link claimFromPool}. Wired from the * backend boot path via {@link setTeamBudgetGate} (mirrors * {@link setIsAgentActive}); `null` — the default — disables the gate so * tests and single-process CLI paths never touch the team store. */ private teamBudgetGate; /** Session → team lookup for task:blocked / task:failed (see {@link setSessionTeamResolver}). */ private sessionTeamResolver; /** * Ticket loop (specs/ticket-loop.md §3): resolves the ticket an agent's * current turn is about, from the `[TICKET:…]` markers in the messages * delivered into it (safe-restart InFlightTurnTracker). Wired from boot; * `null` disables the fallback. */ private ticketResolver; /** Ticket claim order + lock (ticket loop Phase 3); null = plain FIFO */ private ticketClaimPolicy; /** Routes unassigned work to a decider; null = legacy broadcast pool */ private untargetedRouter; /** * The creating agent's last owner turn (origin chain: a delegate made while * answering the owner answers in the owner's thread). null disables it. */ private turnOriginLookup; /** * The conversation a Slack thread belongs to, for an explicit owner thread * on a delegation (`delegate-task --thread`). null = only the creator's own * turn origin can name it. Default: the Slack team-channel / DM mappings. */ private slackThreadConversationLookup; /** * The Slack thread of a chat message, for a delegation that names the * owner's message by its chat id (`--thread `) instead of a * Slack thread key. CE-199 (2026-10-07): Owen passed the owner's message id, * it was ignored, and Vera's answer opened a new ticket thread instead of * landing in the owner's thread. null disables it. */ private chatMessageThreadLookup; /** * The owner request the creating agent handles that is not a chat turn (an * app comment, a colleague's hand-over). null disables it. */ private ownerContextLookup; /** * When the owner last wrote in a place a delegation names explicitly * (crewly#1357: an explicit owner origin older than * `ORIGIN_INHERIT_MAX_AGE_MS` is not taken). null = unknown (taken as given). * Default: the chat-v2 store. */ private ownerAskedAtLookup; /** * Who reports a new owner-origin delegation to the owner, and where * (crewly#1371: the team lead, in the team channel). null disables the * stamp. Default: the real teams and Slack team channels. */ private leadReportResolver; /** * Serializes claim operations to prevent the race where two concurrent * claimFromPool / claimSpecificItem calls both select the same queued * WorkItem between their read and write phases. In-process only — does * not protect against cross-process races, but the backend runs as a * single process today. */ private claimMutex; constructor(storage?: PoolStorage); /** * Wire the EventBus reference used by {@link addToPool} to publish * `workitem:queued` events (INBOUND-1.f1). Called from the backend boot * path after both services have been constructed. Idempotent and may be * called with `null` to disable publishing (testing). * * @param bus - The EventBus instance, or null to clear */ setEventBusService(bus: EventBusService | null): void; /** * Listeners told after a WorkItem is newly claimed (not for an * already-held claim). There is no claim event on the bus; the claim sites * are the HTTP controller, AgentAutoClaim and the boot path, so the hook * lives here. Listeners run fire-and-forget: a slow or failing listener * never delays or fails the claim. Used by the per-WorkItem worktree * service (#814). */ private claimListeners; /** * Register a claim listener (see {@link claimListeners}). * * @param listener - Called with the claimed WorkItem and the claiming agent * @returns Unsubscribe function */ onClaimed(listener: (workItem: WorkItem, agentId: string) => void | Promise): () => void; /** Fire claim listeners without awaiting them; errors are logged, never thrown. */ private notifyClaimed; /** * Wire the Request-linker reference used by {@link addToPool} to * intrinsically link the new WI into its parent `Request.workItemIds[]` * (P1 Bug B — Pool umbrella WI 72ca743a). * * Called from the backend boot path after both services exist. * Idempotent and may be called with `null` to disable intrinsic * linking (testing). Mirrors the {@link setEventBusService} setter * pattern so the dependency wiring stays uniform and grep-able. * * @param svc - The RequestService instance (or any object satisfying * {@link IRequestWorkItemLinker}), or null to clear. */ setRequestService(svc: IRequestWorkItemLinker | null): void; /** * Wire the agent-liveness probe used by {@link claimFromPool} and * {@link claimSpecificItem} to refuse claims for dead/missing target * sessions. Called from the backend boot path after the agent runtime * exists. Idempotent and may be called with `null` to disable the gate * (testing or single-process CLI). * * @param probe - Async function returning whether the named session is * currently alive (PTY exists + child runtime running), or null to clear */ setIsAgentActive(probe: ((agentId: string) => Promise) | null): void; /** * Wire (or disable with `null`) the team budget gate consulted by * {@link claimFromPool}. Called from the backend boot path with the * {@link TeamBudgetGateService} singleton. * * @param gate - Gate implementation, or null to bypass budget checks */ setTeamBudgetGate(gate: Pick | null): void; /** * Wire (or disable with `null`) the session → team lookup used to put a * real `teamId` on `task:blocked` / `task:failed`, so a team lead's * standing subscription (filtered by teamId) receives them (#842). * * @param resolver - Session name → the member's team and identity, or null */ setSessionTeamResolver(resolver: SessionTeamResolver | null): void; /** * Wire (or disable with `null`) the in-flight-turn ticket resolver used by * {@link addToPool} when a WorkItem arrives without a `requestId`. * * @param resolver - Session name → ticket id (or null) */ setTicketResolver(resolver: ((sessionName: string) => string | null) | null): void; /** * Wire the ticket claim policy (specs/ticket-loop.md Phase 3): claim order * (own rejected → own unblocked → queue rejected → P0..P3) and the * one-ticket-per-agent lock, for every claim path. * * @param policy - Ticket snapshot + self-claim hook, or null for FIFO */ setTicketClaimPolicy(policy: TicketClaimPolicyDeps | null): void; /** * Replace (or disable with `null`) the owner-turn lookup used to give a * new item its creator's origin. * * @param lookup - Session → its last owner turn origin */ setTurnOriginLookup(lookup: ((sessionName: string) => TurnOrigin | undefined) | null): void; /** * Replace (or disable with `null`) the Slack-thread → conversation lookup * used for an explicit owner thread on a delegation. * * @param lookup - (Slack channel, thread ts) → chat conversation id */ setSlackThreadConversationLookup(lookup: ((slackChannelId: string, threadTs: string) => Promise) | null): void; /** * Replace (or disable with `null`) the owner request context lookup. * * @param lookup - Session → the owner request it handles */ setOwnerContextLookup(lookup: ((sessionName: string) => Promise) | null): void; /** * Replace (or disable with `null`) the lead-report resolver (crewly#1371). * * @param resolver - New item + creator + pool → its lead report, or null */ setLeadReportResolver(resolver: ((input: { workItem: WorkItem; creator?: string; items: readonly WorkItem[]; }) => Promise) | null): void; /** * Override how a chat message id resolves to its Slack thread (tests). * * @param lookup - Lookup, or null to disable */ setChatMessageThreadLookup(lookup: ((messageId: string) => Promise<{ slackChannelId: string; threadTs: string; conversationId: string; } | null>) | null): void; /** * Wire (or disable) the lookup of when the owner last wrote in an * explicitly named place (crewly#1357). * * @param lookup - Lookup, or null (ages unknown: explicit origins are taken as given) */ setOwnerAskedAtLookup(lookup: ((origin: Extract) => Promise) | null): void; /** * Wire the unassigned-work router: an item added with no target goes to * someone who decides (ticket owner → team lead → creator's lead → * orchestrator) instead of whoever is idle. * * @param router - The router, or null for the old broadcast pool */ setUntargetedRouter(router: UntargetedRouterDeps | null): void; /** * Give an unassigned item its decider (in place, before it is stored). * * @param wi - The item * @param creatorSession - Who created it */ private routeUntargeted; /** * Move routed items nobody took one level up (member → lead → * orchestrator), and route any unassigned queued item left from before. * * @param maxAgeMs - How long a decider has before it moves up * @returns Items that moved, with their new target */ escalateUnassigned(maxAgeMs: number): Promise>; /** * Order claim candidates for an agent under the ticket policy (FIFO * without one). Used by claimFromPool and by AutoClaim. * * @param agentId - The claiming agent * @param candidates - Queued, unclaimed, target-respecting items * @returns Candidates in claim order, lock applied */ orderClaimCandidates(agentId: string, candidates: WorkItem[]): Promise; /** * After a broadcast claim: an untargeted ticket item makes its claimer the * ticket's assignee when it has none (the lock). Best-effort. * * @param item - The claimed item * @param agentId - The claimer */ private noteTicketSelfClaim; /** * Chains the given critical section after any in-flight claim operation. * Guarantees FIFO ordering even under concurrent invocation. */ private withClaimLock; /** * Get singleton instance of TaskPoolService. * * @returns The singleton TaskPoolService */ static getInstance(): TaskPoolService; /** * Reset singleton (for testing). */ static resetInstance(): void; /** * Add work items that came with a team from another machine (team move, * crewly#1189), exactly as they were: ids, status and links are kept, no * routing or request inference is applied. Items already in the pool are * skipped, so a resumed import adds nothing twice. * * @param items - Work items from the package * @returns How many were added */ importWorkItems(items: readonly WorkItem[]): Promise; /** * Remove the work items of a team that moved to another machine (team move, * crewly#1189) together with their claims, so nothing here tries to wake * agents that live elsewhere now. The mover keeps copies for rollback. * * @param ids - Work item ids * @returns How many items were removed */ removeWorkItemsForMove(ids: readonly string[]): Promise; /** * Adds an execution-ready WorkItem to the pool. * * Accepted statuses: * - `queued` — ready to run * - `blocked` — waiting on unresolved dependsOn; the resolver will promote * it to `queued` when every upstream dep reaches terminal success. * * Ticket loop: when the item has no `requestId` and `options.creatorSession` * names the agent creating it, the ticket of that agent's current turn (if * exactly one) is filled in, and `metadata.requestIdSource` records that it * was inferred. * * @param workItem - The work item to add * @param options - `creatorSession`: the agent creating it (X-Agent-Session) * @throws Error if workItem is invalid or in an ineligible status */ addToPool(workItem: WorkItem, options?: AddToPoolOptions): Promise; /** * Stamp `metadata.origin` from the item this one continues (verify / retry * / subtask parent) or from the creating agent's current work, so the * answer goes where the request came from (specs/2026-10-01-orc-status-wakes.md §2). * Mutates the item before it is stored; failures are logged and ignored. * * A DELEGATION (an agent handing the item to someone else) takes an owner * origin only when the delegator named the owner conversation (`--thread`, * `--conversation`) and the owner asked there within * `ORIGIN_INHERIT_MAX_AGE_MS`; otherwise its origin is the delegator itself * (specs/2026-10-04-tl-delegation.md §1a, crewly#1357). A verbatim * `metadata.origin` in the body is kept as given. * * @param workItem - The item about to be stored * @param creatorSession - The agent creating it, when known * @param ownerOnly - Stamp only an owner origin */ private inheritOrigin; /** * Stamp `metadata.leadReport` (crewly#1371): team work the orchestrator * delegated on the owner's behalf is reported to the owner by the team's * lead, in the team channel. A verify / retry / subtask item takes its * parent's stamp; a new owner-origin delegation asks the resolver. Mutates * the item before it is stored; failures are logged and ignored. * * @param workItem - The item about to be stored (origin already stamped) * @param creatorSession - The agent creating it, when known */ private stampLeadReport; /** * The origin half of {@link inheritOrigin}. * * @param workItem - The item about to be stored * @param creatorSession - The agent creating it, when known * @param ownerOnly - Stamp only an owner origin * @param inheritCreatorTurn - A self-assigned item may take the creator's turn / context / current work origin */ private inheritOriginOnly; /** * Whether a delegation names an owner thread (`delegate-task --thread`) * Crewly cannot read. Such a delegation is refused rather than created * without the owner's place: Atlas's `:` was dropped * silently, and Kai's PDF went to the channel top level (2026-10-18). * * @param rawKey - `metadata.ownerThread` * @param creator - The delegating agent, when known * @returns True when a thread is named and unreadable */ isOwnerThreadUnreadable(rawKey: unknown, creator?: string): Promise; /** * The owner origin of an explicitly named Slack thread: the creator's own * turn origin when it is that thread (it knows the conversation), else the * conversation the Slack channel maps to. * * @param rawKey - `metadata.ownerThread` (thread key or tag) * @param creator - The delegating agent, when known * @returns The origin, or null when the key is unreadable */ private explicitOwnerOrigin; /** * The owner origin of a chat conversation + thread pair. The thread's Slack * place comes along when the chat message has one; a thread of another * conversation contradicts the pair and gives nothing. * * @param conversationId - chat-v2 conversation id * @param threadId - chat-v2 thread root message id in it * @returns The origin, or null when the pair contradicts itself */ private chatThreadOwnerOrigin; /** * The owner origin of an explicitly named chat conversation * (`delegate-task --conversation `, crewly#1357). When it is the * creator's own turn, the turn's thread and Slack place come along. * * @param rawId - `metadata.ownerConversation` * @param creator - The delegating agent, when known * @returns The origin, or null when the id is unusable */ private explicitOwnerConversation; /** * When the owner last asked in an explicitly named place: the creator's own * turn when it is that place, else the store. null when nothing says. * * @param origin - The explicit owner origin * @param creator - The delegating agent, when known * @param now - Clock (epoch ms) * @returns Epoch ms, or null */ private ownerAskedAt; /** * Fill in `requestId` from the creating agent's in-flight turn (ticket * loop §3). Mutates the item before it is stored; a resolver failure is * logged and ignored. * * @param workItem - The item about to be stored * @param creatorSession - The agent creating it, when known */ private inferRequestIdFromTurn; /** * P1 Bug B helper: intrinsically link a freshly-added WI into its * parent `Request.workItemIds[]`. * * Stays a separate method (vs inlining) so: * 1. The dependency on RequestService stays explicit and grep-able, * mirroring {@link publishWorkItemQueued}. * 2. Future enqueue paths (e.g. a batch addAll) can route through * the same linker for consistent behaviour. * 3. Error handling stays in one place — a thrown linker must NOT * back out the pool mutation (the storage write already committed). * * @param workItem - The WI just persisted into the pool. */ private linkWorkItemToRequest; /** * INBOUND-1.f1 helper: publish a `workitem:queued` event with correlation * ids the SLA subscriber needs (`requestId`, `missionId`, plus the new * `workItemId`). Called by {@link addToPool} after the storage flush. * * Stays a separate method (vs inlining) so: * 1. The dependency on EventBus stays explicit and grep-able. * 2. A future caller adding an alternate enqueue path (e.g. a batch * addAll) can route through the same publisher for consistent * observability. * 3. Error handling stays in one place — a thrown publisher must NOT * back out the pool mutation (the storage write already committed). */ private publishWorkItemQueued; /** * F1-BRIDGE-1 helper: publish `task:done_by_worker` after a successful * `submitForVerification` transition. The {@link EventToWorkItemBridge} * subscribes to this event and creates a verification WorkItem for the * resolved team-lead session. * * Stays a separate method (mirrors {@link publishWorkItemQueued}) so: * 1. The dependency on EventBus stays explicit and grep-able — Arch's * grep guard checks both the type declaration AND a publish call * site for `task:done_by_worker`. * 2. Error handling is uniform with the other publishers — a thrown * bus must NOT back out the verified state transition; the storage * flush has already committed. * 3. A future caller adding an alternate verification-submission path * (e.g. an admin endpoint or a CLI) routes through the same publisher. * * The deterministic event id (`task:done_by_worker:${workItemId}`) keys * dedup so a redelivered submitForVerification (theoretical) collapses * through the bus without firing the bridge handler twice. */ private publishTaskDoneByWorker; /** * F1-BRIDGE-1 helper: publish `task:rejected` after a successful * `verifyItem` transition with verdict='rejected'. The * {@link EventToWorkItemBridge} subscribes and either creates a retry * WI (`retryCount < maxRetries`) or escalates to a TL review WI with * `reviewReason='max_retries_exceeded'` at the cap. * * Mirrors {@link publishTaskDoneByWorker} for symmetry. Only the * verdict='rejected' branch reaches this publisher — `verdict='verified'` * does NOT publish (terminal-success path goes through * {@link resolveBlockedDependents}, which is not a bridge trigger). */ private publishTaskRejected; /** * Publish a terminal-success lifecycle event (`task:done` from * {@link completeSimpleItem}, `task:verified` from {@link verifyItem}). * * **Why this exists.** Both event types were declared in `EVENT_TYPES`, * listed as CRITICAL, and consumed by five subscribers (KR auto-measure, * request cascade, auto-learning, milestone notification, the orc's and * TL's standing subscriptions) — but nothing ever published them. The * pool only emitted `task:done_by_worker` / `task:rejected` / * `task:cancelled`, so `measurementSource: 'task_completion'` KRs never * moved and `team:all_tasks_done` could never be derived. * * Mirrors {@link publishTaskDoneByWorker}: empty `sessionName` (system * event, bypasses the per-session debounce), deterministic id keyed on * the WI so a replay collapses, and a swallowed publisher error so the * committed transition is never rolled back. * * @param type - `'task:done'` or `'task:verified'` * @param workItem - The WI snapshot AFTER the transition committed * @param previousStatus - The status it transitioned from */ private publishTaskTerminalSuccess; /** * Publish `task:blocked` / `task:failed` when a running WorkItem is blocked * or failed by {@link blockItem} / {@link failItem} (#842). * * Both events were declared, and subscribed to by every team lead (filtered * by its teamId), the orchestrator, and the EventToWorkItemBridge's blocked * handler, but nothing published them: a worker's block or failure reached * nobody in a structured way. The event carries the target's real team so * the team lead's subscription matches; `sessionName` stays empty like the * other pool events (system event, no per-session debounce). Never throws: * the transition has already committed. * * @param type - `'task:blocked'` or `'task:failed'` * @param workItem - The WorkItem after the transition * @param previousStatus - The status it left * @param reason - Why (block reason / failure text) */ private publishTaskStopped; /** * Publish `task:cancelled` whenever a WorkItem transitions to the * cancelled status via {@link updateItemStatus} or {@link transitionStatus}. * * **Why this exists.** 2026-05-09 dogfood: 4 child WIs of a Slack- * originated Request all landed in `cancelled` (orc-not-in-health-map * loop, fixed in #531) but the Request stayed in `ready` for hours. * `cascadeRequestStatus` only fires from V3DataService's task * handlers, which are wired to the retired `v3:task_*` events * (`mission-executor.service.ts:152` v1-cleanup comment) — so out-of- * band cancellations never reached cascade. The heartbeat sweep * eventually caught it (#533), but with up-to-30-min latency that * showed up as a misleading "0/4 in every bucket" ping in the user's * Slack thread. * * Publishing `task:cancelled` lets `RequestCascadeSubscriber` close * the Request within seconds of the last child cancelling, instead * of waiting for the heartbeat catch. * * Mirrors {@link publishTaskDoneByWorker} / {@link publishTaskRejected} * so the EventBus surface stays uniform. * * @param workItem - The WI snapshot AFTER the cancelled transition committed * @param previousStatus - The status it transitioned from (for the event payload) */ private publishTaskCancelled; /** * Claims the next available WorkItem from the pool for an agent. * * Selection strategy: FIFO among matching unclaimed 'queued' items. * If a target filter is provided, only items targeting that agent are considered. * * @param agentId - Agent session name claiming the item * @param filters - Optional filters to narrow which items to consider * @returns The claimed work item and claim object, or null if nothing available */ claimFromPool(agentId: string, filters?: PoolFilters): Promise; /** * Claims a specific WorkItem by ID for an agent. * Used by AgentAutoClaimService for score-based selection where the caller * already identified the best item (not FIFO). * * @param agentId - Agent session name claiming the item * @param workItemId - The specific WorkItem to claim * @returns The claimed work item and claim, or null if unavailable */ claimSpecificItem(agentId: string, workItemId: string): Promise; /** * Releases a claimed WorkItem back to the pool. * * The item's status reverts to 'queued' and the claim is marked 'released'. * * A release is an ADMINISTRATIVE event, not a failure. Two invariants * follow, and both were violated before 2026-08-21: * * 1. **`target` is preserved.** A release says "this attempt ended", not * "this work no longer belongs to that agent". Clearing it silently * destroyed a deliberate assignment — and because the wake gate admits * a worker only when a queued/blocked WI carries `target === their * session`, it also made the assigned worker unwakeable for their own * work (`wake_gate_no_pool_work`). The sibling failure path, * {@link requeueAfterFailure}, already keeps `target` on the far * stronger grounds of an actual failure; there is no coherent reading * in which a lease lapse un-assigns work that a failure would not. * Genuine un-assignment is still possible, but it must be asked for by * name via `options.unassign` — never inferred from a lease expiring. * * One carve-out, and it is not an exception to the rule so much as a * sharpening of it: `claimFromPool` stamps `target` on a *broadcast* * item to record who picked it up. That stamp is not an assignment, * and it is dropped on release (`targetSource === 'claim'`) so the * item returns to the broadcast pool. Preserving it would let one * claim permanently bind unassigned work to a single agent — and if * that agent died, only a dead session could ever re-claim it. * * 2. **`retryCount` is untouched.** That counter means failures only (see * {@link WorkItem.retryCount}); consumers branch on * `retryCount < maxRetries` to decide retry-in-place vs terminal-and- * escalate. Charging a retry for lease churn drove live work to 3/3 * with zero failures. Release churn is counted separately in * {@link WorkItem.releaseCount}, which nothing branches on. * * @param workItemId - ID of the work item to release * @param reason - Why the item is being released * @param options - Release options * @param options.unassign - When true, ALSO clear `target`, returning the * item to the unassigned pool. Opt-in only, for the case where a caller * genuinely means "someone else should take this". Defaults to false. * @throws Error if work item not found, or its status is not * 'running' or 'blocked' * * @example * ```typescript * // Lease lapsed / claim revoked — the owner keeps the work. * await pool.releaseBack(id, 'claim revoked: grace exceeded'); * * // Deliberate hand-back — anyone may pick this up. * await pool.releaseBack(id, 'agent declined', { unassign: true }); * ``` */ releaseBack(workItemId: string, reason: string, options?: ReleaseBackOptions): Promise; /** * Decide whether a WorkItem requires TL verification before reaching a * terminal-success status. * * Default policy (VERIF-1): * - `delegate` items default to *requires verification* — a worker * marking their own delegated work as "done" should produce * `done_by_worker` and wake the TL for sign-off. * - All other types (`cron_run`, `notify`, `reconcile`, `check`, * `confirm`, `review`, ...) default to simple completion (`done`). * - Bridge-auto maintenance items (`metadata.autoCreated`) and * trigger-fired check-ins (`triggerId`) complete as `done` too — * nothing reviews them (#813). * * The default is overridable via `wi.metadata.requiresVerification`: * - `true` — force verification path even for non-delegate types * - `false` — skip verification even for delegate types (this is the * F-H affordance REVIEW-1 needs: review WIs are themselves the * verification step, so they must NOT loop back to TL self-verify) * * @param wi - The WorkItem under inspection * @returns true when the verification path should fire */ private requiresVerification; /** * Explicitly block a running WorkItem (the agent's `/block`, e.g. "waiting * on the owner"). * * The item becomes terminal-until-unblocked: * - status `running → blocked`, with `blockedReason` and * `blockSource: 'explicit'` written atomically with the flip; * - the active claim is released (`blocked: `), which frees the * agent's claim slot and leaves no lease for the reconciler to expire, * revoke and redeliver; * - the reconciler never re-queues an explicitly blocked item (see * `detectRecoverableWorkItems` / `detectDependencyResolvedWorkItems`). * * Unblocking is explicit: {@link releaseBack} (POST * `/task-pool/release/:id`, the release-claim skill) or any * `blocked → queued` transition puts it back to `queued` and clears the * marker. `target` is kept, so it returns to the same agent. * * Before this (2026-09-25, WI 92327d6d) `/block` kept the claim, and the * reconciler flipped the item `blocked → queued` (`reconciler_requeue`) * as soon as it saw the agent active, so it was re-claimed and * re-dispatched every few minutes. * * @param workItemId - The running WorkItem to block * @param options.agentId - Who blocked it (recorded in metadata) * @param options.reason - Why (persisted on `blockedReason`) * @throws Error if not found, or the item is not `running` (state machine) */ blockItem(workItemId: string, options: { agentId: string; reason?: string; }): Promise; /** * Release the active claim on a WorkItem, if it has one. * * Shared by `completeSimpleItem` and `submitForVerification` because both * represent the worker handing the item back to the system. * * Also public for callers that must drive a terminal transition * themselves rather than through {@link failItem}. The SLA orphan-close * path is the live example: it targets `cancelled`, `failed` OR * `rejected` depending on the origin status, so `failItem` — which always * transitions to `failed` — cannot serve it. Such callers MUST call this, * otherwise the claim stays `active` against a terminal WorkItem and * quietly wedges the claiming agent. The mechanism is not an error, which * is what makes it hard to spot: {@link claimFromPool} checks * `findActiveClaimByAgent` BEFORE it scans the pool, and short-circuits by * returning the held claim with `alreadyHeld: true` (issue #513). So the * agent is handed the same terminal item on every poll and never sees the * queued work waiting for them, until the lease/grace ladder revokes it. * * Idempotent: a no-op when no active claim exists. * * @param workItemId - WorkItem whose claim should be released * @param endReason - Reason recorded on the claim (`completed`, * `submitted_for_verification`, `sla escalation timeout ...`) for * auditability */ releaseClaim(workItemId: string, endReason: string): Promise; /** * Worker reports a delegated WorkItem as done and submits it for TL * verification. * * Transitions `running → done_by_worker` via {@link transitionStatus}, * which enforces the V3 actor-role gate (`'agent'` is allowed; any * other role throws). The active claim is released as * `submitted_for_verification` so the TL queue is the only thing * blocking forward progress. * * Used by the `completeItem` facade for `delegate`-type items and any * item whose `metadata.requiresVerification === true`. * * @param workItemId - WorkItem id * @param actorRole - Role of the caller (`'agent'` for normal worker * submissions; passed through to `isTransitionPermitted`) * @param result - Optional result payload to attach to the WorkItem * @returns The updated WorkItem, or `null` if the WI was deleted * between the find and the update (race window) * @throws When the WorkItem is missing, the transition is invalid, or * the actor is not permitted to perform `running → done_by_worker`. */ submitForVerification(workItemId: string, actorRole: TransitionActorInput, result?: Record): Promise; /** * Worker (or system) marks a non-delegated WorkItem as fully done. * * Transitions `running → done` via {@link transitionStatus}. Used by * the `completeItem` facade for `cron_run`, `notify`, `reconcile`, * `check`, `confirm`, `review` types — anything whose lifecycle does * NOT include a TL verification step. * * The Reconciler service is the only system-actor caller and uses * `actorRole='system'`, which bypasses the actor check while still * respecting the state-machine legality check. * * @param workItemId - WorkItem id * @param actorRole - Role of the caller (`'agent'`, `'system'`, etc.) * @param result - Optional result payload to attach * @returns The updated WorkItem, or `null` if the WI was deleted * between the find and the update (race window) * @throws When the WorkItem is missing, the transition is invalid, or * the actor is not permitted to perform `running → done`. */ completeSimpleItem(workItemId: string, actorRole: TransitionActorInput, result?: Record): Promise; /** * The verdict a review item's completion would render on its source, after * checking that the completing caller may render it (#813). * * A review item carries `metadata.verifyOf` = the source WorkItem id. The * caller completes the review as its assignee (`agent` role) or as the * orchestrator; on the source it acts as the REVIEWER, so the verdict is * checked as `team_lead`/`orchestrator` with the caller's session — the * reviewer rule then decides by identity, not by the claimed role. * * @param workItemId - The item being completed * @param actorInput - The caller completing it * @param result - The completion result (`verdict: 'rejected'` sends it back) * @returns The planned verdict, or null when this item reviews nothing that * is still awaiting review * @throws ForbiddenTransitionError when the caller may not render the verdict */ private planReviewVerdict; /** * Map the caller of a review item's completion to the actor that renders * the verdict on the source. `agent` → `team_lead` (it is reviewing, not * working); every other role is kept. A missing actor stays missing, so it * is refused. * * @param actorInput - The completing caller * @returns The reviewer actor (or undefined → refused by the gate) */ private static reviewerActorFor; /** * Run the closed permission gate for one transition and throw on refusal. * * For the verdict edges, an item that predates reviewer stamping gets its * reviewer from its review item (`:verify:`, targeted at the * reviewer when the bridge created it), so a lead can still review items * that were in flight when #813 shipped. * * @param item - The WorkItem as it is now * @param to - Target status * @param actorInput - The caller * @param reviewerHint - Reviewer of record when the caller already knows it * (the target of the review item being completed) * @throws ForbiddenTransitionError when refused */ private assertTransitionPermitted; /** * Stamp a review-escalation marker on a `done_by_worker` item (#813). * * The marker is what lets the next reviewer in the chain render the verdict * (the orchestrator after {@link REVIEW_ESCALATED_TO_ORC_KEY}), and it * makes each escalation fire once even across a backend restart — the * reconciler used to dedupe in memory only. Status is not touched. * * @param workItemId - The item under review * @param key - Metadata key to stamp (the orc or owner escalation key) * @param at - Timestamp to record (defaults to now) * @returns True when stamped, false when the item is gone or no longer awaiting review */ stampReviewEscalation(workItemId: string, key: string, at?: string): Promise; /** * TL records a verdict on a WorkItem in `done_by_worker` status. * * `verified` advances to terminal success and unblocks dependents; * `rejected` parks the item until the TL re-queues it (which TRANS-1 * gates to TL/orchestrator/system actors). Worker-actor calls throw * automatically via {@link transitionStatus}'s permission gate — we * do NOT need to add an explicit role check here, the matrix in * `TRANSITION_PERMISSIONS` handles it. * * @param workItemId - WorkItem id (must currently be `done_by_worker`) * @param actorRole - Role of the caller (`'team_lead'` or `'orchestrator'` * for verify/reject; worker calls throw) * @param verdict - `'verified'` or `'rejected'` * @param comment - Optional reviewer comment recorded in `wi.error` * (the field is reused — the WorkItem schema does not yet have a * dedicated `verifierComment` slot; keeping this on `error` lets * downstream UIs render TL feedback alongside failure causes) * @returns The updated WorkItem, or `null` on race-window deletion * @throws When the WorkItem is missing, the verdict is invalid, the * transition is illegal, or the actor is not permitted to verify. */ verifyItem(workItemId: string, actorRole: TransitionActorInput, verdict: 'verified' | 'rejected', comment?: string): Promise; /** * CREW-403: once a retry is verified, make sure the `rejected` parent it * replaced is marked as dealt with. * * `rejected` only has a `→ queued` edge, so the parent can never be * verified itself; its disposition (`succeeded_by` the retry) is what tells * the escalation paths to leave it alone. The bridge stamps that when it * creates the retry, but the stamp is best-effort. This re-applies it * (idempotent: an existing disposition wins) so a lost stamp cannot leave a * parent whose work is done sitting undisposed and escalating to the * orchestrator, who cannot render a verdict on it. * * @param workItemId - The WorkItem that was just verified */ private closeRejectedParentOfRetry; /** * Close the review item tracking a source WorkItem's verification, once * the verdict has been rendered directly on the source (#819) — e.g. via * `POST /verdict`, or the owner resolving an escalation — rather than by * completing the review item itself. Without this, `:verify:` * sits open in the reviewer's queue forever, even though its source has * already been verified or rejected. * * A review item still `running` (claimed by its reviewer) is completed the * same way a reviewer's own completion would ({@link completeSimpleItem}); * `planReviewVerdict` is a safe no-op there because the source is no * longer `done_by_worker`, so this can never recurse back into * {@link verifyItem}. A review item never claimed (`queued`/`blocked`/ * `scheduled`) is cancelled instead, since `→done` requires `running`. * Already-terminal or missing review items are left alone. * * Best-effort: any failure here is logged, never thrown — the real verdict * on the source has already been recorded, and that must not be * jeopardized by a failure to tidy up a secondary tracking item. * * @param sourceId - The WorkItem whose verdict was just rendered */ private closeReviewItemAfterDirectVerdict; /** * Completion facade — picks the verification path for the caller. * * Reads the WorkItem, applies the {@link requiresVerification} policy, and * dispatches to either {@link submitForVerification} (delegate items / * explicit opt-in) or {@link completeSimpleItem} (everything else). * * #813: the caller's actor is REQUIRED. It used to be hard-coded `'agent'` * with no identity, so a review item's completion could not tell its * reviewer from anyone else. Controllers resolve it server-side (see * `resolveTransitionActor` in the task-pool controller). * * A completion from the item's own agent is never lost to claim churn: if * the item is `queued` because that agent's claim was revoked or it was * released back (or it never claimed it — e.g. it worked it from a batch * reminder), the item is resumed (`queued → running`) and the completion * proceeds normally. A completion from anyone else on a queued item still * fails the transition (409). See {@link resumeOwnQueuedItemForCompletion}. * * @param workItemId - WorkItem id * @param result - Optional result payload * @param actor - Who is completing it (role + session) * @throws When the WorkItem is missing or the underlying transition * is rejected (invalid state, forbidden actor, not the reviewer). */ completeItem(workItemId: string, result: Record | undefined, actor: TransitionActorInput): Promise; /** * Puts a `queued` WorkItem back to `running` when its own agent reports it * complete, so the completion lands instead of failing `queued → done*` * with a 409. * * "Its own agent" is the item's `target`, or — for a broadcast item whose * claim stamp was dropped on release — the holder of its most recent claim. * Anyone else gets `null` and the caller's transition fails as before. * * The 2026-09-29 loop this closes (WI f34f09b0, CE-19): Vera's claim was * grace-revoked while she worked, the item went back to `queued`, her * `complete` got 409, and AutoClaim re-claimed the finished item for her a * minute later — `running` for an idle agent, with the work already done. * * @param workItem - The queued WorkItem * @param actorInput - Who is completing it * @returns The resumed (running) WorkItem, or null when the caller is not * its agent or it is no longer queued */ private resumeOwnQueuedItemForCompletion; /** * Puts a `blocked` WorkItem back to `running` when the agent that worked it * reports it complete, so the completion lands instead of failing * `blocked → done*` with a 409. * * CE-232 (CREW-439): Vera reported `[BLOCKED]` on her own running item * (`blockItem`, which releases the claim), then finished the work and her * `POST /task-pool/complete` was refused — there is no `blocked → done` * transition and, unlike `queued`, no rescue. Only the item's owner gets * one: the holder of its latest claim. A general `blocked → done` for anyone * is deliberately not added. A `waiting_on_human` park (the reconciler * resumes it itself) and a dependency block (never claimed, so it has no * claim holder) are left alone. * * @param workItem - The blocked WorkItem * @param actorInput - Who is completing it * @returns The resumed (running) WorkItem, or null when the caller is not * the item's claim holder, the block is a human-wait park, or it is no * longer blocked */ private resumeOwnBlockedItemForCompletion; /** * The agent that held the most recent claim on a WorkItem, if any. * * @param workItemId - WorkItem id * @returns Agent session of the latest claim, or undefined */ private latestClaimHolder; /** * Scans blocked WorkItems that list `completedId` in their `dependsOn` and * promotes each to `queued` if every one of their deps has reached terminal * success (`done` or `verified`). Idempotent and safe to call on any terminal * success transition. * * Serialized via the claim mutex — a concurrent claimFromPool call must not * observe a half-promoted item. */ resolveBlockedDependents(completedId: string): Promise; /** * Marks a work item as failed. * * @param workItemId - ID of the work item * @param error - Error description * @throws Error if work item not found or not in 'running' status */ failItem(workItemId: string, error: string): Promise; /** * Cancel a WorkItem that is currently `queued` or `blocked` (i.e. has * NOT been claimed yet). * * Closes the 2026-05-25 #609 incident: a fallback delegate WI targeted * at `crewly-orc` got stuck `queued` and kept re-dispatching every * minute. The available APIs were either inappropriate (`fail` requires * `running`, `complete` requires `running`) or destructive (`DELETE * /api/task-pool/:id?force=1` is a hard delete with no audit trail). * This method provides the clean `queued → cancelled` transition with * a reason captured in `cancelReason` for the activity timeline. * * @param workItemId - Target WI id * @param reason - Human-readable explanation; persisted on * `cancelReason` and surfaces in the UI badge. * @param options - `supersededBy`: id(s) of the WorkItem(s) that now * carry this work. Stamped on `metadata.supersededBy` * so the Request's completion check follows the * replacement instead of counting this item. * @throws if the WI doesn't exist, has an active claim, or is in a * non-cancellable terminal state. */ cancelQueued(workItemId: string, reason: string, options?: { supersededBy?: string[]; cancelledBy?: string; }): Promise; /** * Requeue a failed WorkItem back to `queued` for another attempt. * * Path: `failed → queued` (legal in WORK_ITEM_TRANSITIONS but previously * unused — the system never auto-retried, every failure was terminal). * Increments `retryCount`, clears `startedAt`, preserves `target` so * the same agent gets a second shot, and records the previous error * on the WI's metadata for postmortem visibility. * * Caller must have ALREADY confirmed `retryCount < maxRetries` — this * method does NOT enforce the cap, on the assumption that the caller * (v3-data.onTaskFailed) handles the exhausted-retries branch with * escalation to ORC. * * @param workItemId - The failed WI to retry * @param reason - Why the previous attempt failed (recorded on * metadata.lastFailureReason; surfaces in the activity timeline) * @throws if the WI doesn't exist or isn't in `failed` status */ requeueAfterFailure(workItemId: string, reason: string): Promise; /** * Decide and record how a stranded WorkItem is dealt with — the single * funnel every `rejected`/`failed` writer must route through. * * `rejected` and `failed` are the only two statuses that are neither * terminal nor able to reach a terminal state: their sole outbound edge is * `→ queued`. An item parked in either is finished as far as every consumer * is concerned (see `SLA_TERMINAL_WORK_ITEM_STATUSES`) and unfinished as far * as the state machine is concerned, and until this funnel existed nothing * owned closing that gap. Items written there by a path that did not itself * arrange a successor simply stopped, permanently and — since PR #733 * correctly stopped the pruning rules emitting illegal corrections for them * — silently. * * Exactly one of three things happens, and the choice is recorded rather * than left to be re-derived later by scanning other WorkItems: * * 1. **retried in place** — `failed` with retry budget left. The item goes * back on the queue via {@link requeueAfterFailure}, which bumps * `retryCount`. Deliberately NOT stamped: the item has left the stranding * statuses under its own power, and a stamp would be a claim about a * failure that is no longer the current one. * 2. **succeeded by another item** — the caller already created a successor * (the `EventToWorkItemBridge` retry/escalation WorkItem). Stamped with * its id. * 3. **terminal** — budget spent, or a status with no retry path. Stamped, * and escalated so a human or the orchestrator renders the verdict. This * is the "deliberate terminal state with an audit record" case: the item * stays in `rejected`/`failed` by design, because the alternative — * adding `→ cancelled` edges — would re-open the illegal-correction * surface #733 closed and turn a deliberate decision into a 24h default. * * Idempotent and re-entrant: an already-disposed item is returned unchanged, * so concurrent reconciler passes converge and the safety-net rule cannot * fire twice on the same strand. Terminating: the only branch that repeats * is (1), and it bumps `retryCount`, so it can run at most `maxRetries` * times before (3) becomes the only reachable outcome. * * @param workItemId - The stranded WorkItem. * @param options - `reason` is carried into the audit record and the * escalation. `actor` defaults to `'system'`. * `successorWorkItemId` selects outcome (2) when the * caller owns a real successor. * @returns The recorded disposition, or `null` when there was nothing to do * (item missing, or not in a stranding status). * * @example * ```typescript * await pool.disposeFailedWorkItem(wi.id, { reason: 'SLA escalation timeout' }); * ``` */ disposeFailedWorkItem(workItemId: string, options: { reason: string; actor?: WorkItemOwner; successorWorkItemId?: string; }): Promise; /** * Persist a disposition record onto a WorkItem's metadata. * * Writes metadata only — no status transition — so it is safe to call on an * item whose status has no legal outbound edge, which is precisely the * situation a `terminal` disposition describes. * * @param workItemId - WorkItem to stamp. * @param disposition - The record to write. * @returns The disposition as written. */ private stampDisposition; /** * Processes a heartbeat from an agent for their active claim. * * @param claimId - The claim ID * @param agentId - The agent sending the heartbeat * @returns HeartbeatResult */ heartbeat(claimId: string, agentId: string): Promise; /** * Renews a claim's lease because its holder was seen working (Reconciler * liveness renewal — see {@link ClaimService.renewLease}). * * @param claimId - The claim ID * @returns True when the claim was renewed */ renewClaim(claimId: string): Promise; /** * Extends the lease on a claim. * * @param claimId - The claim ID * @param agentId - The agent requesting extension * @returns ExtendLeaseResult */ extendLease(claimId: string, agentId: string): Promise; /** * Scans for expired claims (for Reconciler use). * * @param now - Current timestamp in ms * @returns Summary of expiring and grace-exceeded claims */ scanExpiredClaims(now?: number): Promise; /** * Agents that look HUNG — they keep claiming work but never heartbeat, so * their claims are repeatedly grace-revoked. Powers the `/health` * orchestrator-liveness signal ("up but stalled") and hung-session recovery. * * @param threshold - Minimum consecutive grace-revokes to count as hung. * @returns Agent ids that currently look hung. */ getHungAgents(threshold?: number): string[]; /** * Clear an agent's hung state after a recovery (session restart), so the * restarted session is not immediately re-flagged before it can heartbeat. * * @param agentId - The agent/session id to clear. */ clearHungState(agentId: string): void; /** * Revokes a claim and releases the work item back to the pool. * Used by the Reconciler when a claim's grace period is exceeded. * * @param claimId - The claim to revoke * @param reason - Reason for revocation */ revokeAndRelease(claimId: string, reason: string): Promise; /** * Exposes the underlying ClaimService for advanced operations. * * @returns The ClaimService instance */ getClaimService(): ClaimService; /** * Returns a snapshot of pool statistics. * * @returns Pool statistics including counts and avg wait time */ getPoolStatus(): Promise; /** * Returns all available (unclaimed, queued) work items. * * @param filters - Optional filters * @returns Array of available WorkItems */ getAvailableItems(filters?: PoolFilters): Promise; /** * Returns all work items in the pool (all statuses). * * @returns Array of all WorkItems */ getAllItems(): Promise; /** * Find a WorkItem by id without mutating it. * * Public read accessor used by callers that need to inspect a * specific item — for example REVIEW-1's reentrancy lock checks the * status of a Mission's `pendingReviewWorkItemId` to decide whether * to clear the lock. Returns `null` (not `undefined`) so callers can * use a uniform null-fallthrough idiom shared with * `getWorkItemSnapshot` and `transitionStatus`. * * @param workItemId - WorkItem id to look up * @returns The WorkItem, or `null` if no item has that id */ findWorkItem(workItemId: string): Promise; /** * Stores worker-supplied structured output on a WorkItem. * * Replaces the v1 `.output.json` filesystem store with an * in-pool field (see {@link WorkItem.output}). Used by the worker * `complete-task` flow and read back by the TL `verify-output` skill. * * @param workItemId - WorkItem id to attach output to * @param output - Arbitrary task-specific output object * @returns The updated WorkItem, or null if not found */ setOutput(workItemId: string, output: Record): Promise; /** * Shallow-merge keys into a WorkItem's metadata (no status change). * * @param workItemId - WorkItem id * @param patch - Keys to set on `metadata` * @returns The updated WorkItem, or null if not found */ mergeItemMetadata(workItemId: string, patch: Record): Promise; /** * Replace the `blockedReason` of a blocked WorkItem (no status change): a * held follow-up whose due time moved (crewly#1360). * * @param workItemId - WorkItem id * @param reason - The new reason * @returns The updated WorkItem, or null when not found or not blocked */ setBlockedReason(workItemId: string, reason: string): Promise; /** * Re-point a queued WorkItem at another agent (its target went stale). * Only `queued` items move: running work belongs to whoever holds it. * * @param workItemId - WorkItem id * @param target - New target session * @param reason - Why (kept in metadata.retargetedFrom / retargetReason) * @returns The updated item, or null when not found / not queued */ retargetQueuedItem(workItemId: string, target: string, reason: string): Promise; /** * Unassign every queued, unclaimed WorkItem whose target is one of these * sessions (their team was paused — specs/2026-10-04-team-pause.md). The * item goes back to the unassigned pool, where the untargeted router picks * a decider that is not paused. Running and claimed items are left alone. * * @param sessions - Session names of the paused team's members * @param reason - Why (stored on the item and logged) * @returns Ids of the items unassigned */ unassignQueuedForSessions(sessions: ReadonlySet, reason: string): Promise; /** * Records an auditor quality score on a WorkItem. * * Backs `POST /api/tasks/score` (auditor `score-task` skill). The score * lives under `metadata.qualityScore` with the scorer and timestamp next * to it, so it survives the regular WorkItem update path and needs no * separate persistence. * * @param workItemId - WorkItem id to score * @param qualityScore - Score in the 0–100 range (validated by the caller) * @param scoredBy - Session/member name of the scorer * @returns The updated WorkItem, or null if not found */ scoreItem(workItemId: string, qualityScore: number, scoredBy: string): Promise; /** * Reassigns a WorkItem to a different target agent. * * Replaces the v1 `/task-management/handoff` filesystem reassign with a * direct `target` field update on the WorkItem. * * Allowed transitions: * - `target` must be a non-empty string * - WI must not be in a terminal state (done/cancelled/failed-final) * * @param workItemId - WorkItem to reassign * @param newTarget - Session name of the new target agent * @param fromAgent - Session name of the agent handing off (for audit) * @param reason - Human-readable reason recorded as a working-note * @returns The updated WorkItem, or null if not found */ handoff(workItemId: string, newTarget: string, fromAgent: string, reason: string): Promise; /** * Set one top-level metadata key on a WorkItem. Never changes its status * (this is not a transition), so it needs no actor. Used by the worktree * service to record `metadata.worktree` (#814). * * @param workItemId - WorkItem id * @param key - Metadata key * @param value - New value (replaces the old one) * @returns The updated WorkItem, or null when it does not exist */ patchMetadata(workItemId: string, key: string, value: unknown): Promise; /** * Appends a working-state note to a WorkItem's metadata.notes array. * * Replaces v1 `/task-management/sync` and `/task-management/save-working-notes`, * both of which appended progress notes to the `.md` task body. With v1 * retired, notes live on the WorkItem itself under `metadata.notes[]`. * * @param workItemId - WorkItem id * @param author - Session name of note author * @param note - Note content (free-form) * @returns The updated WorkItem, or null if not found */ appendNote(workItemId: string, author: string, note: string): Promise; /** * Removes a WorkItem from the pool entirely. * Used for purging old completed/cancelled items. * * @param workItemId - ID of the item to remove */ removeItem(workItemId: string): Promise; /** * Public delete API used by the bulk-cleanup script and the new * `DELETE /api/task-pool/:id` HTTP endpoint (P1 umbrella WI 1ffffb84 * component a, Steve directive 2026-05-06: just-DELETE-not-backfill). * * Three guarantees over {@link removeItem}: * 1. Idempotent — `not_found` is a no-op return, not a throw. * 2. Claim-safety — refuses claimed delete unless `opts.force`. * 3. Audit log — single info-level log per removal. * * Force path also revokes the orphaned claim (status='revoked', * endedAt, endReason). */ removeFromPool(workItemId: string, opts?: { force?: boolean; }): Promise; /** * Forces an immediate flush of pool data to disk. * Called during graceful shutdown to prevent data loss. */ flush(): Promise; /** * Returns all active/expiring claims. * Used by the Reconciler to check lease health. * * @returns Array of active/expiring TaskClaims */ getActiveClaims(): Promise; /** * Updates a work item's status directly. * Used by the Reconciler for corrections (e.g., stuck → blocked). * * Reconciler invocations pass through `actorRole='system'` (default) which * bypasses the per-role gate at {@link isTransitionPermitted} but still * enforces the state-machine via {@link isValidWorkItemTransition}. Other * callers MUST supply the actor's role so TRANS-1 V3 enforcement applies. * * @param workItemId - The work item ID * @param newStatus - The target status * @param actorRole - Role of the caller (defaults to `'system'` for Reconciler) * @throws Error if work item not found * @throws Error if transition is invalid (state machine — see WORK_ITEM_TRANSITIONS) * @throws Error if actor is not permitted (role check — see TRANSITION_PERMISSIONS) */ updateItemStatus(workItemId: string, newStatus: WorkItemStatus, actorRole: TransitionActorInput, /** * Optional human-readable reason. Persisted on: * - `WorkItem.cancelReason` when `newStatus === 'cancelled'` * - `WorkItem.blockedReason` when `newStatus === 'blocked'` (Steve * 2026-05-15 dogfood — UI showed Blocked badge with no * explanation) * - Ignored for other status transitions. */ reason?: string, /** * Optional `WorkItem.blockSource` to set atomically with a transition * INTO `blocked` (the waiting_on_human rule passes * WORK_ITEM_BLOCK_SOURCES.WAITING_ON_HUMAN, #815). Ignored otherwise. */ blockSource?: WorkItemBlockSource): Promise; /** * Public guarded transition helper — TRANS-1's canonical entrypoint. * * Routes any externally-initiated WorkItem status change through the * combined state-machine + actor-role gates. Designed as the public API * VERIF-1 will call from `submitForVerification` / `verifyItem`, and as * the recommended path for any future caller that previously reached * for `storage.updateWorkItem` to flip status. * * Differences vs {@link updateItemStatus}: * - Requires `actorRole` explicitly (no default) — forces the caller to * decide the trust posture rather than silently inheriting `'system'`. * - Accepts an optional `mutator` so callers can carry additional field * updates (e.g. `result`, `error`, `completedAt`) atomically with the * status flip — preventing races between status update and metadata * attachment that direct `storage.updateWorkItem` callers risked. * * @param workItemId - The work item ID * @param newStatus - The target status * @param actorRole - Role of the caller (REQUIRED; pass `'system'` for trusted server-internal paths) * @param mutator - Optional additional WorkItem field updates applied atomically with the status change * @returns The updated WorkItem after the transition * @throws Error if work item not found * @throws Error if transition is invalid (state machine) * @throws Error if actor is not permitted (role check) * * @example * ```typescript * // VERIF-1 worker submitting for verification * await pool.transitionStatus(wiId, 'done_by_worker', 'agent', (wi) => { * wi.result = output; * }); * * // VERIF-1 TL verifying * await pool.transitionStatus(wiId, 'verified', 'team_lead'); * * // VERIF-1 TL rejecting (allowed for TL only) * await pool.transitionStatus(wiId, 'rejected', 'team_lead', (wi) => { * wi.error = 'Did not meet acceptance criteria'; * }); * ``` */ transitionStatus(workItemId: string, newStatus: WorkItemStatus, actorRole: TransitionActorInput, mutator?: (wi: WorkItem) => void, /** * Optional human-readable reason. Routed by target status: * - `cancelled` → persisted on `WorkItem.cancelReason` * - `blocked` → persisted on `WorkItem.blockedReason` * (Steve 2026-05-15 dogfood) * - other → ignored * Applied BEFORE the caller's mutator runs so the mutator may * override if needed. Surfaces in the activity timeline so the * Blocked/Cancelled badges aren't opaque ("WorkItem was X."). * Parameter name retained as `cancelReason` for source compat; * effectively a generic transition reason. */ cancelReason?: string): Promise; /** * Update token usage and cost on a WorkItem. * Called after task completion when token data is available from TokenUsageService. * * @param workItemId - The work item ID * @param inputTokens - Number of input tokens consumed * @param outputTokens - Number of output tokens generated * @param cost - Total cost in USD * @returns True if the update was applied, false if WorkItem not found */ updateTokenUsage(workItemId: string, inputTokens: number, outputTokens: number, cost: number): Promise; /** * Marks a claim as 'expiring' (lease expired, within grace period). * Used by the Reconciler fast loop. * * @param claimId - The claim ID to mark */ markClaimExpiring(claimId: string): Promise; /** * Graceful shutdown — flush pending writes. */ destroy(): Promise; /** * Exposes underlying storage for advanced operations (e.g., Reconciler). * * @returns The PoolStorage instance */ getStorage(): PoolStorage; } //# sourceMappingURL=task-pool.service.d.ts.map