diff --git a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts index a9782fb250e..497b9e01aea 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -14,8 +14,10 @@ import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; import type { + CatId, CatRoutingError, MessageFrom, + MessageWorkDisposition, QueueAuthorIntent, QueueAuthorIntentFallbackReason, QueueTargetAttemptTerminalReason, @@ -24,6 +26,11 @@ import type { import { isMessageFrom } from '@cat-cafe/shared'; import { createModuleLogger } from '../../../../../infrastructure/logger.js'; import type { CallerTraceContext } from '../../../../../infrastructure/telemetry/genai-semconv.js'; +import { + type QueueAdmissionPolicyContext, + resolveMessageDispositionForAdmission, + resolveQueueAuthorIntentByCatId, +} from '../../../../../routes/message-disposition-admission.js'; import { type ActionSuccessorFence, actionSuccessorFencesMatch, @@ -43,6 +50,7 @@ import { type QueueLedgerEntry, type QueueLedgerStore, type QueueOwner, + queueEntryId, queueOwner, } from './queue-ledger/QueueLedger.js'; import { createQueueLedgerAdmission } from './queue-ledger/QueueLedgerAdmission.js'; @@ -64,6 +72,8 @@ type LifecycleTargetRetirementResult = | { outcome: 'state_changed' }; export interface QueueEnqueueInput { + /** Consumer observers register after commit and before the shared drain notification. Not persisted. */ + onQueueEntriesAdmitted?: (entries: readonly QueueEntry[], message?: StoredMessage) => void | Promise; threadId: string; userId: string; owner?: QueueOwner; @@ -78,6 +88,8 @@ export interface QueueEnqueueInput { targetCats: string[]; routingWarnings?: CatRoutingError[]; authorIntentByCatId?: Record; + /** Optional one-message choice; absent means the shared scoped default at admission. */ + messageDisposition?: MessageWorkDisposition; intent: string; autoExecute?: boolean; liveSessionId?: string; @@ -264,7 +276,10 @@ export class InvocationQueue { } } - constructor(private readonly ledgerStore: QueueLedgerStore = new InMemoryQueueLedgerStore()) {} + constructor( + private readonly ledgerStore: QueueLedgerStore = new InMemoryQueueLedgerStore(), + private readonly admissionPolicy: QueueAdmissionPolicyContext = {}, + ) {} private scopeKey(threadId: string, userId: string): string { return `${threadId}:${userId}`; @@ -311,7 +326,7 @@ export class InvocationQueue { ) { throw new Error('targetCats must contain unique non-empty target ids'); } - if (kind !== 'conversation_input' && input.targetCats.length === 0) { + if (input.targetCats.length === 0 && kind !== 'conversation_input') { throw new Error(`${kind} must have an exact target`); } if (!isMessageFrom(input.from)) { @@ -366,19 +381,55 @@ export class InvocationQueue { sourceId: string, enqueuedAt: number, messageId?: string, + replayEntries?: readonly QueueLedgerEntry[], + reuseResolvedTargets = false, + requestedTargetCats?: readonly string[], ): QueueLedgerEntry[] { InvocationQueue.requireAdmissionContract({ ...input, messageId: messageId ?? input.messageId }); + // A stable source reuses its winning policy, including an exact parent or + // fallback. Only genuinely new admissions consult the current defaults. + const replay = replayEntries?.find((entry) => entry.id === queueEntryId(sourceId)); + if ( + requestedTargetCats && + replay?.payload.requestedTargetCats && + !isDeepStrictEqual(replay.payload.requestedTargetCats, requestedTargetCats) + ) { + throw new Error(`Queue admission identity conflict: ${sourceId}`); + } + const authorIntentByCatId = replay + ? replay.delivery.authorIntentByTarget + : input.kind === 'private_input' + ? input.authorIntentByCatId + : { + ...resolveQueueAuthorIntentByCatId({ + ...this.admissionPolicy, + targetCats: input.targetCats as CatId[], + requested: resolveMessageDispositionForAdmission({ + explicit: input.messageDisposition, + projectRoot: this.admissionPolicy.projectRoot, + threadId: input.threadId, + }), + threadId: input.threadId, + userId: input.userId, + now: enqueuedAt, + }), + ...input.authorIntentByCatId, + }; return createQueueLedgerAdmission({ sourceId, threadId: input.threadId, owner: queueOwner(input), kind: input.kind, from: input.from, - targetCatIds: input.targetCats, + targetCatIds: replay && reuseResolvedTargets ? replay.targets : input.targetCats, + ...((requestedTargetCats ?? replay?.payload.requestedTargetCats) && + (!replay || replay.payload.requestedTargetCats) + ? { requestedTargetCats: requestedTargetCats ?? replay?.payload.requestedTargetCats } + : {}), content: input.content, ...(messageId ? { messageId } : {}), ...(input.routingWarnings ? { routingWarnings: input.routingWarnings } : {}), - ...(input.authorIntentByCatId ? { authorIntentByCatId: input.authorIntentByCatId } : {}), + ...(authorIntentByCatId ? { authorIntentByCatId } : {}), intent: input.intent, ownerAuthProvenance: input.ownerAuthProvenance, executionScope: input.executionScope, @@ -512,11 +563,19 @@ export class InvocationQueue { async enqueueDurable(input: QueueEnqueueInput): Promise { const sourceId = InvocationQueue.persistentSourceId(input); const enqueuedAt = this.nextEnqueuedAt(); - const rows = this.createLedgerRows(input, sourceId, enqueuedAt, input.messageId ?? undefined); + const existing = await this.ledgerStore.get(input.threadId, queueEntryId(sourceId)); + const rows = this.createLedgerRows( + input, + sourceId, + enqueuedAt, + input.messageId ?? undefined, + existing ? [existing] : undefined, + ); const result = await this.ledgerStore.enqueue(rows, input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined); if (result.outcome === 'full') return { outcome: 'full' }; if (result.outcome === 'conflict') throw new Error(`Queue admission identity conflict: ${sourceId}`); const entries = this.cacheLedgerEntries(result.entries); + await this.notifyAdmission(input.threadId, entries, undefined, input.onQueueEntriesAdmitted); const primary = entries[0]; return { outcome: 'enqueued', @@ -548,6 +607,7 @@ export class InvocationQueue { const result = await messageStore.appendNoticeWithPrivateQueueAdmission(notice, rows, this.ledgerStore); if (result.outcome === 'full') return { outcome: 'full' }; const entries = this.cacheLedgerEntries(result.entries); + await this.notifyAdmission(input.threadId, entries, undefined, input.onQueueEntriesAdmitted); const primary = entries[0]; return { outcome: 'enqueued', @@ -564,11 +624,19 @@ export class InvocationQueue { throw new Error('synchronous durable Queue admission requires the in-memory ledger'); } const sourceId = InvocationQueue.persistentSourceId(input); - const rows = this.createLedgerRows(input, sourceId, this.nextEnqueuedAt(), input.messageId ?? undefined); + const existing = this.ledgerStore.getNow(input.threadId, queueEntryId(sourceId)); + const rows = this.createLedgerRows( + input, + sourceId, + this.nextEnqueuedAt(), + input.messageId ?? undefined, + existing ? [existing] : undefined, + ); const result = this.ledgerStore.enqueueNow(rows, input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined); if (result.outcome === 'full') return { outcome: 'full' }; if (result.outcome === 'conflict') throw new Error(`Queue admission identity conflict: ${sourceId}`); const entries = this.cacheLedgerEntries(result.entries); + this.notifyAdmission(input.threadId, entries, undefined, input.onQueueEntriesAdmitted); const primary = entries[0]; return { outcome: 'enqueued', @@ -581,12 +649,83 @@ export class InvocationQueue { }; } + /** Shared conversation fallback; authorization-bound carriers retain exact recipients. */ + async resolveSendTargets( + requested: readonly string[], + threadId: string, + content?: string, + exact = false, + ): Promise { + const resolved = this.admissionPolicy.resolveTargets + ? await this.admissionPolicy.resolveTargets(requested, threadId, content, exact) + : [...requested]; + if (exact && requested.length > 0 && !isDeepStrictEqual(resolved, [...requested])) { + throw new Error('send target identity is invalid; explicit targets cannot be rerouted'); + } + return resolved; + } + + private notifyAdmission( + threadId: string, + entries: readonly QueueEntry[], + message?: StoredMessage, + onPrepared?: QueueEnqueueInput['onQueueEntriesAdmitted'], + ): void | Promise { + if (entries.length === 0) return; + const wake = () => { + try { + this.admissionPolicy.onAdmitted?.({ threadId, entries, ...(message ? { message } : {}) }); + } catch (err) { + // Durable acceptance cannot be undone by an ephemeral wake failure; recovery owns the rows. + this.log.error({ err, threadId }, 'send drain notification failed'); + } + }; + // Domain consumers may bind authority or observations to the committed source, before it runs. + const prepared = onPrepared?.(entries, message); + if (prepared && typeof prepared.then === 'function') return prepared.then(wake); + wake(); + } + /** One storage transaction for a queued Message and its complete Queue fan-out. */ - async appendAndEnqueueDurable( + async send( messageStore: IMessageStore, message: AppendMessageInput, input: QueueEnqueueInput, ): Promise { + // Explicit wake and authorization-bound carriers cannot substitute their recipient. Ordinary conversation + // work may use the shared fallback, and every replay retains its winning recipient. + if ( + message.visibility === 'whisper' && + (input.targetCats.length === 0 || + input.targetCats.some((target) => !message.whisperTo?.includes(target as CatId))) + ) { + throw new Error('Whisper Queue targets must be authorized recipients'); + } + const exactTargets = Boolean( + message.visibility === 'whisper' || + input.executionScope || + input.actionSuccessorFence || + input.waitContinuationCarrier || + input.cloudDispatchProvenance || + input.requiresExactCloudDispatchProvenance || + input.liveSessionId || + input.sourceCategory === 'a2a_failure' || + input.kind === 'private_input' || + input.kind === 'message_wake', + ); + const reuseResolvedTargets = !exactTargets; + const requestedTargetCats = [...input.targetCats]; + const committed = message.idempotencyKey + ? await messageStore.getByIdempotencyKey(input.userId, input.threadId, message.idempotencyKey) + : null; + const committedRow = committed ? await this.ledgerStore.get(input.threadId, queueEntryId(committed.id)) : null; + input = { + ...input, + targetCats: + committedRow && !exactTargets + ? [...committedRow.targets] + : await this.resolveSendTargets(input.targetCats, input.threadId, input.content, exactTargets), + }; if ( (message.threadId ?? 'default') !== input.threadId || message.userId !== input.userId || @@ -599,23 +738,26 @@ export class InvocationQueue { const enqueuedAt = this.nextEnqueuedAt(); const result = await messageStore.appendWithQueueLedgerAdmission( message, - (messageId) => this.createLedgerRows(input, messageId, enqueuedAt, messageId), + (messageId, replayEntries) => + this.createLedgerRows( + input, + messageId, + enqueuedAt, + messageId, + replayEntries, + reuseResolvedTargets, + requestedTargetCats, + ), this.ledgerStore, input.from.kind === 'user' ? MAX_QUEUE_DEPTH : undefined, ); if (result.outcome === 'full') return { outcome: 'full' }; const projected = this.cacheLedgerEntries(result.entries); - const expected = this.createLedgerRows(input, result.message.id, enqueuedAt, result.message.id); - const primary = - projected[0] ?? - (() => { - const first = expected[0]; - if (!first) return undefined; - const owner = queueOwner(input); - const ownerUserId = owner.kind === 'user' ? owner.userId : `system:${owner.service}`; - return this.findEntry(input.threadId, ownerUserId, first.id); - })(); + await this.notifyAdmission(input.threadId, projected, result.message, input.onQueueEntriesAdmitted); + const owner = queueOwner(input); + const ownerUserId = owner.kind === 'user' ? owner.userId : `system:${owner.service}`; + const primary = projected[0] ?? this.findEntry(input.threadId, ownerUserId, queueEntryId(result.message.id)); return { outcome: 'enqueued', message: result.message, @@ -650,7 +792,14 @@ export class InvocationQueue { sourceId: responseMessageId, messageId: responseMessageId, }; - const rows = this.createLedgerRows(canonicalInput, responseMessageId, source.timestamp, responseMessageId); + const existing = await this.ledgerStore.get(input.threadId, queueEntryId(responseMessageId)); + const rows = this.createLedgerRows( + canonicalInput, + responseMessageId, + source.timestamp, + responseMessageId, + existing ? [existing] : undefined, + ); const result = await messageStore.commitLifecycleResponseTerminalWithQueueLedgerAdmission( responseMessageId, terminalPatch, @@ -665,6 +814,7 @@ export class InvocationQueue { ); } const projected = this.cacheLedgerEntries(result.entries); + await this.notifyAdmission(input.threadId, projected, result.message, input.onQueueEntriesAdmitted); const primary = projected[0]; return { outcome: 'enqueued', @@ -695,7 +845,8 @@ export class InvocationQueue { throw new Error('existing Queue source does not match its execution work item'); } const enqueuedAt = this.nextEnqueuedAt(); - const rows = this.createLedgerRows(input, messageId, enqueuedAt, messageId); + const existing = await this.ledgerStore.get(input.threadId, queueEntryId(messageId)); + const rows = this.createLedgerRows(input, messageId, enqueuedAt, messageId, existing ? [existing] : undefined); const result = await messageStore.enqueueExistingMessageWithQueueLedgerAdmission( messageId, rows, @@ -704,6 +855,7 @@ export class InvocationQueue { ); if (result.outcome === 'full') return { outcome: 'full' }; const projected = this.cacheLedgerEntries(result.entries); + await this.notifyAdmission(input.threadId, projected, result.message, input.onQueueEntriesAdmitted); const primary = projected[0]; return { outcome: 'enqueued', @@ -1754,14 +1906,11 @@ export class InvocationQueue { } private static canExposeToCurrentParent( - entry: Pick, + entry: Pick, catId: string, parentInvocationId: string | undefined, ): boolean { - // Author disposition belongs only to human-authored work. Agent and connector - // carriers retain their typed custody/continuation path and may be read at a - // current safe boundary without manufacturing a human queue preference. - if (entry.from.kind !== 'user') return true; + // All ordinary sources follow the same saved policy and exact-parent fence. const authorIntent = entry.delivery.authorIntentByTarget?.[catId]; return Boolean( parentInvocationId && diff --git a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts index d6c5be5b607..a1cef68ae8c 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/PersistedQueueDelivery.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; -import { createCatId, type WaitContinuationCarrierV1 } from '@cat-cafe/shared'; +import { createCatId, type MessageWorkDisposition, type WaitContinuationCarrierV1 } from '@cat-cafe/shared'; import type { OwnerAuthProvenance } from '../../owner-auth-provenance.js'; import type { AppendMessageInput, IMessageStore, StoredMessage } from '../../stores/ports/MessageStore.js'; import type { InvocationQueue, QueueEntry } from './InvocationQueue.js'; @@ -9,9 +9,10 @@ import { queueEntryId } from './queue-ledger/QueueLedger.js'; export interface PersistedQueueDeliveryInput { ownerUserId: string; threadId: string; - targetCatId: string; + targetCatId?: string; idempotencyKey: string; content: string; + messageDisposition?: MessageWorkDisposition; source: NonNullable; /** Producer-owned envelope metadata (e.g. memory cues); never routing or reliability state. */ extra?: NonNullable; @@ -87,7 +88,7 @@ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { messages: IMessageStore; queue: Pick< InvocationQueue, - | 'appendAndEnqueueDurable' + | 'send' | 'enqueueDurable' | 'enqueueDurableWithVisibleNotice' | 'findAdmittedEntriesForMessages' @@ -97,40 +98,46 @@ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { }, ) {} - async deliver(input: PersistedQueueDeliveryInput) { - const targetCat = createCatId(input.targetCatId); + async deliver(envelope: PersistedQueueDeliveryInput) { + const existing = await this.deps.messages.getByIdempotencyKey( + envelope.ownerUserId, + envelope.threadId, + envelope.idempotencyKey, + ); + const input = envelope; + const requestedTargets = input.targetCatId ? [createCatId(input.targetCatId)] : []; // `from.sender` is the canonical ACTOR identity: bundle author grouping keys on it, the // envelope maps it to `{kind:'user', id}`, and receipts address it. `source.sender` is the // person; `source.label` is the room's display name. Defaulting to the label would give every // member of a group chat the same fabricated identity named after the room, so a producer that // knows no person leaves it absent — consumers already fall back to connectorId/label to show. const from = - input.from ?? + envelope.from ?? ({ kind: 'external' as const, - connectorId: input.source.connector, - ...(input.source.sender ? { sender: input.source.sender } : {}), + connectorId: envelope.source.connector, + ...(envelope.source.sender ? { sender: envelope.source.sender } : {}), } as NonNullable); - const existing = await this.deps.messages.getByIdempotencyKey( - input.ownerUserId, - input.threadId, - input.idempotencyKey, - ); - if (existing) { + // Retired rows stay retired. Live-row replay, including its target and policy snapshot, + // belongs to common send rather than this producer adapter. + if (existing && !(await this.deps.queue.getDurableEntry(input.threadId, queueEntryId(existing.id)))) { return this.progressExistingMessage(existing, input); } - const admitted = await this.deps.queue.appendAndEnqueueDurable( + if (existing && !matchesPersistedEnvelope(existing, input)) { + return { state: 'conflict' as const, reason: 'Persisted producer envelope does not match', message: existing }; + } + const admitted = await this.deps.queue.send( this.deps.messages, { userId: input.ownerUserId, threadId: input.threadId, from, content: input.content, - mentions: [targetCat], + mentions: requestedTargets, timestamp: input.timestamp ?? Date.now(), deliveryStatus: 'queued', source: input.source, - extra: { ...(input.extra ?? {}), targetCats: [targetCat] }, + ...(input.extra ? { extra: input.extra } : {}), ...(input.contentBlocks ? { contentBlocks: input.contentBlocks } : {}), idempotencyKey: input.idempotencyKey, }, @@ -146,8 +153,9 @@ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { idempotencyKey: input.idempotencyKey, content: input.content, from, - targetCats: [targetCat], + targetCats: requestedTargets, intent: 'execute', + ...(input.messageDisposition ? { messageDisposition: input.messageDisposition } : {}), ...(input.priority ? { priority: input.priority } : {}), ...(input.suggestedSkill ? { suggestedSkill: input.suggestedSkill } : {}), ...(input.sourceCategory ? { sourceCategory: input.sourceCategory } : {}), @@ -175,7 +183,10 @@ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { if (entry.status === 'claimed' || entry.status === 'processing') { return { state: 'already_processing' as const, entryId: entry.id, message }; } - return { state: await this.deps.progress(entry, input.targetCatId), entryId: entry.id, message }; + if (entry.status === 'terminal') return { state: 'terminal_owned' as const, entryId: entry.id, message }; + const targetCatId = entry.targets[0]; + if (!targetCatId) return { state: 'unavailable' as const, reason: 'No available conversation target', message }; + return { state: await this.deps.progress(entry, targetCatId), entryId: entry.id, message }; } async deliverPrivate(input: PrivateQueueDeliveryInput): Promise<{ admitted: boolean; entryId?: string }> { @@ -268,7 +279,7 @@ export class PersistedQueueDelivery implements PersistedQueueDeliveryPort { return { state: 'already_processing', entryId, message: existing }; } if (entry.status === 'terminal') return { state: 'terminal_owned', entryId, message: existing }; - return { state: await this.deps.progress(entry, input.targetCatId), entryId, message: existing }; + return { state: await this.deps.progress(entry, entry.targets[0]!), entryId, message: existing }; } if ( this.deps.queue.findAdmittedEntriesForMessages( @@ -294,6 +305,6 @@ function matchesPersistedEnvelope(message: StoredMessage, input: PersistedQueueD message.content === input.content && message.source?.connector === input.source.connector && isDeepStrictEqual(message.source.meta, input.source.meta) && - message.mentions.some((cat) => cat === input.targetCatId) + (!input.targetCatId || message.mentions.some((cat) => cat === input.targetCatId)) ); } diff --git a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts index c7f41294328..08a1334d6f3 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -73,7 +73,7 @@ import { } from '../../stores/ports/MessageStore.js'; import type { IThreadStore } from '../../stores/ports/ThreadStore.js'; import type { ITurnExecutionStore } from '../../stores/ports/TurnExecutionStore.js'; -import { getTimelineOrderTime, resolveDeliveryTimelineScore } from '../../stores/visibility.js'; +import { canViewMessage, getTimelineOrderTime, resolveDeliveryTimelineScore } from '../../stores/visibility.js'; import { type AgentClientActiveRunDispatcher, type AgentMessage, @@ -604,6 +604,8 @@ export class QueueProcessor { private nextAutoResumeSuppressionEpoch = 0; /** RFC #1356: one event-driven drain owner plus a no-lost-wakeup dirty bit per thread. */ private readonly threadDrains = new Map(); + /** Native Append ACK holds only its target, after the durable handoff frees the drain. */ + private readonly automaticAppendSlots = new Set(); /** * RFC #1356 admission handoff. Queue owns only pre-admission work; once the * provider is admitted, this process-local registry keeps the immutable @@ -838,19 +840,23 @@ export class QueueProcessor { * The exact run/capability/revision fences remain owned by the shared * lifecycle projection and append transaction below. */ - async tryAutoAppendExactEntry(input: { - threadId: string; - userId: string; - entryId: string; - targetCatId?: string; - }): Promise { + async tryAutoAppendExactEntry( + input: { + threadId: string; + userId: string; + entryId: string; + targetCatId?: string; + }, + onHandoff?: () => void, + ): Promise { const { queue, invocationTracker } = this.deps; const entry = queue.getEntrySnapshot(input.threadId, input.userId, input.entryId); if (!entry || entry.execution.liveSessionId) { return { outcome: 'rejected', reason: 'append_unavailable' }; } const requestedTargets = input.targetCatId ? [input.targetCatId] : queueEntryTargetCats(entry); - if (requestedTargets.some((targetId) => entry.delivery.authorIntentByTarget?.[targetId])) { + { + // New send admissions persist intent; legacy rows without it cannot imply immediate guidance. if (!invocationTracker.getExecutionId) { return { outcome: 'rejected', reason: 'append_unavailable' }; } @@ -884,13 +890,47 @@ export class QueueProcessor { ...(input.targetCatId ? { targetIds: [input.targetCatId] } : {}), }); if (!projection.available) return { outcome: 'rejected', reason: 'append_unavailable' }; - return this.appendExactEntry({ - threadId: input.threadId, - userId: input.userId, - entryId: input.entryId, - expectedQueueRevision: projection.action.expectedQueueRevision, - expectedRuns: projection.action.expectedRuns, + return this.appendExactEntry( + { + threadId: input.threadId, + userId: input.userId, + entryId: input.entryId, + expectedQueueRevision: projection.action.expectedQueueRevision, + expectedRuns: projection.action.expectedRuns, + }, + onHandoff, + ); + } + + /** Wait for durable admission, keeping a native ACK on its own target rather than the thread owner. */ + private async appendFromDrain(input: { + threadId: string; + userId: string; + entryId: string; + targetCatId: string; + }): Promise { + const slotKey = QueueProcessor.slotKey(input.threadId, input.targetCatId); + this.automaticAppendSlots.add(slotKey); + let handedOff = false; + let releaseDrain!: () => void; + const handoff = new Promise((resolve) => { + releaseDrain = () => resolve(undefined); }); + const completion = this.tryAutoAppendExactEntry(input, () => { + handedOff = true; + releaseDrain(); + }).finally(() => { + this.automaticAppendSlots.delete(slotKey); + if (handedOff) { + void this.requestDrain(input.threadId).catch((err) => + this.deps.log.error( + { err, threadId: input.threadId, entryId: input.entryId }, + '[QueueProcessor] Append completion failed to signal Queue progress', + ), + ); + } + }); + return Promise.race([completion, handoff]); } /** @@ -898,13 +938,16 @@ export class QueueProcessor { * is revalidated before the synchronous Queue claim; provider side effects * occur only after exposure, lifecycle refs, and History admission are durable. */ - async appendExactEntry(input: { - threadId: string; - userId: string; - entryId: string; - expectedQueueRevision: string; - expectedRuns: readonly { targetId: string; invocationId: string; responseMessageId: string }[]; - }): Promise { + async appendExactEntry( + input: { + threadId: string; + userId: string; + entryId: string; + expectedQueueRevision: string; + expectedRuns: readonly { targetId: string; invocationId: string; responseMessageId: string }[]; + }, + onHandoff?: () => void, + ): Promise { if (input.expectedRuns.length > 1) { if (this.deps.queue.snapshotRevision(input.threadId, input.userId) !== input.expectedQueueRevision) { return { outcome: 'rejected', reason: 'state_changed' }; @@ -1002,6 +1045,14 @@ export class QueueProcessor { if (sourceMessagesBeforeAdmission.length !== inputMessageIds.length) { throw new Error(`lifecycle Append source vanished before admission: ${input.entryId}`); } + if ( + sourceMessagesBeforeAdmission.some((source) => + input.expectedRuns.some((run) => !canViewMessage(source, { type: 'cat', catId: run.targetId as CatId })), + ) + ) { + await queue.restoreClaimedEntries(input.threadId, [input.entryId]); + return { outcome: 'rejected', reason: 'append_unavailable' }; + } sourceMessages = sourceMessagesBeforeAdmission; const imagePaths = sourceMessagesBeforeAdmission.flatMap((message) => extractImagePaths(message.contentBlocks)); // Fence the response receiving this delivery. Model consumption is not a second admission. @@ -1079,6 +1130,7 @@ export class QueueProcessor { ); } providerDispatchStarted = true; + onHandoff?.(); const results = await Promise.all( input.expectedRuns.map(async (run, index) => { try { @@ -2803,8 +2855,8 @@ export class QueueProcessor { } /** - * Progress a producer-owned durable carrier through the ordinary Queue - * comparator. Recovery is not authority to skip older sources, clear a + * Progress a producer-owned durable carrier through existing Append admission + * or the ordinary Queue comparator. Recovery is not authority to clear a * cancellation fence, or start a second invocation for an admitted row. */ async progressOwnedCarrier(entry: QueueEntry, targetCatId: string): Promise { @@ -2823,11 +2875,6 @@ export class QueueProcessor { if (current.status === 'claimed' || current.status === 'processing') return 'already_processing'; if (current.status === 'terminal') return 'terminal_owned'; if (this.isAutoResumeSuppressed(entry.threadId, targetCatId)) return 'owned_deferred_suppressed'; - const slotKey = QueueProcessor.slotKey(entry.threadId, targetCatId); - if (this.processingSlots.has(slotKey) || this.deps.invocationTracker.has(entry.threadId, targetCatId)) { - return 'owned_deferred_busy'; - } - await this.requestDrain(entry.threadId); if ( this.deps.queue.findAdmittedEntriesForMessages(entry.threadId, queueEntryMessageIds(entry), userId, targetCatId) @@ -3053,6 +3100,36 @@ export class QueueProcessor { const settled = await this.terminalizeUnavailableConversationHead(candidate, 'explicit'); return QueueProcessor.terminalizedHeadAttempt(settled); } + // Every ingress signals this one owner after durable admission. Append + // and a fresh turn share Queue progress; the sender kind chooses neither. + let appended = false; + if (!this.retryDeferrals.isDeferred(candidate.id)) { + for (const targetCatId of queueEntryTargetCats(candidate)) { + if ( + heldTargets.has(targetCatId) || + this.automaticAppendSlots.has(QueueProcessor.slotKey(threadId, targetCatId)) || + this.isAutoResumeSuppressed(threadId, targetCatId) + ) + continue; + const result = await this.appendFromDrain({ + threadId, + userId: queueEntryOwnerId(candidate), + entryId: candidate.id, + targetCatId, + }); + if (!result || result.outcome === 'appended') appended = true; + else if (candidate.delivery.authorIntentByTarget?.[targetCatId]?.requested === 'continue_current') { + await this.deps.queue.fallbackQueuedAuthorIntentDurable( + threadId, + queueEntryOwnerId(candidate), + candidate.id, + targetCatId, + 'parent_terminal_before_exposure', + ); + } + } + } + if (appended) return { started: false, progressed: true }; const admission = await this.resolveAdmissionTargets(threadId, candidate, index === 0); if (admission.kind === 'settled') return admission.attempt; if (admission.kind === 'barrier') { @@ -3091,6 +3168,18 @@ export class QueueProcessor { return { started: false }; } + /** A public fallback cannot expand the persisted source's recipient visibility. */ + private async resolveQueuedConversationTargets(entry: QueueEntry): Promise { + const sources = await Promise.all(queueEntryMessageIds(entry).map((id) => this.deps.messageStore.getById(id))); + const targets = await this.deps.router.resolveConversationTargetsAtAdmission( + queueEntryTargetCats(entry), + entry.threadId, + ); + return targets.filter((catId) => + sources.every((source) => !source || canViewMessage(source, { type: 'cat', catId: catId as CatId })), + ); + } + /** * The targets a queued entry would start with now. Only the comparator head settles an entry that * resolves no target (it fails in place); a later one waits, holding its requested targets. @@ -3113,7 +3202,7 @@ export class QueueProcessor { if (entry.kind === 'conversation_input') { const routingClass = requestedTargets.length === 0 ? 'targetless' : 'explicit'; if (routingClass === 'targetless' && this.deps.invocationTracker.has(threadId)) return { kind: 'barrier' }; - const targets = await this.deps.router.resolveConversationTargetsAtAdmission(requestedTargets, threadId); + const targets = await this.resolveQueuedConversationTargets(entry); if (targets.length > 0) { return { kind: 'resolved', @@ -3157,6 +3246,7 @@ export class QueueProcessor { return ( (bypassesExactSuppression || remainingMs === 0) && !this.processingSlots.has(slotKey) && + !this.automaticAppendSlots.has(slotKey) && !this.deps.invocationTracker.has(threadId, catId) ); } @@ -3295,10 +3385,7 @@ export class QueueProcessor { ); return { started: false }; } - resolvedTargetCats = await this.deps.router.resolveConversationTargetsAtAdmission( - queueEntryTargetCats(nextEntry), - threadId, - ); + resolvedTargetCats = await this.resolveQueuedConversationTargets(nextEntry); if (resolvedTargetCats.length === 0) { const terminalized = await this.terminalizeUnavailableConversationHead(nextEntry, routingClass); return { started: false, progressed: terminalized !== null, ...(terminalized ? { entry: terminalized } : {}) }; @@ -4065,6 +4152,15 @@ export class QueueProcessor { const queueEntry = queue.getEntrySnapshot(threadId, userId, queueEntryId) ?? (queueEntryId === entry.id ? entry : null); if (queueEntry) { + for (const sourceId of queueEntryMessageIds(queueEntry)) { + const source = await messageStore.getById(sourceId); + if ( + source && + targetCats.some((catId) => !canViewMessage(source, { type: 'cat', catId: catId as CatId })) + ) { + throw new Error('Queue execution target cannot view its source'); + } + } await this.ensureAttemptMessageCustody(queueEntry); } } diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts index 76eb51bdde1..16d17336d57 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedger.ts @@ -26,6 +26,8 @@ export interface QueueLedgerPayload { content: string; messageId?: string; routingWarnings?: readonly CatRoutingError[]; + /** Caller target selection, distinct from the resolved/pending delivery recipients. */ + requestedTargetCats?: readonly string[]; } export interface QueueLedgerExecution { diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts index a427c8aac32..c4e8e54e0e7 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.ts @@ -13,6 +13,7 @@ export interface QueueLedgerAdmissionInput { kind: QueueLedgerEntry['kind']; from: MessageFrom; targetCatIds: readonly string[]; + requestedTargetCats?: readonly string[]; content: string; messageId?: string; routingWarnings?: readonly CatRoutingError[]; @@ -55,6 +56,7 @@ export function createQueueLedgerAdmission(input: QueueLedgerAdmissionInput): Qu targets: [...input.targetCatIds], payload: { sourceRecordId: input.sourceId, + ...(input.requestedTargetCats ? { requestedTargetCats: [...input.requestedTargetCats] } : {}), content: input.content, ...(input.messageId ? { messageId: input.messageId } : {}), ...(input.routingWarnings?.length ? { routingWarnings: structuredClone(input.routingWarnings) } : {}), diff --git a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts index 78ed0fb1ea2..f76f507bb67 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerValidation.ts @@ -65,6 +65,7 @@ function assertQueuePayload(value: unknown): asserts value is QueueLedgerPayload if (!isRecord(value) || typeof value.sourceRecordId !== 'string' || !value.sourceRecordId) { throw new Error('queue ledger payload identity is incomplete'); } + if (value.requestedTargetCats !== undefined) assertQueueTargets(value.requestedTargetCats); if (typeof value.content !== 'string') throw new Error('queue ledger payload content is invalid'); if (value.messageId !== undefined && typeof value.messageId !== 'string') { throw new Error('queue ledger payload messageId is invalid'); diff --git a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts index c54ad67d444..4f25d539e0e 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -61,7 +61,6 @@ import type { AgentRegistrationFailure } from '../registry/AgentServiceUnavailab import type { RouteExecutionOptions, RouteOptions, RouteStrategyDeps } from '../routing/route-helpers.js'; import { routeParallel } from '../routing/route-parallel.js'; import { routeSerial } from '../routing/route-serial.js'; -import { resolveCatTarget } from './cat-target-resolver.js'; import { appendContextAttachmentsToPrompt } from './context-attachment-prompt.js'; const log = createModuleLogger('agent-router'); @@ -98,7 +97,6 @@ const BARE_URL_PREFIX_BEFORE_MENTION_RE = /(?:^|[^a-z0-9_-])(?:[a-z0-9-]+\.)+[a- function projectAgentRouteIntent(intent: IntentResult): AgentRouteIntent { return { intent: intent.intent, explicit: intent.explicit }; } -const DOMAIN_LIKE_UNKNOWN_HANDLE_RE = /^[a-z0-9_-]+(?:\.[a-z0-9-]+)+$/i; const ASCII_WORD_RE = /[a-z0-9]/i; const QUOTE_SPAN_PAIRS: readonly [string, string][] = [ ['"', '"'], @@ -387,21 +385,11 @@ function matchMentionPatternEnd(message: string, pos: number, pattern: string): return cursor; } -function hasDomainSuffixedMentionPatternAt(message: string, pos: number, patterns: readonly MentionPattern[]): boolean { - return patterns.some((entry) => { - const suffixStart = matchMentionPatternEnd(message, pos, entry.pattern); - if (suffixStart === null) return false; - const suffixNext = message[suffixStart + 1]; - return message[suffixStart] === '.' && suffixNext !== undefined && DOMAIN_SUFFIX_START_RE.test(suffixNext); - }); -} - function recordRouteLineMentions( message: string, patterns: readonly MentionPattern[], seenCats: Set, mentions: ParsedMention[], - routingWarnings: CatRoutingError[], ): void { const excluded = buildMentionExclusionSpans(message); forEachRouteLineMentionCandidate(message, (_line, lineOffset, candidate) => { @@ -411,27 +399,12 @@ function recordRouteLineMentions( const matched = findMentionPatternAt(message, position, patterns, (end) => skipClosingRouteMarkdownMarkers(message, end, openingMarkers), ); - if (matched) recordResolvedMention(matched.catId, position, seenCats, mentions, routingWarnings); + if (matched) recordResolvedMention(matched.catId, position, seenCats, mentions); }); } -function recordResolvedMention( - catId: CatId, - position: number, - seenCats: Set, - mentions: ParsedMention[], - routingWarnings: CatRoutingError[], -): void { +function recordResolvedMention(catId: CatId, position: number, seenCats: Set, mentions: ParsedMention[]): void { const key = catId as string; - const resolved = resolveCatTarget(key); - if ('error' in resolved) { - if (!seenCats.has(key)) { - seenCats.add(key); - routingWarnings.push(resolved.error); - } - return; - } - if (!seenCats.has(key)) { seenCats.add(key); mentions.push({ catId, position }); @@ -444,23 +417,6 @@ function recordResolvedMention( } } -function recordUnknownMentionWarning( - message: string, - position: number, - seenCats: Set, - routingWarnings: CatRoutingError[], -): void { - const handle = message.slice(position + 1).match(/^([a-z0-9_.-]+)/)?.[1]; - if (!handle) return; - if (DOMAIN_LIKE_UNKNOWN_HANDLE_RE.test(handle)) return; - const key = `@unknown:${handle}`; - const resolved = resolveCatTarget(handle); - if ('error' in resolved && !seenCats.has(key)) { - seenCats.add(key); - routingWarnings.push(resolved.error); - } -} - /** * Build mention aliases and speech regex from the current cat configs. * Must be called after catRegistry is populated (not at module load time). @@ -880,17 +836,24 @@ export class AgentRouter { return filtered; } + private filterKnownCats(catIds: Iterable): CatId[] { + const configs = catRegistry.getAllConfigs(); + return [...new Set(catIds)].filter( + (catId): catId is CatId => typeof catId === 'string' && Object.hasOwn(configs, catId), + ); + } + /** * F294: validate an explicit target set without parsing prose or applying fallback routing. - * An unavailable/disabled/unknown member makes the whole set invalid; callers must fail - * closed instead of silently dropping one target or substituting the default cat. + * Validate catalog identity, independently of executable services. Unknown explicit IDs + * invalidate the set; known members retain normal execution/failure settlement. */ async resolveExplicitTargets( requestedCatIds: readonly string[], threadId: string, options?: { persist?: boolean }, ): Promise { - const resolved = this.filterRoutableCats(requestedCatIds); + const resolved = this.filterKnownCats(requestedCatIds); if (resolved.length !== requestedCatIds.length) return []; if (options?.persist && this.threadStore) { await this.threadStore.addParticipants(threadId, resolved); @@ -901,10 +864,9 @@ export class AgentRouter { /** * Canonical target resolution for public conversation work. * - * New no-mention user input calls this before the atomic source + Queue - * admission so its pending target is never an empty client-side guess. - * Queue admission calls the same resolver again to revalidate persisted or - * recovered target intent before a client effect. Still-routable explicit + * Common send resolves before atomic Message + Queue admission. Drain uses + * the same resolver to handle targets that have since become unavailable. + * Still-routable explicit * members are preserved in their original order. If none remain, the most * recent completed lifecycle response is the only history-derived fallback; * non-terminal and non-completed bubbles are deliberately ignored. The @@ -929,6 +891,24 @@ export class AgentRouter { return fallback ? [fallback] : []; } + /** Public send parses prose once, then shares the existing conversation fallback. */ + async resolveSendTargets( + requested: readonly string[], + threadId: string, + content = '', + exact = false, + ): Promise { + if (requested.length > 0 && (await this.resolveExplicitTargets(requested, threadId)).length !== requested.length) { + throw new Error('Invalid explicit member identity'); + } + if (exact) return this.resolveExplicitTargets(requested, threadId); + const targets = + requested.length > 0 + ? requested + : (await this.resolveTargetsAndIntent(content, threadId, { persist: false, allowFallback: false })).targetCats; + return this.resolveConversationTargetsAtAdmission(targets, threadId); + } + /** * F194 Phase Z5 AC-Z16: 无 @ fallback 优先用上一条 user message 的 mentions, * 不让 thread 里其他猫的发言(如 vision guard cross-post)抢路由 fallback。 @@ -1115,28 +1095,21 @@ export class AgentRouter { const seenCats = new Set(); const routing_warnings: CatRoutingError[] = []; // Route-line grammar handles markdown/list wrappers before the broader inline scan. - recordRouteLineMentions(lowerMessage, allPatterns, seenCats, mentions, routing_warnings); + recordRouteLineMentions(lowerMessage, allPatterns, seenCats, mentions); // Explicit @mentions are user-authored route tokens and may appear anywhere in prose. forEachUserMentionCandidate(lowerMessage, (pos) => { const matched = findMentionPatternAt(lowerMessage, pos, allPatterns); if (matched) { - recordResolvedMention(matched.catId, pos, seenCats, mentions, routing_warnings); - return; - } - // P2 (codex review 6949db49): an explicit @handle that matched NO registered cat is an - // unknown handle (e.g. @kimi). Without this, parseAllMentions returns empty mentions + empty - // warnings, so the caller silently falls back to the default cat with zero user feedback. - if (hasDomainSuffixedMentionPatternAt(lowerMessage, pos, allPatterns)) { + recordResolvedMention(matched.catId, pos, seenCats, mentions); return; } - recordUnknownMentionWarning(lowerMessage, pos, seenCats, routing_warnings); }); // Speech aliases like "at 砚砚" stay limited to route-line syntax; otherwise ordinary // prose such as "look at codex docs" would become an implicit route. if (speechRouteMessage !== lowerMessage) { - recordRouteLineMentions(speechRouteMessage, allPatterns, seenCats, mentions, routing_warnings); + recordRouteLineMentions(speechRouteMessage, allPatterns, seenCats, mentions); } mentions.sort((a, b) => a.position - b.position); @@ -1310,24 +1283,9 @@ export class AgentRouter { } } - // Filter out routing_warnings for group mention keywords — they were already - // matched by parseGroupMentions and are not individual cat mentions. - // Only suppress breed handles with ≥1 routable cat (service + available); - // breeds where all cats are unavailable still warn so the user gets feedback. - const groupHandles = new Set(['all', 'thread']); - for (const [catId, config] of Object.entries(catRegistry.getAllConfigs())) { - if (config.breedId && this.isRoutableCat(catId)) { - groupHandles.add(`all-${config.breedId}`); - } - } - const filteredWarnings = individual.routing_warnings.filter((w) => { - if (w.kind !== 'cat_not_found') return true; - return !groupHandles.has(w.mention.toLowerCase()); - }); - return { mentions: [...before, ...groupResult.cats, ...after], - routing_warnings: filteredWarnings, + routing_warnings: [], }; } return this.parseMentions(message); @@ -1575,9 +1533,7 @@ export class AgentRouter { routing_warnings: CatRoutingError[]; }> { const resolvedThreadId = threadId ?? DEFAULT_THREAD_ID; - // Capture both valid mentions AND routing_warnings (for disabled/not-found cats). - // routing_warnings lets callers (e.g. messages.ts) surface explicit feedback when - // a user's @mention silently fell back to a different cat (Thread 1 bug: @kimi→opus). + // Only registered mention patterns select members. Unmatched @ text is ordinary prose. const allMentions = await this.parseAllMentions(message, resolvedThreadId, { allowGroupFallback: options?.allowFallback !== false, }); diff --git a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts index abffd3e4741..908795dbf22 100644 --- a/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/ports/MessageStore.ts @@ -677,7 +677,10 @@ export function canonicalizeAppendMessageInput(input: AppendMessageInput): Canon return canonical; } -export type QueueLedgerAdmissionFactory = (messageId: string) => readonly QueueLedgerEntry[]; +export type QueueLedgerAdmissionFactory = ( + messageId: string, + replayEntries?: readonly QueueLedgerEntry[], +) => readonly QueueLedgerEntry[]; export type QueueLedgerMessageAdmissionResult = | { @@ -748,6 +751,24 @@ export function prepareLifecycleResponseTerminalWithLedgerTargets( : { kind: 'conflict', reason: 'different_terminal', message: structuredClone(current) }; } +export function assertQueueMessageReplay(existing: StoredMessage, incoming: AppendMessageInput): void { + const canonical = canonicalizeAppendMessageInput(incoming); + for (const key of [ + 'from', + 'content', + 'mentions', + 'contentBlocks', + 'visibility', + 'whisperTo', + 'replyTo', + 'source', + ] as const) { + if (!isDeepStrictEqual(existing[key], canonical[key])) { + throw new Error(`Queue admission identity conflict for replayed message ${existing.id}`); + } + } +} + export function prepareQueueLedgerMessageAdmission( message: AppendMessageInput, messageId: string, @@ -2288,8 +2309,15 @@ export class MessageStore { const threadId = msg.threadId ?? DEFAULT_THREAD_ID; const existing = msg.idempotencyKey ? this.getByIdempotencyKey(msg.userId, threadId, msg.idempotencyKey) : null; if (existing) { - const expected = [...buildAdmission(existing.id)]; - const persisted = expected.map((entry) => ledgerStore.getNow(entry.threadId, entry.id)); + assertQueueMessageReplay(existing, msg); + const identities = [...buildAdmission(existing.id)]; + const persisted = identities.map((entry) => ledgerStore.getNow(entry.threadId, entry.id)); + const expected = [ + ...buildAdmission( + existing.id, + persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + ), + ]; if ( persisted.some((entry) => entry === null) || !persisted.every((entry, index) => queueLedgerAdmissionsMatch(entry!, expected[index]!)) diff --git a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts index 44790a921df..d4a0d594521 100644 --- a/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts +++ b/packages/api/src/domains/cats/services/stores/redis/RedisMessageStore.ts @@ -78,6 +78,7 @@ import { advanceLifecycleInputDispatchMetadata, applyStreamMetadataAugment, assertPrivateNoticeQueueAdmission, + assertQueueMessageReplay, assertValidStoredMessageTimestamp, assignLifecycleDispatchTargetsMetadata, COORDINATION_TERMINAL_SCAN_PAGE_SIZE, @@ -1240,8 +1241,15 @@ export class RedisMessageStore { if (!result.replayed) { return { outcome: 'enqueued', message: result.message, entries, deduped: false }; } - const replayEntries = [...buildAdmission(result.message.id)]; - const persisted = await Promise.all(replayEntries.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + assertQueueMessageReplay(result.message, input); + const identities = [...buildAdmission(result.message.id)]; + const persisted = await Promise.all(identities.map((entry) => ledgerStore.get(entry.threadId, entry.id))); + const replayEntries = [ + ...buildAdmission( + result.message.id, + persisted.filter((entry): entry is QueueLedgerEntry => entry !== null), + ), + ]; if ( persisted.some((entry) => entry === null) || !persisted.every((entry, index) => queueLedgerAdmissionsMatch(entry!, replayEntries[index]!)) diff --git a/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts b/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts index 1383ab21124..5ae94f2f938 100644 --- a/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts +++ b/packages/api/src/domains/plugin/builtin-runtime/collective-ingress-dispatcher.ts @@ -15,7 +15,6 @@ import { } from '@cat-cafe/shared'; import type { InvocationQueue } from '../../cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../../cats/services/agents/invocation/QueueProcessor.js'; import type { IMessageStore, StoredMessage } from '../../cats/services/stores/ports/MessageStore.js'; import { routeCollectiveChannelEvent } from './collective-channel-ingress.js'; import { @@ -59,8 +58,7 @@ export interface CollectiveIngressDispatcherOptions { get(threadId: string): CollectiveIngressThread | null | Promise; }; readonly messageStore: IMessageStore; - readonly invocationQueue: Pick; - readonly queueProcessor: Pick; + readonly invocationQueue: Pick; readonly socketManager: { broadcastToRoom(room: string, event: string, data: unknown): void; emitToUser?(userId: string, event: string, data: unknown): void; @@ -252,7 +250,7 @@ export class CollectiveIngressDispatcher { } const source = collectiveSource(event, sourceIdentity); const from = collectiveMessageFrom(event); - const stored = await this.options.invocationQueue.appendAndEnqueueDurable( + const stored = await this.options.invocationQueue.send( this.options.messageStore, { threadId, @@ -279,25 +277,22 @@ export class CollectiveIngressDispatcher { targetCats: [catId], intent: 'execute', suggestedSkill: 'collective-participation', + onQueueEntriesAdmitted: async (_entries, message) => { + if (event.workRequest === 'entrust' && message) + await this.options.admitStandingWork?.(message, catId as CatId); + }, }, ); if (stored.outcome === 'full') throw ingressError('ROUTE_QUEUE_FULL', 'Configured Cat queue is full'); - { - if (event.workRequest === 'entrust') await this.options.admitStandingWork?.(stored.message, catId as CatId); - if (!stored.deduped) { - this.options.socketManager.emitToUser?.(route.localOwnerUserId, 'messages_queued', { - threadId, - messageIds: [stored.message.id], - messages: [stored.message], - }); - } - try { - await this.options.queueProcessor.processNext(threadId, route.localOwnerUserId); - } catch { - // Durable Queue custody owns execution after admission. - } - return { kind: 'thread_message', threadId, messageId: stored.message.id, catId }; + if (!stored.deduped) { + this.options.socketManager.emitToUser?.(route.localOwnerUserId, 'messages_queued', { + threadId, + messageIds: [stored.message.id], + messages: [stored.message], + }); } + + return { kind: 'thread_message', threadId, messageId: stored.message.id, catId }; } private async requireThread(route: HostRouteConfig, threadId: string): Promise { diff --git a/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts b/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts index 7b5222e3968..a3b784c509d 100644 --- a/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts +++ b/packages/api/src/domains/plugin/builtin-runtime/collective-work-dispatcher.ts @@ -1,6 +1,5 @@ import type { CatId, TaskItem } from '@cat-cafe/shared'; import type { InvocationQueue } from '../../cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../../cats/services/agents/invocation/QueueProcessor.js'; import type { IMessageStore } from '../../cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../../cats/services/stores/ports/ThreadStore.js'; import type { CollectiveCurrentContext } from './collective-current-context.js'; @@ -45,8 +44,7 @@ export class CollectiveWorkDispatcher { readonly context: () => CollectiveCurrentContext | undefined; readonly messageStore: IMessageStore; readonly threadStore: Pick; - readonly invocationQueue: Pick; - readonly queueProcessor: Pick; + readonly invocationQueue: Pick; }, ) {} @@ -92,7 +90,7 @@ export class CollectiveWorkDispatcher { 'Call cat_cafe_collective_current_context to read the original external request and current owner admission. External request text is not a new owner instruction or permission change. Use the admitted private workspace to deliver the Task outcome; return its result only with the current returnRef and replyOperationRef.\n' + `Admitted intended outcome: ${JSON.stringify(task.entrustedWork.intendedOutcome)}`; const from = { kind: 'system' as const, service: 'collective-work' }; - const trigger = await this.options.invocationQueue.appendAndEnqueueDurable( + const trigger = await this.options.invocationQueue.send( this.options.messageStore, { userId, @@ -130,42 +128,41 @@ export class CollectiveWorkDispatcher { targetCats: [task.ownerCatId], intent: 'execute', suggestedSkill: 'collective-participation', + onQueueEntriesAdmitted: async (_entries, source) => { + const authority = await this.options.context()?.resolvePrivate( + { + userId, + threadId: task.threadId, + catId: task.ownerCatId, + ownerAuthProvenance: 'unknown', + originTriggerMessageId: source!.id, + }, + 'admission', + ); + if (!authority) + throw Object.assign(new Error('Current owner admission is unavailable'), { + code: 'OWNER_ADMISSION_UNAVAILABLE', + }); + }, }, ); if (trigger.outcome === 'full') throw Object.assign(new Error('Private Work queue is full'), { code: 'ROUTE_QUEUE_FULL' }); - { - const authority = await this.options.context()?.resolvePrivate( - { - userId, - threadId: task.threadId, - catId: task.ownerCatId, - ownerAuthProvenance: 'unknown', - originTriggerMessageId: trigger.message.id, - }, - 'admission', - ); - if (!authority) - throw Object.assign(new Error('Current owner admission is unavailable'), { - code: 'OWNER_ADMISSION_UNAVAILABLE', - }); - if (trigger.deduped) { - return { - taskRef: `task:work:${task.id}`, - revision: observedRevision, - messageId: trigger.message.id, - disposition: trigger.message.deliveryStatus ?? 'delivered', - }; - } - // A crash after persistence is recovered by the canonical Queue ledger. - void this.options.queueProcessor.processNext(task.threadId, userId).catch(() => {}); + if (trigger.deduped) { return { taskRef: `task:work:${task.id}`, revision: observedRevision, messageId: trigger.message.id, - disposition: 'queued' as const, + disposition: trigger.message.deliveryStatus ?? 'delivered', }; } + // A crash after persistence is recovered by the canonical Queue ledger. + return { + taskRef: `task:work:${task.id}`, + revision: observedRevision, + messageId: trigger.message.id, + disposition: 'queued' as const, + }; } } diff --git a/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts b/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts index 2daeaf04963..23c21281b93 100644 --- a/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts +++ b/packages/api/src/domains/signal-intake/ThreadMeetingArtifactDispatcher.ts @@ -7,7 +7,6 @@ import { writeOpportunityPresentationRetryCarrierV1Schema, } from '@cat-cafe/shared'; import type { InvocationQueue } from '../cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../cats/services/agents/invocation/QueueProcessor.js'; import { messageFrom } from '../cats/services/stores/message-from.js'; import type { IMessageStore } from '../cats/services/stores/ports/MessageStore.js'; import { buildAsrPersonMemoryDynamicScenes } from './AsrPersonMemorySceneBuilder.js'; @@ -20,8 +19,7 @@ import { parsePrivateThreadHandle } from './ThreadDestinationAuthority.js'; export interface ThreadMeetingArtifactDispatcherOptions { readonly threadStore: MeetingThreadStore; readonly messageStore: IMessageStore; - readonly invocationQueue: Pick; - readonly queueProcessor: Pick; + readonly invocationQueue: Pick; readonly socketManager: { emitToUser(userId: string, event: string, data: unknown): void; }; @@ -212,7 +210,7 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche targetCats: [catId], intent: 'execute', }; - const enqueue = await this.options.invocationQueue.appendAndEnqueueDurable( + const enqueue = await this.options.invocationQueue.send( this.options.messageStore, { from, @@ -248,18 +246,12 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche throw Object.assign(new Error('meeting destination queue is full'), { code: 'ROUTE_UNAVAILABLE' }); } const sourceMessageId = enqueue.message.id; - let started = false; - try { - started = (await this.options.queueProcessor.processNext(input.threadId, input.intake.ownerId)).started; - } catch { - // Durable queue custody owns later execution; admission is already complete. - } return { queueEntryId: enqueue.entry.id, sourceMessageId, targetCatId: catId, deduped: enqueue.deduped === true, - started, + started: Boolean(enqueue.message.lifecycle?.dispatchRefs?.some((ref) => ref.targetId === catId)), }; } @@ -390,7 +382,7 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche intent: 'execute', sourceCategory: 'scheduled' as const, }; - const enqueue = await this.options.invocationQueue.appendAndEnqueueDurable( + const enqueue = await this.options.invocationQueue.send( this.options.messageStore, { from, @@ -424,11 +416,6 @@ export class ThreadMeetingArtifactDispatcher implements MeetingArtifactDispatche code: 'ROUTE_UNAVAILABLE', }); } - try { - await this.options.queueProcessor.processNext(threadId, input.intake.ownerId); - } catch { - // Durable queue custody owns later execution; retry admission is already complete. - } return { sourceMessageId: source.id, triggerMessageId, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3e62f64c98d..c5f5f9b8546 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -715,6 +715,7 @@ async function main(): Promise { // Create invocation tracker for cancellation support const invocationTracker = new InvocationTracker(); + let router!: AgentRouter; // Initialize WebSocket manager BEFORE routes (injected via opts, no circular import). // IMPORTANT: Socket.io must attach to the SAME server Fastify listens on. @@ -1000,6 +1001,23 @@ async function main(): Promise { const invocationQueue = new InvocationQueue( redis ? new RedisQueueLedgerStore(redis) : new InMemoryQueueLedgerStore(), + { + projectRoot: resolveActiveProjectRoot(), + invocationTracker, + resolveCarrierCapability: (catId) => router?.freshnessCarrierCapability(catId), + resolveTargets: (requested, threadId, content, exact) => + router.resolveSendTargets(requested, threadId, content, exact), + onAdmitted: ({ threadId, message, entries }) => { + if (message && entries.some((entry) => entry.sourceCategory !== 'a2a_failure')) + queueProcessor.registerCallerDispatchInitialTargets( + message, + entries.flatMap((entry) => entry.targets), + ); + void queueProcessor + .requestDrain(threadId) + .catch((err) => app.log.error({ err, threadId }, 'send drain failed')); + }, + }, ); await invocationQueue.hydrateFromLedger(messageStore); const invocationRecordStore = createInvocationRecordStore(redis); @@ -2169,7 +2187,6 @@ async function main(): Promise { // ── F32-b: AgentRegistry (catId → AgentService) — one instance per cat ── // Each cat gets its own AgentService instance with its catId + model. const agentRegistry = new AgentRegistry(); - let router!: AgentRouter; const syncAgentRegistry = async (configs: Record) => { agentRegistry.reset(); clearL0Cache(); // Invalidate stale L0 compilations from previous sync @@ -3217,7 +3234,6 @@ async function main(): Promise { // Register routes (socketManager injected, no circular import) const messagesOpts = { - projectRoot: resolveActiveProjectRoot(), registry, messageStore, socketManager, @@ -3226,12 +3242,9 @@ async function main(): Promise { ...(sessionStore ? { sessionStore } : {}), threadStore, invocationTracker, - invocationRecordStore, - turnExecutionStore, summaryStore, draftStore, invocationQueue, - queueProcessor, sessionContinuationCoordinator, ...(f101GameStore ? { gameStore: f101GameStore } : {}), ...(f101SharedDriver ? { autoPlayer: f101SharedDriver } : {}), @@ -5304,7 +5317,6 @@ async function main(): Promise { socketManager, router, invocationQueue, - queueProcessor, onProposalReject: (input) => onProposalReject({ ...input, proposalType: 'thread' }), }); // F231 Phase C: profile-update approve/reject (user-auth; locked critical section in service) @@ -5409,7 +5421,6 @@ async function main(): Promise { messageStore, threadStore, invocationQueue, - queueProcessor, }); const { CollectiveWorkAdmission } = await import( './domains/plugin/builtin-runtime/collective-work/collective-work-admission.js' @@ -5506,7 +5517,6 @@ async function main(): Promise { threadStore, messageStore, invocationQueue, - queueProcessor, socketManager: { broadcastToRoom: (room, event, data) => socketManager?.broadcastToRoom(room, event, data), emitToUser: (userId, event, data) => socketManager?.emitToUser(userId, event, data), @@ -5800,7 +5810,7 @@ async function main(): Promise { work: collectiveWorkAuthority, dispatcher: collectiveWorkDispatcher, queue: invocationQueue, - reconsideration: { queue: invocationQueue, processor: queueProcessor }, + reconsideration: { queue: invocationQueue }, }); const { registerCollectiveWorkResultRoutes } = await import('./routes/collective-work-result-routes.js'); registerCollectiveWorkResultRoutes(app, { @@ -6145,7 +6155,6 @@ async function main(): Promise { threadStore, messageStore, invocationQueue, - queueProcessor, socketManager, supportsPresentationRetry: (catId) => supportsWriteOpportunityPresentationCapability(router.contextCapability(catId)), @@ -7649,7 +7658,7 @@ async function main(): Promise { const admitManagedWake: ManagedCommandWakeRecoveryDeps['admitWake'] = async (input) => { // INV-I1: Message and Queue row in one transaction. The lease was already verified against // this exact envelope, so nothing admitted here can belong to a generation that has moved on. - const admitted = await invocationQueue.appendAndEnqueueDurable(messageStore, input.message, { + const admitted = await invocationQueue.send(messageStore, input.message, { threadId: input.threadId, userId: input.userId, sourceId: input.message.idempotencyKey, diff --git a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts index 1c589bdfb80..d477c9df29b 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorRouter.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorRouter.ts @@ -298,13 +298,13 @@ export class ConnectorRouter { icon: connectorSourceIcon(def2), }; const mentionPatterns = this.getMentionPatterns(); - const { targetCatId } = parseMentions(fwdText, mentionPatterns, this.getDefaultCatId()); + const mention = parseMentions(fwdText, mentionPatterns, this.getDefaultCatId()); const fwdStored = await deliverConnectorMessage( { delivery: this.opts.persistedQueueDelivery }, { threadId: fwdThreadId, userId: this.opts.defaultUserId, - catId: targetCatId, + ...(mention.matched ? { catId: mention.targetCatId } : {}), content: fwdText, source: fwdSource, idempotencyKey: `im:${connectorId}:${externalChatId}:${externalMessageId}:thread`, @@ -422,16 +422,6 @@ export class ConnectorRouter { // Parse @-mentions to determine target cat const mentionPatterns = this.getMentionPatterns(); const mentionResult = parseMentions(resolvedText, mentionPatterns, this.getDefaultCatId()); - let targetCatId = mentionResult.targetCatId; - if (!mentionResult.matched && this.opts.threadStore.getParticipantsWithActivity) { - const participants = await this.opts.threadStore.getParticipantsWithActivity(binding.threadId); - const lastActive = participants - .filter((p) => p.messageCount > 0) - .sort((a, b) => b.lastMessageAt - a.lastMessageAt)[0]; - if (lastActive) { - targetCatId = lastActive.catId as CatId; - } - } // 4. RFC §5.1/§5.2: an IM input is the same `conversation_input` envelope a user send builds. // One atomic Message + Queue admission makes it durable; Queue drain owes the owner wake. @@ -440,7 +430,7 @@ export class ConnectorRouter { { threadId: binding.threadId, userId: this.opts.defaultUserId, - catId: targetCatId, + ...(mentionResult.matched ? { catId: mentionResult.targetCatId } : {}), content: resolvedText, source, idempotencyKey: `im:${connectorId}:${externalChatId}:${externalMessageId}`, diff --git a/packages/api/src/infrastructure/email/deliver-connector-message.ts b/packages/api/src/infrastructure/email/deliver-connector-message.ts index 0a7d6684219..0ed4339e5d7 100644 --- a/packages/api/src/infrastructure/email/deliver-connector-message.ts +++ b/packages/api/src/infrastructure/email/deliver-connector-message.ts @@ -14,7 +14,7 @@ export interface ConnectorDeliveryDeps { export interface ConnectorDeliveryInput { readonly threadId: string; readonly userId: string; - readonly catId: string; + readonly catId?: string; readonly content: string; readonly source: ConnectorSource; /** Stable Queue admission identity; replaying the same key is idempotent, never a second input. */ @@ -62,7 +62,7 @@ export async function deliverConnectorMessage( const result = await deps.delivery.deliver({ ownerUserId: input.userId, threadId: input.threadId, - targetCatId: input.catId as CatId, + ...(input.catId ? { targetCatId: input.catId as CatId } : {}), idempotencyKey: input.idempotencyKey, content: input.content, source: input.source, diff --git a/packages/api/src/routes/callback-a2a-trigger.ts b/packages/api/src/routes/callback-a2a-trigger.ts index 05932dc3e96..95568ab3a57 100644 --- a/packages/api/src/routes/callback-a2a-trigger.ts +++ b/packages/api/src/routes/callback-a2a-trigger.ts @@ -53,14 +53,7 @@ interface A2ATriggerLogger { } export interface QueueProcessorLike { - requestDrain?(threadId: string): Promise; registerCallerDispatchInitialTargets?(source: StoredMessage, targetIds: readonly string[]): void; - tryAutoAppendExactEntry?(input: { - threadId: string; - userId: string; - entryId: string; - targetCatId?: string; - }): Promise<{ outcome: 'appended' | 'rejected' }>; /** F216 c3 supersede: releaseSlot force-frees the per-slot processingSlots * mutex so the next drain sees a free slot. */ releaseSlot?(threadId: string, catId: string): void; @@ -80,7 +73,7 @@ export interface A2ATriggerDeps { invocationQueue?: Pick< InvocationQueue, | 'enqueueExistingMessageDurable' - | 'appendAndEnqueueDurable' + | 'send' | 'terminalizeResponseAndEnqueueDurable' | 'countAgentEntriesForThread' | 'getEntrySnapshot' @@ -110,6 +103,7 @@ export async function appendA2ASourceWithLedgerAdmission( message: AppendMessageInput, options: { plan: A2AFanoutAdmissionPlan; + onQueueEntriesAdmitted?: (entries: readonly QueueEntry[]) => void; ownerAuthProvenance: OwnerAuthProvenance; parentInvocationId?: string; callerTraceContext?: CallerTraceContext; @@ -128,11 +122,12 @@ export async function appendA2ASourceWithLedgerAdmission( } if (!deps.invocationQueue) throw new Error('A2A source admission requires InvocationQueue'); if (message.from.kind !== 'agent') throw new Error('A2A source admission requires Agent speech'); - const result = await deps.invocationQueue.appendAndEnqueueDurable(deps.messageStore, message, { + const result = await deps.invocationQueue.send(deps.messageStore, message, { from: message.from, threadId: message.threadId ?? 'default', userId: message.userId, kind: 'message_wake', + ...(options.onQueueEntriesAdmitted ? { onQueueEntriesAdmitted: options.onQueueEntriesAdmitted } : {}), ownerAuthProvenance: normalizeOwnerAuthProvenance(options.ownerAuthProvenance), content: message.content, sourceCategory: 'a2a', @@ -563,9 +558,7 @@ export async function enqueueA2ATargets( preAdmittedReplayed?: boolean; }, ): Promise<{ enqueued: CatId[]; coalesced?: CatId[] }> { - if (!deps.invocationQueue || !deps.queueProcessor?.requestDrain) { - throw new Error('A2A dispatch requires InvocationQueue and QueueProcessor'); - } + if (!deps.invocationQueue) throw new Error('A2A dispatch requires the shared Queue admission port'); const { log } = deps; const { threadId, callerCatId } = opts; const ownerAuthProvenance = normalizeOwnerAuthProvenance(opts.ownerAuthProvenance); @@ -743,6 +736,14 @@ export async function enqueueA2ATargets( targetCats: pendingAcceptedTargetCats, intent: 'execute', autoExecute: true, + onQueueEntriesAdmitted: (entries) => { + if (entries[0]?.sourceCategory !== 'a2a_failure') + deps.queueProcessor?.registerCallerDispatchInitialTargets?.( + persistedQueueTrigger, + pendingAcceptedTargetCats, + ); + opts.onQueueEntriesAdmitted?.(entries); + }, a2aParentInvocationId: opts.parentInvocationId, callerTraceContext: ensureDispatchTraceContext(), a2aTriggerMessageId: triggerMessageId, @@ -759,8 +760,8 @@ export async function enqueueA2ATargets( if (result.outcome === 'enqueued') { // A failure carrier is an exact scheduling reference to an existing // response, not a new business dispatch authored by the reporter. - if (result.entry?.sourceCategory !== 'a2a_failure') { - deps.queueProcessor.registerCallerDispatchInitialTargets?.(persistedQueueTrigger, pendingAcceptedTargetCats); + if (preAdmittedEntry && result.entry?.sourceCategory !== 'a2a_failure') { + deps.queueProcessor?.registerCallerDispatchInitialTargets?.(persistedQueueTrigger, pendingAcceptedTargetCats); } if (result.deduped) { coalesced.push(...pendingAcceptedTargetCats); @@ -771,19 +772,7 @@ export async function enqueueA2ATargets( } } - opts.onQueueEntriesAdmitted?.(acceptedEntries); - if (deps.queueProcessor.tryAutoAppendExactEntry) { - for (const entry of acceptedEntries) { - for (const targetCatId of entry.targets) { - await deps.queueProcessor.tryAutoAppendExactEntry({ - threadId, - userId: opts.userId, - entryId: entry.id, - targetCatId, - }); - } - } - } + if (preAdmittedEntry) opts.onQueueEntriesAdmitted?.(acceptedEntries); if (enqueued.length > 0) { await emitQueueUpdated( deps.socketManager, @@ -797,7 +786,7 @@ export async function enqueueA2ATargets( { threadId, triggerMessageId, callerCatId, targetCats, queueDiagnostics, enqueued }, '[DIAG/a2a] enqueueA2ATargets single-ledger admission', ); - await deps.queueProcessor.requestDrain(threadId); + return { enqueued, ...(coalesced.length > 0 ? { coalesced } : {}), diff --git a/packages/api/src/routes/callback-multi-mention-routes.ts b/packages/api/src/routes/callback-multi-mention-routes.ts index e7d237a8512..d027f4aa0fd 100644 --- a/packages/api/src/routes/callback-multi-mention-routes.ts +++ b/packages/api/src/routes/callback-multi-mention-routes.ts @@ -242,7 +242,7 @@ export interface MultiMentionRouteDeps { Pick; /** F122B B6: QueueProcessor for execution + response hook */ queueProcessor?: { - requestDrain?(threadId: string): Promise; + registerCallerDispatchInitialTargets?(source: StoredMessage, targetIds: readonly string[]): void; registerEntryCompleteHook?( entryId: string, hook: ( @@ -440,6 +440,22 @@ async function dispatchViaQueue( sourceInput, { plan: preplannedAdmission, + onQueueEntriesAdmitted: (entries) => { + for (const entry of entries) { + for (const catId of queueEntryTargetCats(entry)) { + registerMultiMentionCompletionHook({ + deps, + queueProcessor, + entryId: entry.id, + requestId, + catId: catId as CatId, + threadId, + userId, + log, + }); + } + } + }, ownerAuthProvenance, parentInvocationId, ...(actionFence ? { actionSuccessorFence: actionFence } : {}), @@ -475,22 +491,6 @@ async function dispatchViaQueue( ...(cloudDispatchProvenance ? { cloudDispatchProvenance } : {}), requiresExactCloudDispatchProvenance: true, ...(actionFence ? { actionSuccessorFence: actionFence } : {}), - onQueueEntriesAdmitted: (entries) => { - for (const entry of entries) { - for (const catId of queueEntryTargetCats(entry)) { - registerMultiMentionCompletionHook({ - deps, - queueProcessor, - entryId: entry.id, - requestId, - catId: catId as CatId, - threadId, - userId, - log, - }); - } - } - }, }, ); const admitted = [...result.enqueued, ...(result.coalesced ?? [])]; @@ -769,7 +769,7 @@ export function registerMultiMentionRoutes(app: FastifyInstance, deps: MultiMent } } - if (!deps.invocationQueue || !deps.queueProcessor?.requestDrain) { + if (!deps.invocationQueue || !deps.queueProcessor) { return reply.code(503).send({ error: 'Multi-mention dispatch requires InvocationQueue and QueueProcessor' }); } diff --git a/packages/api/src/routes/callbacks.ts b/packages/api/src/routes/callbacks.ts index 164e7dd16d8..fc31d8465ed 100644 --- a/packages/api/src/routes/callbacks.ts +++ b/packages/api/src/routes/callbacks.ts @@ -947,7 +947,7 @@ export interface CallbackRoutesOptions { invocationId: string | undefined, completedCatIds: readonly string[], ): Promise; - requestDrain(threadId: string): Promise; + registerCallerDispatchInitialTargets?(source: StoredMessage, targetIds: readonly string[]): void; registerEntryCompleteHook( entryId: string, hook: ( @@ -1850,7 +1850,7 @@ export const callbacksRoutes: FastifyPluginAsync = async } : undefined; if (a2aAdmissionOptions) { - if (!opts.invocationQueue || !queueProcessor?.requestDrain) { + if (!opts.invocationQueue || !queueProcessor) { reply.status(503); return { kind: 'a2a_admission_unavailable', @@ -3343,7 +3343,7 @@ export const callbacksRoutes: FastifyPluginAsync = async } : undefined; if (a2aAdmissionOptions) { - if (!opts.invocationQueue || !queueProcessor?.requestDrain) { + if (!opts.invocationQueue || !queueProcessor) { await reconcileActionSuccessorEnqueue({ service: opts.actionSuccessorAdmissionService, fence: actionFence, @@ -6107,9 +6107,7 @@ export const callbacksRoutes: FastifyPluginAsync = async const notificationContent = buildVoteNotification(question, options); const mentionCatIds = resolvedVoters; const notificationTimestamp = Date.now(); - const canDispatchVote = Boolean( - router && invocationRecordStore && opts.invocationQueue && queueProcessor?.requestDrain, - ); + const canDispatchVote = Boolean(router && invocationRecordStore && opts.invocationQueue && queueProcessor); const voteAdmissionPlan = canDispatchVote ? planA2AFanoutAdmission( { invocationQueue: opts.invocationQueue }, diff --git a/packages/api/src/routes/collective-owner-work-reconsideration.ts b/packages/api/src/routes/collective-owner-work-reconsideration.ts index f3baa2cd0ef..f2b24f05744 100644 --- a/packages/api/src/routes/collective-owner-work-reconsideration.ts +++ b/packages/api/src/routes/collective-owner-work-reconsideration.ts @@ -4,7 +4,6 @@ import { collectiveSourceIdentitySchema, createCatId } from '@cat-cafe/shared'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import { queueEntryId } from '../domains/cats/services/agents/invocation/queue-ledger/QueueLedger.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; @@ -13,8 +12,7 @@ import { sendCollectiveOwnerError } from './collective-owner-errors.js'; import { pluginAccessError, requirePluginOwnerLocalAccess } from './plugin-access-guards.js'; export interface CollectiveWorkReconsiderationRuntime { - readonly queue: Pick; - readonly processor: Pick; + readonly queue: Pick; } interface ReconsiderationOptions { readonly connector: () => CollectiveConnector | undefined; @@ -64,7 +62,7 @@ export function registerCollectiveOwnerWorkReconsiderationRoutes( if (existing) return recoverWake(existing, scope, runtime, options.messages, access.operator); const source = collectiveSource(scope.event, scope.source); const from = { kind: 'external' as const, connectorId: 'collective', sender: collectiveSender(scope.event) }; - const stored = await runtime.queue.appendAndEnqueueDurable( + const stored = await runtime.queue.send( options.messages, { userId: access.operator, @@ -109,8 +107,6 @@ export function registerCollectiveOwnerWorkReconsiderationRoutes( return response(scope, stored.message, 'queued'); }, ); - if (prepared.disposition !== 'already_classified') - void runtime.processor.processNext(prepared.threadId, access.operator).catch(() => {}); return prepared; } catch (error) { return sendCollectiveOwnerError( diff --git a/packages/api/src/routes/message-disposition-admission.ts b/packages/api/src/routes/message-disposition-admission.ts index 785e077c079..8878142521b 100644 --- a/packages/api/src/routes/message-disposition-admission.ts +++ b/packages/api/src/routes/message-disposition-admission.ts @@ -1,4 +1,4 @@ -/** F264: resolve scoped author preference and bind current-work intent to an exact live parent. */ +/** F264: bind an explicit single-message choice to its exact live parent. Defaults are snapshotted by common Queue admission. */ import { type CatId, @@ -45,6 +45,23 @@ export function resolveMessageDispositionForAdmission(input: { return resolveMessageDispositionPreference(input.projectRoot, input.threadId).effective; } +export interface QueueAdmissionPolicyContext { + projectRoot?: string; + resolveTargets?: ( + requested: readonly string[], + threadId: string, + content?: string, + exact?: boolean, + ) => Promise; + onAdmitted?: (admission: { + threadId: string; + entries: readonly import('../domains/cats/services/agents/invocation/InvocationQueue.js').QueueEntry[]; + message?: import('../domains/cats/services/stores/ports/MessageStore.js').StoredMessage; + }) => void; + invocationTracker?: ExactParentTracker; + resolveCarrierCapability?: (catId: CatId) => FreshnessCarrierCapability | undefined; +} + export function resolveQueueAuthorIntentByCatId(input: { targetCats: readonly CatId[]; requested: MessageWorkDisposition; diff --git a/packages/api/src/routes/messages.ts b/packages/api/src/routes/messages.ts index 94f0ea7e928..6107b380fbe 100644 --- a/packages/api/src/routes/messages.ts +++ b/packages/api/src/routes/messages.ts @@ -33,7 +33,6 @@ import { import type { InvocationRegistry } from '../domains/cats/services/agents/invocation/InvocationRegistry.js'; import type { InvocationTracker } from '../domains/cats/services/agents/invocation/InvocationTracker.js'; import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import { resetStreak } from '../domains/cats/services/agents/routing/WorklistRegistry.js'; import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; import { @@ -48,11 +47,9 @@ import type { AgentRouter } from '../domains/cats/services/index.js'; import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IDraftStore } from '../domains/cats/services/stores/ports/DraftStore.js'; import type { IGameStore } from '../domains/cats/services/stores/ports/GameStore.js'; -import type { IInvocationRecordStore } from '../domains/cats/services/stores/ports/InvocationRecordStore.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; import { isTimelinePublished } from '../domains/cats/services/stores/ports/MessageStore.js'; import { deriveAutoThreadTitle, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; -import type { ITurnExecutionStore } from '../domains/cats/services/stores/ports/TurnExecutionStore.js'; import { getTimelineOrderTime, isInternalNonQuotableParent, @@ -83,11 +80,6 @@ import { buildGameSeats, parseGameCommand, sanitizeCatIds } from './game-command import type { HoldBallCancelDeps } from './hold-ball-cancel.js'; import { cancelPendingHoldsForThread } from './hold-ball-cancel.js'; import { formatHoldOwnerName, persistHoldTerminalVisibility } from './hold-ball-terminal-visibility.js'; -import { - resolveFreshnessCarrierCapabilityOrUndeclared, - resolveMessageDispositionForAdmission, - resolveQueueAuthorIntentByCatId, -} from './message-disposition-admission.js'; import { buildMessageContentBlocks, type SendMessageInput, sendMessageSchema } from './messages.schema.js'; import { parseMultipart } from './parse-multipart.js'; @@ -136,8 +128,6 @@ function bundleAdmissionErrorMessage(reason: MessageBundleAdmissionFailureReason * socketManager is injected to avoid circular import from index.ts. */ export interface MessagesRoutesOptions { - /** Shared owner-preference root. Optional test harnesses retain product-default behavior. */ - projectRoot?: string; registry: InvocationRegistry; messageStore: IMessageStore; socketManager: SocketManager; @@ -145,16 +135,11 @@ export interface MessagesRoutesOptions { threadStore?: IThreadStore; uploadDir?: string; invocationTracker?: InvocationTracker; - invocationRecordStore?: IInvocationRecordStore; - /** Not read by these routes; kept so existing callers still type-check. */ - turnExecutionStore?: Pick; /** #80: Streaming draft store for F5 recovery */ draftStore?: IDraftStore; /** Canonical durable ingress for every normal user message. */ invocationQueue?: InvocationQueue; - /** Single event-driven admission and execution coordinator. */ - queueProcessor?: QueueProcessor; /** F101: Game store for /game command interception */ gameStore?: IGameStore; /** F101: Injectable auto-player for lifecycle-safe teardown in tests/routes */ @@ -424,23 +409,15 @@ export const messagesRoutes: FastifyPluginAsync = async ( // Default to 'default' thread for lobby (prevents global broadcast) const resolvedThreadId = threadId ?? 'default'; - if (!opts.invocationQueue || !opts.queueProcessor) { + if (!opts.invocationQueue) { return reply.code(503).send({ error: 'Message delivery is unavailable', code: 'MESSAGE_DELIVERY_UNAVAILABLE' }); } - // A retried no-mention send must retain the target that won the original - // atomic admission even if a newer reply has since changed the fallback. - // Queue remains the sole owner of this immutable admission fact; History is - // consulted only to locate the exact source record named by the idempotency key. + // Live owns an exact session receipt; ordinary retries are handled by shared send. const receiptOwner = { userId, threadId: resolvedThreadId }; const replayedSource = idempotencyKey && opts.invocationQueue ? await opts.messageStore.getByIdempotencyKey(userId, resolvedThreadId, idempotencyKey) : null; - const replayedQueueEntry = replayedSource - ? opts.invocationQueue - ?.list(resolvedThreadId, userId) - .find((entry) => entry.payload.sourceRecordId === replayedSource.id) - : undefined; let admittedMessageBundle: ResolvedBundleAdmission | undefined; let explicitBundleTargetCats: CatId[] | undefined; @@ -669,7 +646,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( : undefined; if (explicitMentionTargetCats && explicitRoutingTargetCats?.length !== explicitMentionTargetCats.length) { reply.status(400); - return { error: 'One or more selected members are unavailable', code: 'INVALID_EXPLICIT_TARGETS' }; + return { error: 'One or more selected member identities are invalid', code: 'INVALID_EXPLICIT_TARGETS' }; } const structuredTargetCats = admittedMessageBundle ? bundleRoutingTargetCats : explicitRoutingTargetCats; const routingResult: Awaited> = structuredTargetCats @@ -683,29 +660,17 @@ export const messagesRoutes: FastifyPluginAsync = async ( persist: true, allowFallback: false, }); - const { targetCats: resolvedTargetCats, intent, routing_warnings } = routingResult; + const { targetCats: resolvedTargetCats, intent } = routingResult; // F35: When sending a whisper, override routing targets to only whisperTo recipients. // This prevents non-recipient cats from being invoked and seeing whisper content. const sourceTargetCats = whisperVisibility === 'whisper' && whisperRecipients?.length ? [...new Set(whisperRecipients)] : [...resolvedTargetCats]; - // A user-visible source remains honest about whether the author wrote an @mention, - // while Queue custody must never defer the ordinary no-@ default to a later UI or - // execution guess. Resolve the canonical conversation fallback before the atomic - // source + Queue admission, then persist that member as the pending delivery target. - // Message Bundles and whispers already carry an exact caller-owned target set. const targetCats: CatId[] = - !admittedMessageBundle && - whisperVisibility !== 'whisper' && - sourceTargetCats.length === 0 && - (routing_warnings?.length ?? 0) === 0 - ? replayedQueueEntry - ? (queueEntryTargetCats(replayedQueueEntry) as CatId[]) - : await router.resolveConversationTargetsAtAdmission([], resolvedThreadId) - : sourceTargetCats; - const visibleRoutingWarnings = - whisperVisibility !== 'whisper' && routing_warnings?.length ? [...routing_warnings] : []; + liveSessionId && sourceTargetCats.length === 0 + ? (((await opts.invocationQueue.resolveSendTargets([], resolvedThreadId, content)) ?? []) as CatId[]) + : [...sourceTargetCats]; if (liveSessionId && targetCats.length !== 1) return reply.code(400).send({ error: 'Live requires one exact member', code: 'INVALID_LIVE_ADMISSION' }); if (replayedSource && (liveSessionId || replayedSource.extra?.liveAdmission)) { @@ -752,11 +717,11 @@ export const messagesRoutes: FastifyPluginAsync = async ( participants: [...participantCats], }); }; - const publishAdmittedBundleParticipants = async () => { + const publishAdmittedParticipants = async () => { try { await publishSidebarParticipants(); } catch (err) { - log.warn({ err, threadId: resolvedThreadId }, 'Bundle persisted but participant projection failed'); + log.warn({ err, threadId: resolvedThreadId }, 'Message persisted but participant projection failed'); opts.socketManager.emitToUser(userId, 'thread_updated', { threadId: resolvedThreadId, participants: [...targetCats], @@ -765,14 +730,13 @@ export const messagesRoutes: FastifyPluginAsync = async ( }; // Bundle admission defers this mutation until the canonical carrier write // succeeds, so validation/queue-capacity failures leave no false sidebar state. - if (!admittedMessageBundle) await publishSidebarParticipants(); + // Participants follow the winning committed targets, including a conversation fallback. // Server-generated idempotency key if client didn't provide one const resolvedIdempotencyKey = idempotencyKey ?? randomUUID(); const sourcePayloadExtra: NonNullable = { ...(liveSessionId ? { liveAdmission: { sessionId: liveSessionId, targetId: targetCats[0]! } } : {}), ...(admittedMessageBundle ? { messageBundle: admittedMessageBundle.carrier } : {}), - ...(visibleRoutingWarnings.length > 0 ? { routingWarnings: visibleRoutingWarnings } : {}), }; const sourcePayloadWrite: { extra: NonNullable } | Record = Object.keys(sourcePayloadExtra).length > 0 ? { extra: sourcePayloadExtra } : {}; @@ -780,11 +744,6 @@ export const messagesRoutes: FastifyPluginAsync = async ( log.debug({ threadId: resolvedThreadId, targetCats, intent: intent.intent }, 'Queue ingress accepted'); if (opts.invocationQueue) { - const requestedDisposition = resolveMessageDispositionForAdmission({ - explicit: messageDisposition, - projectRoot: opts.projectRoot, - threadId: resolvedThreadId, - }); const queueInput = { ...(liveSessionId ? { liveSessionId } : {}), from: { kind: 'user' as const, userId }, @@ -795,25 +754,17 @@ export const messagesRoutes: FastifyPluginAsync = async ( idempotencyKey: resolvedIdempotencyKey, content, targetCats, - ...(replayedQueueEntry?.payload.routingWarnings - ? { routingWarnings: [...replayedQueueEntry.payload.routingWarnings] } - : visibleRoutingWarnings.length > 0 - ? { routingWarnings: visibleRoutingWarnings } - : {}), - authorIntentByCatId: - replayedQueueEntry?.delivery.authorIntentByTarget ?? - resolveQueueAuthorIntentByCatId({ - targetCats, - requested: requestedDisposition, - threadId: resolvedThreadId, - userId, - invocationTracker: opts.invocationTracker, - resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), - }), + ...(messageDisposition ? { messageDisposition } : {}), + onQueueEntriesAdmitted: async ( + entries: readonly import('../domains/cats/services/agents/invocation/InvocationQueue.js').QueueEntry[], + ) => { + targetCats.splice(0, targetCats.length, ...(entries.flatMap((entry) => entry.targets) as CatId[])); + await publishAdmittedParticipants(); + }, intent: intent.intent, }; const enqueueResult = await opts.invocationQueue - .appendAndEnqueueDurable( + .send( opts.messageStore, { from: queueInput.from, @@ -894,35 +845,8 @@ export const messagesRoutes: FastifyPluginAsync = async ( ); } - if (admittedMessageBundle) await publishAdmittedBundleParticipants(); - const admittedEntries = enqueueResult.entries ?? (enqueueResult.entry ? [enqueueResult.entry] : []); - if ( - !liveSessionId && - requestedDisposition === 'continue_current' && - opts.queueProcessor?.tryAutoAppendExactEntry - ) { - for (const admittedEntry of admittedEntries) { - if (admittedEntry.targets.length === 0) continue; - for (const targetCatId of admittedEntry.targets) { - const append = await opts.queueProcessor.tryAutoAppendExactEntry({ - threadId: resolvedThreadId, - userId, - entryId: admittedEntry.id, - targetCatId, - }); - if (append.outcome === 'rejected') { - await opts.invocationQueue.fallbackQueuedAuthorIntentDurable( - resolvedThreadId, - userId, - admittedEntry.id, - targetCatId, - 'parent_terminal_before_exposure', - ); - } - } - } - } + // Common send signals Queue progress; HTTP acknowledges durable admission only. const admittedInvocationQueue = opts.invocationQueue; const admittedEntryStillQueued = admittedEntries.length === 0 || @@ -945,7 +869,6 @@ export const messagesRoutes: FastifyPluginAsync = async ( } await tryAutoCancelPendingHolds(resolvedThreadId, opts.holdBallCancelDeps); - void opts.queueProcessor?.requestDrain(resolvedThreadId); reply.status(202); return { @@ -973,7 +896,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( reply.status(400); return { error: 'Retry 请求格式无效', code: 'INVALID_DELIVERY_RETRY_REQUEST' }; } - if (!opts.invocationQueue || !opts.queueProcessor) { + if (!opts.invocationQueue) { reply.status(503); return { error: '消息投递暂不可用', code: 'DELIVERY_RETRY_UNAVAILABLE' }; } @@ -1031,17 +954,10 @@ export const messagesRoutes: FastifyPluginAsync = async ( idempotencyKey, content: source.content, targetCats: [target], - authorIntentByCatId: resolveQueueAuthorIntentByCatId({ - targetCats: [target], - requested: 'next_work', - threadId: source.threadId, - userId, - invocationTracker: opts.invocationTracker, - resolveCarrierCapability: (catId) => resolveFreshnessCarrierCapabilityOrUndeclared(opts.router, catId), - }), + messageDisposition: 'next_work' as const, intent: 'cloud_delivery_retry', }; - const admitted = await opts.invocationQueue.appendAndEnqueueDurable( + const admitted = await opts.invocationQueue.send( opts.messageStore, { from: queueInput.from, @@ -1079,7 +995,7 @@ export const messagesRoutes: FastifyPluginAsync = async ( opts.invocationQueue.list(source.threadId, userId), admitted.outcome, ); - void opts.queueProcessor.requestDrain(source.threadId); + reply.status(202); return { status: 'queued', diff --git a/packages/api/src/routes/proposal-approve-dispatch.ts b/packages/api/src/routes/proposal-approve-dispatch.ts index 32a6049110c..a19439d04ff 100644 --- a/packages/api/src/routes/proposal-approve-dispatch.ts +++ b/packages/api/src/routes/proposal-approve-dispatch.ts @@ -1,7 +1,6 @@ import type { CatId, DeclaredWorkMode, ReportingMode } from '@cat-cafe/shared'; import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import { parseIntent } from '../domains/cats/services/context/IntentParser.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; @@ -20,13 +19,11 @@ import { export { enrichWithParentThreadHeader } from './proposal-enrich-header.js'; type ProposalRouter = Pick; -type ProposalInvocationQueue = Pick; -type ProposalQueueProcessor = Pick; +type ProposalInvocationQueue = Pick; export interface ProposalInitialMessageDispatchDeps { router?: ProposalRouter; invocationQueue?: ProposalInvocationQueue; - queueProcessor?: ProposalQueueProcessor; } export interface AppendApprovedInitialMessageInput extends ProposalInitialMessageDispatchDeps { @@ -172,7 +169,6 @@ export async function appendApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, existingSeed, }: AppendApprovedInitialMessageInput): Promise { // F128 source envelope: seed content uses the envelope only when there is no @@ -211,7 +207,7 @@ export async function appendApprovedInitialMessage({ }; // Phase AA (AC-AA6): resolve source cat handle for routing credentials const sourceCatHandle = sourceCatId ? (primaryMentionHandleForCatId(sourceCatId) ?? `@${sourceCatId}`) : null; - if (!router || !invocationQueue || !queueProcessor) { + if (!router || !invocationQueue) { if (existingSeed) { // We cannot wake a target without dispatch dependencies. Cancel the existing // seed so the reconcile loop stops retrying a permanently unwakeable row. @@ -321,7 +317,6 @@ export async function appendApprovedInitialMessage({ threadStore, socketManager, invocationQueue, - queueProcessor, ...(existingSeed ? { existingSeed } : {}), }); } diff --git a/packages/api/src/routes/proposal-approve-reconcile.ts b/packages/api/src/routes/proposal-approve-reconcile.ts index 867e18e3e8b..6cd2ab5cf74 100644 --- a/packages/api/src/routes/proposal-approve-reconcile.ts +++ b/packages/api/src/routes/proposal-approve-reconcile.ts @@ -5,10 +5,7 @@ import { appendApprovedInitialMessage } from './proposal-approve-dispatch.js'; import type { ProposalRoutesOptions } from './proposal-route-options.js'; export interface ReconcileApprovedSeedDeps - extends Pick< - ProposalRoutesOptions, - 'messageStore' | 'threadStore' | 'socketManager' | 'router' | 'invocationQueue' | 'queueProcessor' - > { + extends Pick { proposal: ThreadProposal; userId: string; ownerAuthProvenance: OwnerAuthProvenance; @@ -26,10 +23,7 @@ export interface ReconcileApprovedInitialMessageResult { } export interface ReconcileFactoryDeps - extends Pick< - ProposalRoutesOptions, - 'messageStore' | 'threadStore' | 'socketManager' | 'router' | 'invocationQueue' | 'queueProcessor' - > { + extends Pick { userId: string; ownerAuthProvenance: OwnerAuthProvenance; } @@ -42,7 +36,6 @@ export function createReconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, }: ReconcileFactoryDeps) { return (proposal: ThreadProposal) => reconcileApprovedInitialMessage({ @@ -54,7 +47,6 @@ export function createReconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, }); } @@ -62,8 +54,8 @@ export function createReconcileApprovedInitialMessage({ * #1387 / #1406 B1: materialization and wake-completion are separate facts. * A seed is dispatch-complete only when it has terminal owner-delivery state * or every actually dispatched lifecycle target has settled. Merely owning - * Queue custody does NOT mean the processor woke the target — processNext may - * throw or return started:false before any dispatchRef exists. + * Queue custody does NOT mean the processor woke the target — the shared drain may + * fail or defer before any dispatchRef exists. */ function isDispatchComplete(message: StoredMessage): boolean { if (message.deliveryStatus === 'delivered' || message.deliveryStatus === 'canceled') return true; @@ -126,7 +118,6 @@ async function dispatchApprovedInitialMessage( socketManager: ReconcileApprovedSeedDeps['socketManager'], router: ReconcileApprovedSeedDeps['router'], invocationQueue: ReconcileApprovedSeedDeps['invocationQueue'], - queueProcessor: ReconcileApprovedSeedDeps['queueProcessor'], existingSeed?: StoredMessage, ): Promise<{ warnings: string[] }> { // Best-effort source-thread title: a transient store failure here must not @@ -163,7 +154,6 @@ async function dispatchApprovedInitialMessage( socketManager, router, invocationQueue, - queueProcessor, existingSeed, }); if (result.warning) warnings.push(result.warning); @@ -187,7 +177,6 @@ export async function reconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, }: ReconcileApprovedSeedDeps): Promise { const threadId = proposal.createdThreadId!; @@ -201,7 +190,7 @@ export async function reconcileApprovedInitialMessage({ `proposal-initial:${proposal.proposalId}`, ); if (idempotentSeed) { - // The seed is materialized, but queue-full / processNext failure can leave + // The seed is materialized, but queue-full / drain failure can leave // it without queue custody. In that case the seed is NOT dispatch-complete; // retry dispatch exactly once per reconcile call. if (isDispatchComplete(idempotentSeed)) { @@ -216,7 +205,6 @@ export async function reconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, idempotentSeed, ); return { warnings, wasPresent: true, redispatched: true }; @@ -239,7 +227,6 @@ export async function reconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, legacySeed, ); return { warnings, wasPresent: true, legacy: true, redispatched: true }; @@ -260,7 +247,6 @@ export async function reconcileApprovedInitialMessage({ socketManager, router, invocationQueue, - queueProcessor, ); return { warnings, wasPresent: false }; } diff --git a/packages/api/src/routes/proposal-route-options.ts b/packages/api/src/routes/proposal-route-options.ts index 2f17d91fed4..52c379e2476 100644 --- a/packages/api/src/routes/proposal-route-options.ts +++ b/packages/api/src/routes/proposal-route-options.ts @@ -1,5 +1,4 @@ import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import type { AgentRouter } from '../domains/cats/services/index.js'; import type { IMessageStore } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IProposalStore } from '../domains/cats/services/stores/ports/ProposalStore.js'; @@ -14,8 +13,7 @@ export interface ProposalRoutesOptions { messageStore: IMessageStore; socketManager: SocketManager; router?: Pick; - invocationQueue?: Pick; - queueProcessor?: Pick; + invocationQueue?: Pick; onProposalReject?: (input: { proposalId: string; catId: string; diff --git a/packages/api/src/routes/proposal-routes.ts b/packages/api/src/routes/proposal-routes.ts index e5d312d1c41..19aa6e28c4e 100644 --- a/packages/api/src/routes/proposal-routes.ts +++ b/packages/api/src/routes/proposal-routes.ts @@ -66,7 +66,6 @@ export const proposalRoutes: FastifyPluginAsync = async ( socketManager, router: opts.router, invocationQueue: opts.invocationQueue, - queueProcessor: opts.queueProcessor, }); const proposal = await proposalStore.get(paramsParse.data.proposalId); @@ -237,7 +236,6 @@ export const proposalRoutes: FastifyPluginAsync = async ( socketManager, router: opts.router, invocationQueue: opts.invocationQueue, - queueProcessor: opts.queueProcessor, }); const proposal = await proposalStore.get(paramsParse.data.proposalId); diff --git a/packages/api/src/routes/proposal-seed-admission.ts b/packages/api/src/routes/proposal-seed-admission.ts index b3d44e5ac3a..7bfc56c1d60 100644 --- a/packages/api/src/routes/proposal-seed-admission.ts +++ b/packages/api/src/routes/proposal-seed-admission.ts @@ -1,7 +1,6 @@ import type { CatId, MessageContent } from '@cat-cafe/shared'; import type { InvocationQueue } from '../domains/cats/services/agents/invocation/InvocationQueue.js'; import type { OwnerAuthProvenance } from '../domains/cats/services/agents/invocation/owner-auth-provenance.js'; -import type { QueueProcessor } from '../domains/cats/services/agents/invocation/QueueProcessor.js'; import { messageFrom } from '../domains/cats/services/stores/message-from.js'; import type { IMessageStore, StoredMessage } from '../domains/cats/services/stores/ports/MessageStore.js'; import type { IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; @@ -9,8 +8,7 @@ import type { SocketManager } from '../infrastructure/websocket/index.js'; import type { AppendApprovedInitialMessageResult } from './proposal-approve-dispatch.js'; import { admitThreadParticipants } from './thread-participant-admission.js'; -type ProposalInvocationQueue = Pick; -type ProposalQueueProcessor = Pick; +type ProposalInvocationQueue = Pick; /** * Lossless source envelope delivered to a child thread when the proposal has no @@ -113,7 +111,6 @@ export interface ExecuteQueuedDispatchInput { threadStore: Pick; socketManager: Pick; invocationQueue: ProposalInvocationQueue; - queueProcessor: ProposalQueueProcessor; /** A previously materialized seed (legacy or queue-full) to reuse instead of appending a new message. */ existingSeed?: StoredMessage; } @@ -133,12 +130,25 @@ export async function executeQueuedDispatch({ threadStore, socketManager, invocationQueue, - queueProcessor, existingSeed, }: ExecuteQueuedDispatchInput): Promise { + const prepareParticipants = async () => { + // F128 owns the final dispatch plan: preferredCats ordering and explicit + // parallel intent can differ from raw-message mentions. Admit only those + // final targets, after Queue accepted a durable carrier and before the + // processor can start, so Sidebar C2 and C10 cannot contradict each other. + await admitThreadParticipants({ + userId, + threadId, + targetCats, + threadStore, + socketManager, + emitPolicy: 'membership-changed', + }); + }; const idempotencyKey = `proposal-initial:${proposalId}`; const from = sourceCatId ? ({ kind: 'agent', catId: sourceCatId } as const) : ({ kind: 'user', userId } as const); - let enqueueResult: Awaited>; + let enqueueResult: Awaited>; if (existingSeed) { try { enqueueResult = await invocationQueue.enqueueExistingMessageDurable(messageStore, existingSeed.id, { @@ -152,6 +162,7 @@ export async function executeQueuedDispatch({ sourceId: existingSeed.id, targetCats: targetCats as CatId[], intent: intentName, + onQueueEntriesAdmitted: prepareParticipants, }); } catch (error) { // The ledger admission and Message transition are one CAS. If another @@ -164,7 +175,7 @@ export async function executeQueuedDispatch({ throw error; } } else { - enqueueResult = await invocationQueue.appendAndEnqueueDurable( + enqueueResult = await invocationQueue.send( messageStore, { from, @@ -188,6 +199,7 @@ export async function executeQueuedDispatch({ content, targetCats: targetCats as CatId[], intent: intentName, + onQueueEntriesAdmitted: prepareParticipants, }, ); } @@ -222,33 +234,5 @@ export async function executeQueuedDispatch({ const storedMessageId = enqueueResult.message.id; - // F128 owns the final dispatch plan: preferredCats ordering and explicit - // parallel intent can differ from raw-message mentions. Admit only those - // final targets, after Queue accepted a durable carrier and before the - // processor can start, so Sidebar C2 and C10 cannot contradict each other. - await admitThreadParticipants({ - userId, - threadId, - targetCats, - threadStore, - socketManager, - emitPolicy: 'membership-changed', - }); - - try { - const started = await queueProcessor.processNext(threadId, userId); - if (!started.started) { - return { - messageId: storedMessageId, - warning: 'initialMessage queued but did not start automatically', - }; - } - } catch (err) { - return { - messageId: storedMessageId, - warning: `initialMessage queued but auto-start failed: ${err instanceof Error ? err.message : String(err)}`, - }; - } - return { messageId: storedMessageId }; } diff --git a/packages/api/test/1398-append-delivery-boundary.test.js b/packages/api/test/1398-append-delivery-boundary.test.js index 7522f997d6c..08903515884 100644 --- a/packages/api/test/1398-append-delivery-boundary.test.js +++ b/packages/api/test/1398-append-delivery-boundary.test.js @@ -1,13 +1,23 @@ import assert from 'node:assert/strict'; -import { describe, it, mock } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it, mock } from 'node:test'; import { canonicalTestMessageInput, canonicalTestQueueInput } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { QueueProcessor } = await import('../dist/domains/cats/services/agents/invocation/QueueProcessor.js'); const { InvocationTracker } = await import('../dist/domains/cats/services/agents/invocation/InvocationTracker.js'); +const { PersistedQueueDelivery } = await import( + '../dist/domains/cats/services/agents/invocation/PersistedQueueDelivery.js' +); const { MessageStore, settleLifecycleResponseInputs } = await import( '../dist/domains/cats/services/stores/ports/MessageStore.js' ); +const { saveMessageDispositionPreference } = await import('../dist/config/user-preferences-store.js'); +const appendPreferenceRoot = await mkdtemp(join(tmpdir(), 'f117-append-preference-')); +saveMessageDispositionPreference(appendPreferenceRoot, { scope: 'global', disposition: 'continue_current' }); +after(() => rm(appendPreferenceRoot, { recursive: true, force: true })); let sequence = 0; function deferred() { @@ -18,10 +28,42 @@ function deferred() { return { promise, resolve }; } -function createHarness() { - const queue = new InvocationQueue(); +async function waitFor(predicate) { + for (let attempt = 0; attempt < 100; attempt++) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail('Queue progress did not converge'); +} + +function createHarness({ projectRoot = appendPreferenceRoot, autoDrain = false } = {}) { + let processor; + const admissionNotifications = []; const messageStore = new MessageStore(); const invocationTracker = new InvocationTracker(); + // This Append fixture starts at an active parent before source admission. + for (const target of ['opus', 'codex']) + invocationTracker.start('thread-1', target, 'user-1', [target], 'parent-phase-m'); + const queue = new InvocationQueue(undefined, { + projectRoot, + resolveTargets: async (requested) => (requested.length ? [...requested] : ['opus']), + ...(autoDrain + ? { + onAdmitted: ({ threadId, entries, message }) => { + assert.ok(messageStore.getById(message.id), 'wake follows durable source commit'); + admissionNotifications.push(entries); + void processor.requestDrain(threadId); + }, + } + : {}), + invocationTracker, + resolveCarrierCapability: () => ({ + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + }), + }); const socketManager = { broadcastAgentMessage: mock.fn(), broadcastToRoom: mock.fn(), emitToUser: mock.fn() }; const deps = { queue, @@ -37,7 +79,8 @@ function createHarness() { messageStore, log: { info: mock.fn(), warn: mock.fn(), error: mock.fn() }, }; - return { ...deps, processor: new QueueProcessor(deps, { retryDeferral: { baseDelayMs: 60_000 } }) }; + processor = new QueueProcessor(deps, { retryDeferral: { baseDelayMs: 60_000 } }); + return { ...deps, processor, admissionNotifications }; } async function admit(harness, overrides = {}) { @@ -53,18 +96,27 @@ async function admit(harness, overrides = {}) { intent: 'execute', ...overrides, }); - const result = await harness.queue.appendAndEnqueueDurable( + const result = await harness.queue.send( harness.messageStore, canonicalTestMessageInput({ threadId: 'thread-1', - userId: 'user-1', + userId: queueInput.userId, catId: null, from: queueInput.from, content: queueInput.content, - mentions: ['opus'], + mentions: queueInput.targetCats, timestamp: Date.now(), deliveryStatus: 'queued', - ...(overrides.messageSource ? { source: overrides.messageSource } : {}), + ...(overrides.visibility ? { visibility: overrides.visibility, whisperTo: overrides.whisperTo } : {}), + ...(overrides.idempotencyKey ? { idempotencyKey: overrides.idempotencyKey } : {}), + ...(['external', 'system'].includes(queueInput.from.kind) + ? { + source: overrides.messageSource ?? { + connector: queueInput.from.connectorId ?? queueInput.from.service, + label: 'policy-fixture', + }, + } + : {}), }), queueInput, ); @@ -72,12 +124,12 @@ async function admit(harness, overrides = {}) { return result; } -function bindRun(harness, dispatch) { - const invocationId = `turn-phase-m-${sequence}`; +function bindRun(harness, dispatch, targetId = 'opus') { + const invocationId = `turn-phase-m-${sequence}-${targetId}`; const startedAt = Date.now(); - harness.invocationTracker.start('thread-1', 'opus', 'user-1', ['opus'], 'parent-phase-m'); + harness.invocationTracker.start('thread-1', targetId, 'user-1', [targetId], 'parent-phase-m'); const response = harness.messageStore.append({ - from: { kind: 'agent', catId: 'opus' }, + from: { kind: 'agent', catId: targetId }, userId: 'user-1', content: '', mentions: [], @@ -88,7 +140,7 @@ function bindRun(harness, dispatch) { kind: 'response', orderKey: `${startedAt}:${invocationId}`, invocationId, - targetId: 'opus', + targetId, inputEntryIds: [], inputMessageIds: [], status: 'processing', @@ -99,7 +151,7 @@ function bindRun(harness, dispatch) { harness.invocationTracker.bindLifecycleActiveRun( { threadId: 'thread-1', - targetId: 'opus', + targetId, invocationId, responseMessageId: response.id, inputEntryIds: [], @@ -111,7 +163,7 @@ function bindRun(harness, dispatch) { ), true, ); - const releaseCarrier = harness.invocationTracker.bindAgentClientActiveRunDispatcher('thread-1', 'opus', { + const releaseCarrier = harness.invocationTracker.bindAgentClientActiveRunDispatcher('thread-1', targetId, { invocationId, capabilities: { append: true, steer: true }, handle: { provider: 'anthropic', carrier: 'claude_agent_sdk', threadId: 's', turnId: 't' }, @@ -137,7 +189,400 @@ const deliveredEmits = (harness) => .flatMap((call) => call.arguments[2].messageIds); const activeInputs = (harness) => harness.invocationTracker.getActiveSlots('thread-1')[0].activeRun.inputMessageIds; +describe('common Queue default sending policy', () => { + const sources = [ + { kind: 'user', userId: 'user-1' }, + { kind: 'agent', catId: 'codex' }, + { kind: 'external', connectorId: 'github-wait', sender: { id: 'github-wait' } }, + { kind: 'system', service: 'scheduler' }, + ]; + for (const disposition of ['next_work', 'continue_current']) { + for (const from of sources) { + it(`${from.kind} uses the same ${disposition} default at admission`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-policy-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition }); + const harness = createHarness({ projectRoot }); + const admitted = await admit(harness, { from }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, disposition === 'continue_current' ? 1 : 0); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + const queued = harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id); + if (disposition === 'next_work') { + assert.equal(queued.status, 'queued'); + assert.equal( + queued.delivery.authorIntentByTarget.opus.requested, + 'next_work', + 'default is persisted at admission', + ); + } else { + assert.equal(queued, null); + } + }); + } + } + + it('common admission uses thread over global over product', async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-scopes-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + for (const phase of ['product', 'global', 'thread', 'removed_thread']) { + if (phase === 'global') + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'continue_current' }); + if (phase === 'thread') + saveMessageDispositionPreference(projectRoot, { + scope: 'thread', + threadId: 'thread-1', + disposition: 'next_work', + }); + if (phase === 'removed_thread') + saveMessageDispositionPreference(projectRoot, { scope: 'thread', threadId: 'thread-1', disposition: null }); + const harness = createHarness({ projectRoot }); + const admitted = await admit(harness); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + const expected = ['global', 'removed_thread'].includes(phase) ? 'continue_current' : 'next_work'; + assert.equal(admitted.entry.delivery.authorIntentByTarget.opus.requested, expected, phase); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, expected === 'continue_current' ? 1 : 0, phase); + } + }); + + for (const from of sources) { + for (const explicit of ['next_work', 'continue_current']) { + it(`${from.kind} explicit ${explicit} overrides the opposite default`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-explicit-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + saveMessageDispositionPreference(projectRoot, { + scope: 'global', + disposition: explicit === 'next_work' ? 'continue_current' : 'next_work', + }); + const harness = createHarness({ projectRoot }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + const admitted = await admit(harness, { + from, + authorIntentByCatId: { + opus: { + requested: explicit, + ...(explicit === 'continue_current' ? { boundParentInvocationId: 'parent-phase-m' } : {}), + }, + }, + }); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, explicit === 'continue_current' ? 1 : 0); + if (explicit === 'next_work') { + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + } + }); + } + + it(`${from.kind} same-ID replay retains admission policy; changes affect only new messages`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-replay-policy-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + const harness = createHarness({ projectRoot }); + const overrides = { from, idempotencyKey: `policy-replay-${from.kind}`, content: 'same immutable source' }; + const admitted = await admit(harness, overrides); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const otherDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + bindRun(harness, otherDispatch, 'codex'); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'continue_current' }); + const queuedReplay = await admit(harness, overrides); + assert.equal(queuedReplay.message.id, admitted.message.id); + assert.equal(queuedReplay.entry.delivery.authorIntentByTarget.opus.requested, 'next_work'); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, 0, 'changing default cannot rewrite an already queued strategy'); + const nextOverrides = { ...overrides, idempotencyKey: `${overrides.idempotencyKey}:new`, targetCats: ['codex'] }; + const next = await admit(harness, nextOverrides); + assert.equal(next.entry.delivery.authorIntentByTarget.codex.requested, 'continue_current'); + await harness.processor.requestDrain('thread-1'); + assert.equal(otherDispatch.mock.calls.length, 1); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'next_work' }); + if (from.kind === 'external' || from.kind === 'system') { + const delivery = new PersistedQueueDelivery({ + messages: harness.messageStore, + queue: harness.queue, + progress: (entry, targetCatId) => harness.processor.progressOwnedCarrier(entry, targetCatId), + }); + const deliveredReplay = await delivery.deliver({ + ownerUserId: 'user-1', + threadId: 'thread-1', + targetCatId: 'codex', + from, + idempotencyKey: nextOverrides.idempotencyKey, + content: nextOverrides.content, + source: { connector: from.connectorId ?? from.service, label: 'policy-fixture' }, + }); + assert.ok(['already_processing', 'terminal_owned'].includes(deliveredReplay.state)); + assert.equal(deliveredReplay.message.id, next.message.id); + } + await harness.processor.requestDrain('thread-1'); + assert.equal(otherDispatch.mock.calls.length, 1, 'new defaults cannot resurrect a retired source'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', next.entry.id), null); + }); + + it(`${from.kind} busy unsupported target waits even when the default requests guidance`, async () => { + const harness = createHarness(); + const admitted = await admit(harness, { from }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const run = bindRun(harness, dispatch); + run.releaseCarrier(); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, 0); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + }); + + for (const disposition of ['next_work', 'continue_current']) { + it(`${from.kind} idle target starts a fresh turn under ${disposition}`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-idle-policy-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition }); + const harness = createHarness({ projectRoot }); + harness.invocationTracker.completeAll('thread-1', ['opus', 'codex']); + harness.invocationRecordStore.create.mock.mockImplementation(async () => ({ + outcome: 'created', + invocationId: 'fresh-policy-parent', + })); + await admit(harness, { from }); + await harness.processor.requestDrain('thread-1'); + await waitFor(() => harness.router.routeExecution.mock.calls.length === 1); + assert.equal(harness.router.routeExecution.mock.calls.length, 1); + assert.equal(harness.invocationRecordStore.create.mock.calls.length, 1); + }); + } + } +}); + describe('delivery owns Append admission, without model-read state', () => { + for (const from of [ + { kind: 'user', userId: 'user-1' }, + { kind: 'agent', catId: 'codex' }, + { kind: 'external', connectorId: 'github-wait', sender: { id: 'github-wait' } }, + { kind: 'system', service: 'scheduler' }, + ]) { + it(`one Queue drain appends ${from.kind} input without ingress-owned Append calls`, async () => { + const harness = createHarness(); + const admitted = await admit(harness, { from }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const run = bindRun(harness, dispatch); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, 1); + assert.deepEqual(activeInputs(harness), [admitted.message.id]); + const source = harness.messageStore.getById(admitted.message.id); + assert.equal(source.lifecycle.dispatchRefs[0].statusMessageId, run.response.id); + assert.equal(harness.router.routeExecution.mock.calls.length, 0); + }); + } + + for (const barrier of ['next_work', 'retry']) { + it(`automatic Append respects a same-target ${barrier} barrier while another target progresses`, async () => { + const harness = createHarness(); + const first = await admit(harness); + if (barrier === 'next_work') { + await harness.queue.bindContinueCurrentIntentDurable('thread-1', 'user-1', first.entry.id, 'opus', { + requested: 'next_work', + }); + } else { + harness.processor.retryDeferrals.defer('thread-1', first.entry.id); + } + const later = await admit(harness); + const other = await admit(harness, { targetCats: ['codex'] }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const otherDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + bindRun(harness, otherDispatch, 'codex'); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', first.entry.id).status, 'queued'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', later.entry.id).status, 'queued'); + assert.equal(otherDispatch.mock.calls.length, 1); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', other.entry.id), null); + harness.processor.retryDeferrals.forget(first.entry.id); + }); + } + + it('pending native ACK holds its target while a different target appends', async () => { + const harness = createHarness(); + const entered = deferred(); + const release = deferred(); + const first = await admit(harness); + bindRun(harness, async () => { + entered.resolve(); + await release.promise; + return { accepted: true, handle: {} }; + }); + const firstDrain = harness.processor.requestDrain('thread-1'); + await entered.promise; + const later = await admit(harness); + const other = await admit(harness, { targetCats: ['codex'] }); + const otherDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, otherDispatch, 'codex'); + try { + await Promise.race([ + harness.processor.requestDrain('thread-1'), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error('native ACK blocked drain')), 1000); + timer.unref(); + }), + ]); + await waitFor(() => otherDispatch.mock.calls.length === 1); + assert.equal(otherDispatch.mock.calls.length, 1, 'other target advances before first ACK'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', first.entry.id), null); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', later.entry.id).status, 'queued'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', other.entry.id), null); + } finally { + release.resolve(); + await firstDrain; + } + await harness.processor.requestDrain('thread-1'); + await waitFor(() => harness.queue.getEntrySnapshot('thread-1', 'user-1', later.entry.id) === null); + assert.equal( + harness.queue.getEntrySnapshot('thread-1', 'user-1', later.entry.id), + null, + 'same-target input resumes on ACK', + ); + }); + + it('concurrent ingress drain signals append one source once', async () => { + const harness = createHarness(); + const admitted = await admit(harness); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + await Promise.all(Array.from({ length: 4 }, () => harness.processor.requestDrain('thread-1'))); + assert.equal(dispatch.mock.calls.length, 1); + assert.deepEqual(activeInputs(harness), [admitted.message.id]); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); + }); + + it('one Queue drain preserves an explicit next-work intent while a reply runs', async () => { + const harness = createHarness(); + const admitted = await admit(harness); + await harness.queue.bindContinueCurrentIntentDurable('thread-1', 'user-1', admitted.entry.id, 'opus', { + requested: 'next_work', + }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + await harness.processor.requestDrain('thread-1'); + assert.equal(dispatch.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + assert.deepEqual(activeInputs(harness), []); + }); + + it('Queue records cutover fallback without appending to a different parent', async () => { + const harness = createHarness(); + const admitted = await admit(harness); + await harness.queue.bindContinueCurrentIntentDurable('thread-1', 'user-1', admitted.entry.id, 'opus', { + requested: 'continue_current', + boundParentInvocationId: 'closed-parent', + }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + await harness.processor.requestDrain('thread-1'); + const row = harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id); + assert.equal(row.status, 'queued'); + assert.equal(row.delivery.authorIntentByTarget.opus.fallbackReason, 'parent_terminal_before_exposure'); + assert.equal(typeof row.delivery.authorIntentByTarget.opus.fallbackAt, 'number'); + assert.equal(dispatch.mock.calls.length, 0); + assert.deepEqual(activeInputs(harness), []); + }); + + for (const connector of ['github-wait', 'scheduled', 'other-connector']) { + it(`producer ${connector} appends through its actual durable delivery path and does not replay`, async () => { + const harness = createHarness(); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const run = bindRun(harness, dispatch); + const delivery = new PersistedQueueDelivery({ + messages: harness.messageStore, + queue: harness.queue, + progress: (entry, target) => harness.processor.progressOwnedCarrier(entry, target), + }); + const carrier = { + v: 1, + waitId: 'task-pr-221', + outcomeId: `wait:${connector}:221:merged`, + ownerFence: { kind: 'containing_task', generation: 1 }, + }; + const input = { + ownerUserId: 'user-1', + threadId: 'thread-1', + targetCatId: 'opus', + ownerAuthProvenance: 'strict', + idempotencyKey: `producer-append-${connector}`, + content: 'PR state: merged', + source: { connector, label: connector, meta: { waitContinuationCarrier: carrier } }, + ...(connector === 'scheduled' ? { from: { kind: 'system', service: 'scheduler' } } : {}), + waitContinuationCarrier: carrier, + }; + const result = await delivery.deliver(input); + assert.equal(result.state, 'terminal_owned', 'Queue retires the appended carrier before returning'); + assert.equal(dispatch.mock.calls.length, 1); + assert.equal(result.message.lifecycle.kind, 'input'); + assert.deepEqual(activeInputs(harness), [result.message.id]); + const source = harness.messageStore.getById(result.message.id); + assert.equal(source.deliveryStatus, 'delivered'); + assert.equal(source.lifecycle.dispatchRefs[0].statusMessageId, run.response.id); + assert.deepEqual(source.source.meta.waitContinuationCarrier, carrier); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', result.entryId), null); + const replay = await delivery.deliver(input); + assert.equal(replay.message.id, result.message.id); + assert.equal(dispatch.mock.calls.length, 1, 'same persisted producer identity must not append twice'); + const independent = await delivery.deliver({ ...input, idempotencyKey: `${input.idempotencyKey}:second` }); + assert.notEqual(independent.message.id, result.message.id, 'same text with another source remains independent'); + assert.equal(dispatch.mock.calls.length, 2); + assert.deepEqual(activeInputs(harness), [result.message.id, independent.message.id]); + assert.equal(harness.router.routeExecution.mock.calls.length, 0, 'no second turn is started'); + }); + } + + for (const boundary of ['unsupported', 'suppressed', 'private', 'owner_mismatch', 'bound_parent', 'system_pinned']) { + it(`producer progress retains its queued carrier at the ${boundary} boundary`, async () => { + const harness = createHarness(); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const run = bindRun(harness, dispatch); + if (boundary === 'unsupported') run.releaseCarrier(); + if (boundary === 'suppressed') harness.processor.suppressAutoResume('thread-1', 'opus'); + const queueOverrides = { + from: { kind: 'external', connectorId: 'github-wait', sender: { id: 'github-wait' } }, + ...(boundary === 'owner_mismatch' ? { userId: 'different-owner' } : {}), + ...(boundary === 'system_pinned' + ? { from: { kind: 'agent', catId: 'opus' }, sourceCategory: 'continuation' } + : {}), + }; + const admitted = + boundary === 'private' + ? await harness.queue.enqueueDurable( + canonicalTestQueueInput({ + threadId: 'thread-1', + userId: 'user-1', + sourceId: 'private-producer', + content: 'private work', + targetCats: ['opus'], + intent: 'execute', + ...queueOverrides, + kind: 'private_input', + }), + ) + : await admit(harness, queueOverrides); + if (boundary === 'bound_parent') { + await harness.queue.bindContinueCurrentIntentDurable('thread-1', 'user-1', admitted.entry.id, 'opus', { + requested: 'continue_current', + boundParentInvocationId: 'different-parent', + }); + } + const progress = await harness.processor.progressOwnedCarrier(admitted.entry, 'opus'); + assert.equal(progress, boundary === 'suppressed' ? 'owned_deferred_suppressed' : 'owned_deferred_busy'); + assert.equal(dispatch.mock.calls.length, 0); + assert.equal( + harness.queue.getEntrySnapshot('thread-1', admitted.entry.owner.userId, admitted.entry.id).status, + 'queued', + ); + assert.deepEqual(activeInputs(harness), []); + }); + } + it('auto Append honors a connector target choice and exact parent, preserving source identity', async () => { const harness = createHarness(); const from = { kind: 'external', connectorId: 'github-wait', sender: { id: 'github-wait' } }; @@ -250,6 +695,39 @@ describe('delivery owns Append admission, without model-read state', () => { assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); }); + it('automatic Append rejection after handoff compensates once and releases only its target', async () => { + const harness = createHarness(); + const admitted = await admit(harness); + const release = deferred(); + const run = bindRun(harness, async () => { + await release.promise; + return { accepted: false, reason: 'active_run_closed' }; + }); + await harness.processor.requestDrain('thread-1'); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); + assert.equal( + harness.messageStore.getById(admitted.message.id).lifecycle.dispatchRefs[0].statusMessageId, + run.response.id, + ); + release.resolve(); + await waitFor( + () => harness.messageStore.getById(admitted.message.id).lifecycle.dispatchRefs[0].phase === 'settled', + ); + const source = harness.messageStore.getById(admitted.message.id); + const ref = source.lifecycle.dispatchRefs[0]; + assert.notEqual(ref.statusMessageId, run.response.id); + assert.equal(harness.messageStore.getById(ref.statusMessageId).lifecycle.kind, 'delivery_failure'); + assert.deepEqual(activeInputs(harness), []); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); + await harness.processor.requestDrain('thread-1'); + assert.equal( + harness.messageStore + .getByThread('thread-1', 100, 'user-1') + .filter((message) => message.lifecycle?.kind === 'delivery_failure').length, + 1, + ); + }); + it('publishes one exact failure when the carrier rejects delivery without resurrecting Queue work', async () => { const harness = createHarness(); const admitted = await admit(harness); @@ -265,3 +743,159 @@ describe('delivery owns Append admission, without model-read state', () => { assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); }); }); + +describe('existing ingress chains use common send admission and wake', () => { + for (const policy of ['next_work', 'continue_current']) { + for (const ingress of ['im', 'scheduler', 'notification', 'agent']) { + it(`${ingress} ingress snapshots ${policy} and signals the common drain`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-ingress-policy-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: policy }); + const harness = createHarness({ projectRoot, autoDrain: true }); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, dispatch); + const delivery = new PersistedQueueDelivery({ + messages: harness.messageStore, + queue: harness.queue, + progress: (entry, target) => harness.processor.progressOwnedCarrier(entry, target), + }); + if (ingress === 'im') { + const { ConnectorRouter } = await import('../dist/infrastructure/connectors/ConnectorRouter.js'); + const router = new ConnectorRouter({ + bindingStore: { getByExternal: async () => ({ threadId: 'thread-1', userId: 'user-1' }) }, + dedup: { isDuplicate: () => false }, + messageStore: harness.messageStore, + persistedQueueDelivery: delivery, + threadStore: { get: async () => ({ id: 'thread-1', projectPath: projectRoot }) }, + socketManager: { broadcastToRoom() {} }, + defaultUserId: 'user-1', + defaultCatId: 'codex', + log: { info() {}, warn() {}, error() {} }, + }); + await router.route('feishu', 'chat', 'same message body', `ingress-${ingress}-${policy}`); + } else if (ingress === 'scheduler') { + const { createDeliverFn } = await import('../dist/infrastructure/scheduler/delivery.js'); + await createDeliverFn({ + messageStore: harness.messageStore, + socketManager: harness.socketManager, + persistedQueueDelivery: delivery, + })({ + userId: 'user-1', + threadId: 'thread-1', + targetCatId: 'opus', + content: 'same message body', + idempotencyKey: `ingress-${ingress}-${policy}`, + }); + } else if (ingress === 'notification') { + const { deliverConnectorMessage } = await import('../dist/infrastructure/email/deliver-connector-message.js'); + await deliverConnectorMessage( + { delivery }, + { + userId: 'user-1', + threadId: 'thread-1', + catId: 'opus', + content: 'same message body', + idempotencyKey: `ingress-${ingress}-${policy}`, + source: { connector: 'github-wait', label: 'GitHub Wait' }, + }, + ); + } else { + const { appendA2ASourceWithLedgerAdmission } = await import('../dist/routes/callback-a2a-trigger.js'); + await appendA2ASourceWithLedgerAdmission( + { invocationQueue: harness.queue, messageStore: harness.messageStore }, + canonicalTestMessageInput({ + userId: 'user-1', + threadId: 'thread-1', + from: { kind: 'agent', catId: 'codex' }, + content: 'same message body', + mentions: ['opus'], + timestamp: Date.now(), + idempotencyKey: `ingress-${ingress}-${policy}`, + }), + { + plan: { acceptedTargetCats: ['opus'] }, + ownerAuthProvenance: 'strict', + onQueueEntriesAdmitted: () => + assert.equal(harness.admissionNotifications.length, 0, 'consumer hooks precede drain notification'), + }, + ); + } + assert.equal(harness.admissionNotifications.length, 1); + const [admission] = harness.admissionNotifications[0]; + assert.deepEqual(admission.targets, ['opus'], 'IM own fallback must not override the common send target'); + assert.equal(admission.delivery.authorIntentByTarget.opus.requested, policy); + if (policy === 'continue_current') { + await waitFor(() => dispatch.mock.calls.length === 1); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admission.id), null); + } else { + assert.equal(dispatch.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admission.id).status, 'queued'); + } + }); + } + } +}); + +it('automatic Append delivers a whisper only to its authorized active recipient', async () => { + const harness = createHarness(); + const opusDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const codexDispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + bindRun(harness, opusDispatch, 'opus'); + bindRun(harness, codexDispatch, 'codex'); + const admitted = await admit(harness, { visibility: 'whisper', whisperTo: ['opus'] }); + await harness.processor.requestDrain('thread-1'); + assert.equal(opusDispatch.mock.calls.length, 1); + assert.equal(codexDispatch.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id), null); +}); +for (const automatic of [true, false]) { + it(`legacy misrouted whisper is rejected before native Append: automatic=${automatic}`, async () => { + const harness = createHarness(); + const dispatch = mock.fn(async () => ({ accepted: true, handle: {} })); + const run = bindRun(harness, dispatch, 'codex'); + const source = harness.messageStore.append( + canonicalTestMessageInput({ + threadId: 'thread-1', + userId: 'user-1', + catId: null, + content: 'private legacy row', + mentions: ['opus'], + visibility: 'whisper', + whisperTo: ['opus'], + deliveryStatus: 'queued', + timestamp: Date.now(), + }), + ); + const admitted = await harness.queue.enqueueExistingMessageDurable( + harness.messageStore, + source.id, + canonicalTestQueueInput({ + threadId: 'thread-1', + userId: 'user-1', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + content: source.content, + targetCats: ['codex'], + intent: 'execute', + }), + ); + const result = automatic + ? await harness.processor.tryAutoAppendExactEntry({ + threadId: 'thread-1', + userId: 'user-1', + entryId: admitted.entry.id, + targetCatId: 'codex', + }) + : await harness.processor.appendExactEntry({ + threadId: 'thread-1', + userId: 'user-1', + entryId: admitted.entry.id, + expectedQueueRevision: harness.queue.snapshotRevision('thread-1', 'user-1'), + expectedRuns: [{ targetId: 'codex', invocationId: run.invocationId, responseMessageId: run.response.id }], + }); + assert.equal(result.outcome, 'rejected'); + assert.equal(dispatch.mock.calls.length, 0); + assert.equal(harness.queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id).status, 'queued'); + assert.equal(harness.messageStore.getById(run.response.id).lifecycle.inputMessageIds.length, 0); + }); +} diff --git a/packages/api/test/1398-idle-delivery-timing.test.mjs b/packages/api/test/1398-idle-delivery-timing.test.mjs index 6747141053f..2edaf525ef7 100644 --- a/packages/api/test/1398-idle-delivery-timing.test.mjs +++ b/packages/api/test/1398-idle-delivery-timing.test.mjs @@ -26,7 +26,11 @@ function deferred() { // -> fixed History response. Only provider I/O is controlled. All stores are // fresh in-memory instances; no service, Redis, runtime file or user data. test('idle delivery timing distinguishes local admission from slow provider startup and context reads', async (t) => { - const queue = new InvocationQueue(); + const queue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void processor.requestDrain(threadId); + }, + }); const messages = new MessageStore(); const readById = messages.getById.bind(messages); const readByThread = messages.getByThread.bind(messages); diff --git a/packages/api/test/1398-send-ingress-policy.test.js b/packages/api/test/1398-send-ingress-policy.test.js new file mode 100644 index 00000000000..4c272958393 --- /dev/null +++ b/packages/api/test/1398-send-ingress-policy.test.js @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import Fastify from 'fastify'; +import './helpers/setup-cat-registry.js'; +import { saveMessageDispositionPreference } from '../dist/config/user-preferences-store.js'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; +import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; +import { PersistedQueueDelivery } from '../dist/domains/cats/services/agents/invocation/PersistedQueueDelivery.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; +import { ConnectorRouter } from '../dist/infrastructure/connectors/ConnectorRouter.js'; +import { MemoryConnectorThreadBindingStore } from '../dist/infrastructure/connectors/ConnectorThreadBindingStore.js'; +import { InboundMessageDedup } from '../dist/infrastructure/connectors/InboundMessageDedup.js'; +import { deliverConnectorMessage } from '../dist/infrastructure/email/deliver-connector-message.js'; +import { createDeliverFn } from '../dist/infrastructure/scheduler/delivery.js'; +import { callbacksRoutes } from '../dist/routes/callbacks.js'; +import { messagesRoutes } from '../dist/routes/messages.js'; + +// Actual ingress implementations, shared Message/Queue stores; transport and drain are isolated fixtures. +for (const disposition of ['next_work', 'continue_current']) { + test(`current HTTP/callback/IM/mail/notification/scheduler entrances share ${disposition} admission`, async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-ingress-policy-')); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition }); + const owner = 'ingress-owner'; + const threads = new ThreadStore(); + const thread = threads.create(owner, 'Shared ingress'); + const messages = new MessageStore(); + const tracker = new InvocationTracker(); + tracker.start(thread.id, 'opus', owner, ['opus'], 'exact-ingress-parent'); + const capability = { + provider: 'anthropic', + carrier: 'claude_agent_sdk', + activeInvocationGuidance: 'supported', + deliverySemantics: 'exact_active_turn', + }; + const notifications = []; + const queue = new InvocationQueue(undefined, { + projectRoot, + invocationTracker: tracker, + resolveCarrierCapability: () => capability, + resolveTargets: async (targets) => (targets.length ? [...targets] : ['opus']), + onAdmitted: ({ entries, message }) => { + assert.ok(messages.getById(message.id), 'no wake before durable source'); + notifications.push(entries); + }, + }); + const delivery = new PersistedQueueDelivery({ + messages, + queue, + progress: async () => 'owned_deferred_busy', + }); + const registry = new InvocationRegistry(); + const router = { + resolveTargetsAndIntent: async () => ({ targetCats: ['opus'], intent: { intent: 'execute' } }), + resolveExplicitTargets: async (cats) => cats, + resolveConversationTargetsAtAdmission: async (cats) => (cats.length ? cats : ['opus']), + freshnessCarrierCapability: () => capability, + }; + const socketManager = { broadcastToRoom() {}, emitToUser() {}, broadcastAgentMessage() {} }; + const app = Fastify(); + t.after(() => app.close()); + await app.register(messagesRoutes, { + registry, + messageStore: messages, + threadStore: threads, + invocationQueue: queue, + invocationTracker: tracker, + router, + socketManager, + }); + await app.register(callbacksRoutes, { + registry, + messageStore: messages, + threadStore: threads, + invocationQueue: queue, + router, + socketManager, + invocationRecordStore: {}, + queueProcessor: { registerCallerDispatchInitialTargets() {} }, + }); + const user = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': owner }, + payload: { threadId: thread.id, content: 'user payload', idempotencyKey: randomUUID() }, + }); + assert.equal(user.statusCode, 202, user.body); + const auth = await registry.create(owner, 'codex', thread.id); + const callback = await app.inject({ + method: 'POST', + url: '/api/callbacks/post-message', + headers: { 'x-invocation-id': auth.invocationId, 'x-callback-token': auth.callbackToken }, + payload: { threadId: thread.id, targetCats: ['opus'], content: 'agent payload', clientMessageId: 'agent-policy' }, + }); + assert.equal(callback.statusCode, 200, callback.body); + assert.equal(callback.json().status, 'ok', callback.body); + const bindings = new MemoryConnectorThreadBindingStore(); + bindings.bind('feishu', 'policy-chat', thread.id, owner); + const im = new ConnectorRouter({ + bindingStore: bindings, + dedup: new InboundMessageDedup(), + messageStore: messages, + persistedQueueDelivery: delivery, + threadStore: threads, + defaultUserId: owner, + defaultCatId: 'codex', + socketManager, + log: { debug() {}, info() {}, warn() {}, error() {} }, + }); + assert.equal((await im.route('feishu', 'policy-chat', 'IM payload', 'im-policy')).kind, 'routed'); + const mail = await deliverConnectorMessage( + { delivery }, + { + threadId: thread.id, + userId: owner, + content: 'mail payload', + idempotencyKey: 'mail-policy', + source: { connector: 'email', label: 'Mail' }, + }, + ); + assert.equal(mail.admitted, true); + await delivery.deliver({ + ownerUserId: owner, + threadId: thread.id, + targetCatId: 'opus', + content: 'notification payload', + idempotencyKey: 'notification-policy', + source: { connector: 'github-wait', label: 'GitHub Wait' }, + sourceCategory: 'ci', + }); + await createDeliverFn({ messageStore: messages, socketManager, persistedQueueDelivery: delivery })({ + userId: owner, + threadId: thread.id, + targetCatId: 'opus', + content: 'scheduled payload', + idempotencyKey: 'scheduler-policy', + sourceCategory: 'scheduled', + }); + const entries = queue.list(thread.id, owner); + assert.equal(entries.length, 6); + assert.equal(notifications.length, 6, 'each admission notifies the common owner, not a producer loop'); + for (const entry of entries) { + assert.deepEqual(entry.targets, ['opus'], 'IM/mail no-target fallback uses common send, not defaultCatId'); + assert.equal(entry.delivery.authorIntentByTarget.opus.requested, disposition, entry.payload.content); + if (disposition === 'continue_current') + assert.equal(entry.delivery.authorIntentByTarget.opus.boundParentInvocationId, 'exact-ingress-parent'); + } + saveMessageDispositionPreference(projectRoot, { + scope: 'global', + disposition: disposition === 'next_work' ? 'continue_current' : 'next_work', + }); + await deliverConnectorMessage( + { delivery }, + { + threadId: thread.id, + userId: owner, + content: 'mail payload', + idempotencyKey: 'mail-policy', + source: { connector: 'email', label: 'Mail' }, + }, + ); + assert.equal(queue.list(thread.id, owner).length, 6); + const replay = queue.list(thread.id, owner).find((entry) => entry.payload.content === 'mail payload'); + assert.equal(replay.delivery.authorIntentByTarget.opus.requested, disposition); + }); +} diff --git a/packages/api/test/1398-send-policy-redis.test.js b/packages/api/test/1398-send-policy-redis.test.js new file mode 100644 index 00000000000..2a0b3739fbd --- /dev/null +++ b/packages/api/test/1398-send-policy-redis.test.js @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { saveMessageDispositionPreference } from '../dist/config/user-preferences-store.js'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { RedisQueueLedgerStore } from '../dist/domains/cats/services/agents/invocation/queue-ledger/RedisQueueLedgerStore.js'; +import { RedisMessageStore } from '../dist/domains/cats/services/stores/redis/RedisMessageStore.js'; +import { ownedRedisFixture } from './helpers/owned-redis-fixture.js'; + +const owned = ownedRedisFixture('f117-send-policy'); +test('Redis concurrent source replay and reload keep the winning admission policy', async (t) => { + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-redis-policy-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + const redis = owned.client('send-policy:'); + const ledger = new RedisQueueLedgerStore(redis); + const messages = new RedisMessageStore(redis, { ttlSeconds: 0 }); + const queue = new InvocationQueue(ledger, { projectRoot }); + const from = { kind: 'user', userId: 'owner' }; + const message = { + threadId: 'thread', + userId: 'owner', + from, + content: 'same immutable body', + mentions: ['opus'], + deliveryStatus: 'queued', + timestamp: 100, + idempotencyKey: 'policy-request', + }; + const input = { + threadId: 'thread', + userId: 'owner', + from, + content: message.content, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + targetCats: ['opus'], + intent: 'execute', + }; + const first = await queue.send(messages, message, input); + assert.equal(first.entry.delivery.authorIntentByTarget.opus.requested, 'next_work'); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'continue_current' }); + const replays = await Promise.all(Array.from({ length: 12 }, () => queue.send(messages, message, input))); + for (const replay of replays) { + assert.equal(replay.message.id, first.message.id); + assert.equal(replay.deduped, true); + assert.deepEqual(replay.entry.delivery.authorIntentByTarget, first.entry.delivery.authorIntentByTarget); + } + assert.equal((await ledger.list('thread')).length, 1); + const reloaded = new InvocationQueue(new RedisQueueLedgerStore(redis), { projectRoot }); + const replay = await reloaded.send(new RedisMessageStore(redis, { ttlSeconds: 0 }), message, input); + assert.deepEqual(replay.entry.delivery.authorIntentByTarget, first.entry.delivery.authorIntentByTarget); + const next = await reloaded.send(messages, { ...message, idempotencyKey: 'independent-request' }, input); + assert.notEqual(next.message.id, first.message.id); + assert.equal(next.entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); + assert.equal(next.entry.delivery.authorIntentByTarget.opus.fallbackReason, 'carrier_capability_undeclared'); + assert.equal((await ledger.list('thread')).length, 2); + assert.equal(await redis.ttl(`msg:${first.message.id}`), -1); +}); + +test('Redis default target snapshot survives concurrent admission, fallback changes and reload', async () => { + const redis = owned.client('target-replay:'); + const ledger = new RedisQueueLedgerStore(redis); + const messages = new RedisMessageStore(redis, { ttlSeconds: 0 }); + let fallback = 'opus'; + const policy = { resolveTargets: async () => [fallback] }; + const queue = new InvocationQueue(ledger, policy); + const from = { kind: 'user', userId: 'owner' }; + const message = { + threadId: 'thread', + userId: 'owner', + from, + content: 'hello', + mentions: [], + deliveryStatus: 'queued', + timestamp: 100, + idempotencyKey: 'target-request', + }; + const input = { + threadId: 'thread', + userId: 'owner', + from, + content: 'hello', + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + targetCats: [], + intent: 'execute', + }; + const winners = await Promise.all(Array.from({ length: 12 }, () => queue.send(messages, message, input))); + const first = winners[0]; + assert.ok(winners.every((result) => result.message.id === first.message.id)); + fallback = 'codex'; + const reloaded = new InvocationQueue(new RedisQueueLedgerStore(redis), policy); + const retry = await reloaded.send(new RedisMessageStore(redis, { ttlSeconds: 0 }), message, input); + assert.deepEqual(retry.entry.targets, ['opus']); + assert.deepEqual(retry.entry.delivery.authorIntentByTarget, first.entry.delivery.authorIntentByTarget); + await assert.rejects( + reloaded.send(messages, { ...message, content: 'different' }, { ...input, content: 'different' }), + /identity conflict/, + ); + await assert.rejects(reloaded.send(messages, message, { ...input, targetCats: ['codex'] }), /identity conflict/); + const next = await reloaded.send(messages, { ...message, idempotencyKey: 'next-source' }, input); + assert.deepEqual(next.entry.targets, ['codex']); + assert.equal((await ledger.list('thread')).length, 2); + assert.equal(await redis.ttl(`msg:${first.message.id}`), -1); +}); + +test('Redis whisper replay and reload retain authorized recipients without ordinary fallback', async () => { + const redis = owned.client('whisper-replay:'); + const ledger = new RedisQueueLedgerStore(redis); + const messages = new RedisMessageStore(redis, { ttlSeconds: 0 }); + const policy = { resolveTargets: async (targets, _thread, _body, exact) => (exact ? [...targets] : ['codex']) }; + const queue = new InvocationQueue(ledger, policy); + const from = { kind: 'user', userId: 'owner' }; + const message = { + from, + userId: 'owner', + threadId: 'whisper-thread', + content: 'private Redis source', + mentions: ['opus'], + visibility: 'whisper', + whisperTo: ['opus'], + deliveryStatus: 'queued', + timestamp: 100, + idempotencyKey: 'whisper-request', + }; + const input = { + from, + userId: 'owner', + threadId: 'whisper-thread', + content: message.content, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + targetCats: ['opus'], + intent: 'execute', + }; + const results = await Promise.all(Array.from({ length: 6 }, () => queue.send(messages, message, input))); + assert.ok(results.every((result) => result.message.id === results[0].message.id)); + assert.ok(results.every((result) => result.entry.targets.length === 1 && result.entry.targets[0] === 'opus')); + const reloaded = new InvocationQueue(new RedisQueueLedgerStore(redis), policy); + const replay = await reloaded.send(new RedisMessageStore(redis, { ttlSeconds: 0 }), message, input); + assert.deepEqual(replay.entry.targets, ['opus']); + assert.deepEqual(replay.message.whisperTo, ['opus']); + await assert.rejects(reloaded.send(messages, message, { ...input, targetCats: ['codex'] }), /authorized recipients/); + await assert.rejects( + reloaded.send( + messages, + { ...message, whisperTo: ['codex'], mentions: ['codex'] }, + { ...input, targetCats: ['codex'] }, + ), + /identity conflict/, + ); + assert.equal((await ledger.list('whisper-thread')).length, 1); + assert.equal(await redis.ttl(`msg:${results[0].message.id}`), -1); +}); diff --git a/packages/api/test/1398-send-target-replay.test.js b/packages/api/test/1398-send-target-replay.test.js new file mode 100644 index 00000000000..f5662053360 --- /dev/null +++ b/packages/api/test/1398-send-target-replay.test.js @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { test } from 'node:test'; +import Fastify from 'fastify'; +import { InvocationQueue } from '../dist/domains/cats/services/agents/invocation/InvocationQueue.js'; +import { InvocationRegistry } from '../dist/domains/cats/services/agents/invocation/InvocationRegistry.js'; +import { InvocationTracker } from '../dist/domains/cats/services/agents/invocation/InvocationTracker.js'; +import { QueueProcessor } from '../dist/domains/cats/services/agents/invocation/QueueProcessor.js'; +import { InMemoryQueueLedgerStore } from '../dist/domains/cats/services/agents/invocation/queue-ledger/InMemoryQueueLedgerStore.js'; +import { createQueueLedgerAdmission } from '../dist/domains/cats/services/agents/invocation/queue-ledger/QueueLedgerAdmission.js'; +import { AgentRegistry } from '../dist/domains/cats/services/agents/registry/AgentRegistry.js'; +import { AgentRouter } from '../dist/domains/cats/services/agents/routing/AgentRouter.js'; +import { MessageStore } from '../dist/domains/cats/services/stores/ports/MessageStore.js'; +import { ThreadStore } from '../dist/domains/cats/services/stores/ports/ThreadStore.js'; +import { messagesRoutes } from '../dist/routes/messages.js'; + +function envelope(targetCats = [], content = 'hello', idempotencyKey = 'fixed-request') { + const from = { kind: 'user', userId: 'owner' }; + return [ + { + from, + userId: 'owner', + threadId: 'thread', + content, + mentions: targetCats, + deliveryStatus: 'queued', + timestamp: 100, + idempotencyKey, + }, + { + from, + userId: 'owner', + threadId: 'thread', + content, + kind: 'conversation_input', + ownerAuthProvenance: 'strict', + targetCats, + intent: 'execute', + }, + ]; +} +function fixture({ unavailable = false, noServices = false } = {}) { + const agents = new AgentRegistry(); + if (!noServices) agents.register('codex', { invoke: async function* () {} }); + if (unavailable) agents.markUnavailable('opus', { code: 'rejected-account-binding', message: 'owned fixture' }); + else if (!noServices) agents.register('opus', { invoke: async function* () {} }); + const messages = new MessageStore(); + const threads = new ThreadStore(); + const registry = new InvocationRegistry(); + const router = new AgentRouter({ agentRegistry: agents, registry, messageStore: messages, threadStore: threads }); + const queue = new InvocationQueue(undefined, { + resolveTargets: (targets, thread, content, exact) => router.resolveSendTargets(targets, thread, content, exact), + }); + return { messages, threads, registry, router, queue, agents }; +} +test('common send retains default recipient and policy across concurrent retries', async () => { + let fallback = 'opus'; + const queue = new InvocationQueue(undefined, { + resolveTargets: async (targets) => (targets.length ? [...targets] : [fallback]), + }); + const messages = new MessageStore(); + const args = envelope(); + const first = await queue.send(messages, ...args); + fallback = 'codex'; + const retries = await Promise.all(Array.from({ length: 12 }, () => queue.send(messages, ...args))); + for (const retry of retries) { + assert.equal(retry.message.id, first.message.id); + assert.deepEqual(retry.entry.targets, ['opus']); + assert.deepEqual(retry.entry.delivery.authorIntentByTarget, first.entry.delivery.authorIntentByTarget); + assert.equal(retry.deduped, true); + } + const next = await queue.send(messages, ...envelope([], 'hello', 'new-request')); + assert.deepEqual(next.entry.targets, ['codex']); + await assert.rejects(queue.send(messages, ...envelope([], 'different body')), /identity conflict/); + await assert.rejects(queue.send(messages, ...envelope(['codex'])), /identity conflict/); + await assert.rejects( + queue.send(messages, args[0], { ...args[1], ownerAuthProvenance: 'unknown' }), + /identity conflict/, + ); + await assert.rejects(queue.send(messages, args[0], { ...args[1], targetCats: ['codex'] }), /identity conflict/); + assert.equal(queue.list('thread', 'owner').length, 2); +}); +test('ordinary unavailable recipient falls back without rewriting prose; exact recipients stay exact', async () => { + const f = fixture({ unavailable: true }); + const result = await f.queue.send(f.messages, ...envelope([], '@opus hello')); + assert.deepEqual(result.entry.targets, ['codex']); + assert.equal(result.message.content, '@opus hello'); + assert.deepEqual(await f.router.resolveSendTargets(['opus'], 'thread', '@opus hello', true), ['opus']); + await assert.rejects(f.router.resolveSendTargets(['not-a-catalog-id'], 'thread'), /identity/); +}); +for (const [content, expected] of [ + ['no member mentioned', ['opus']], + ['ordinary @not_a_member prose', ['opus']], + ['@not_a_member @codex hello', ['codex']], + ['hello @codex', ['codex']], +]) { + test(`common send parses only catalog mentions: ${content}`, async () => { + const f = fixture(); + const result = await f.queue.send(f.messages, ...envelope([], content)); + assert.deepEqual(result.entry.targets, expected); + assert.equal(result.message.content, content); + assert.equal(result.entry.payload.routingWarnings, undefined); + }); +} +test('no available fallback persists the source and existing drain produces explicit failure', async () => { + const f = fixture({ noServices: true }); + const result = await f.queue.send(f.messages, ...envelope([], '@opus hello')); + assert.equal(result.outcome, 'enqueued'); + assert.deepEqual(result.entry.targets, []); + assert.equal(f.messages.getById(result.message.id).content, '@opus hello'); + const processor = new QueueProcessor({ + queue: f.queue, + invocationTracker: new InvocationTracker(), + messageStore: f.messages, + router: f.router, + socketManager: { emitToUser() {}, broadcastAgentMessage() {}, broadcastToRoom() {} }, + log: { info() {}, warn() {}, error() {} }, + }); + await processor.requestDrain('thread'); + assert.equal(f.queue.list('thread', 'owner').length, 0); + const failure = f.messages + .getByThread('thread') + .find((message) => message.lifecycle?.reason === 'no_available_target'); + assert.ok(failure); + assert.match(failure.content, /没有可用/); +}); +test('HTTP unavailable member falls back and same-ID retry retains admission', async (t) => { + const f = fixture({ unavailable: true }); + const thread = f.threads.create('owner', 'owned'); + const app = Fastify(); + t.after(() => app.close()); + await app.register(messagesRoutes, { + registry: f.registry, + messageStore: f.messages, + threadStore: f.threads, + invocationQueue: f.queue, + router: f.router, + socketManager: { broadcastToRoom() {}, emitToUser() {}, broadcastAgentMessage() {} }, + }); + const request = { + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'owner' }, + payload: { threadId: thread.id, content: '@opus hello', idempotencyKey: randomUUID() }, + }; + const first = await app.inject(request); + assert.equal(first.statusCode, 202, first.body); + assert.deepEqual( + first.json().entries.map((entry) => entry.targetCatId), + ['codex'], + ); + const retry = await app.inject(request); + assert.equal(retry.statusCode, 202, retry.body); + assert.equal(retry.json().userMessageId, first.json().userMessageId); + assert.equal(f.queue.list(thread.id, 'owner').length, 1); +}); + +test('common replay retains a legacy admission without a requested-target field', async () => { + const messages = new MessageStore(); + const ledger = new InMemoryQueueLedgerStore(); + const [message, input] = envelope(); + const first = messages.appendWithQueueLedgerAdmission( + message, + (sourceId) => + createQueueLedgerAdmission({ + sourceId, + messageId: sourceId, + threadId: 'thread', + owner: { kind: 'user', userId: 'owner' }, + kind: 'conversation_input', + from: input.from, + targetCatIds: ['opus'], + content: 'hello', + intent: 'execute', + ownerAuthProvenance: 'strict', + enqueuedAt: 100, + }), + ledger, + ); + const queue = new InvocationQueue(ledger, { resolveTargets: async () => ['codex'] }); + const replay = await queue.send(messages, message, input); + assert.equal(replay.message.id, first.message.id); + assert.deepEqual(replay.entry.targets, ['opus']); + assert.equal(replay.entry.payload.requestedTargetCats, undefined); + assert.equal((await ledger.list('thread')).length, 1); +}); + +test('A2A wake retains the named target instead of ordinary conversation fallback', async () => { + const f = fixture({ unavailable: true }); + const [message, input] = envelope(['opus'], '@opus hello'); + const from = { kind: 'agent', catId: 'codex' }; + const result = await f.queue.send(f.messages, { ...message, from }, { ...input, from, kind: 'message_wake' }); + assert.deepEqual(result.entry.targets, ['opus']); + assert.equal(result.message.content, '@opus hello'); + await assert.rejects( + f.queue.send( + f.messages, + { ...message, from, idempotencyKey: 'missing-wake' }, + { ...input, from, kind: 'message_wake', targetCats: [] }, + ), + /exact target/, + ); + assert.equal(f.messages.getByIdempotencyKey('owner', 'thread', 'missing-wake'), null); +}); + +for (const progress of ['requestDrain', 'processNext']) { + test(`queued whisper cannot fall back after recipient becomes unavailable: ${progress}`, async () => { + const f = fixture(); + const [message, input] = envelope(['opus'], 'private recipient-only text'); + const result = await f.queue.send(f.messages, { ...message, visibility: 'whisper', whisperTo: ['opus'] }, input); + assert.deepEqual(result.entry.targets, ['opus']); + f.agents.markUnavailable('opus', { code: 'rejected-account-binding', message: 'owned late failure' }); + f.router.refreshFromRegistry(f.agents); + const deliveries = []; + f.router.routeExecution = async function* (...args) { + deliveries.push({ content: args[1], targets: args[4] }); + }; + const processor = new QueueProcessor({ + queue: f.queue, + invocationTracker: new InvocationTracker(), + messageStore: f.messages, + router: f.router, + socketManager: { emitToUser() {}, broadcastAgentMessage() {}, broadcastToRoom() {} }, + log: { info() {}, warn() {}, error() {} }, + }); + if (progress === 'requestDrain') await processor.requestDrain('thread'); + else await processor.processNext('thread', 'owner'); + assert.deepEqual(deliveries, []); + assert.equal(f.queue.list('thread', 'owner').length, 0); + assert.ok(f.messages.getByThread('thread').some((row) => row.lifecycle?.reason === 'invalid_explicit_target')); + }); +} +test('common whisper admission cannot authorize targets outside whisperTo', async () => { + const f = fixture({ unavailable: true }); + const [message, input] = envelope(['codex'], 'private'); + await assert.rejects( + f.queue.send(f.messages, { ...message, visibility: 'whisper', whisperTo: ['opus'] }, input), + /authorized recipients/, + ); + assert.equal(f.messages.getByThread('thread').length, 0); + assert.equal(f.queue.list('thread', 'owner').length, 0); + const permitted = await f.queue.send( + f.messages, + { ...message, mentions: ['opus'], visibility: 'whisper', whisperTo: ['opus'] }, + { ...input, targetCats: ['opus'] }, + ); + assert.deepEqual(permitted.entry.targets, ['opus']); + const retries = await Promise.all( + Array.from({ length: 4 }, () => + f.queue.send( + f.messages, + { ...message, mentions: ['opus'], visibility: 'whisper', whisperTo: ['opus'] }, + { ...input, targetCats: ['opus'] }, + ), + ), + ); + assert.ok(retries.every((retry) => retry.message.id === permitted.message.id && retry.entry.targets[0] === 'opus')); +}); diff --git a/packages/api/test/a2a-1577-action-history-seam.test.mjs b/packages/api/test/a2a-1577-action-history-seam.test.mjs index 34ef5866e66..d28160cb537 100644 --- a/packages/api/test/a2a-1577-action-history-seam.test.mjs +++ b/packages/api/test/a2a-1577-action-history-seam.test.mjs @@ -104,7 +104,7 @@ async function fixture(status = 'completed', options = {}) { const messages = new MessageStore(); const records = new InvocationRecordStore(); const turns = new InMemoryTurnExecutionStore(); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messages, { threadId: current.holderThreadId, @@ -450,7 +450,7 @@ test('recovery response distinguishes persisted Queue admission from unavailable test('a proven interrupted source cannot schedule again while its exact replacement remains pending', async () => { const f = await fixture('interrupted'); - await f.queue.appendAndEnqueueDurable( + await f.queue.send( f.messages, { threadId: f.current.holderThreadId, diff --git a/packages/api/test/a2a-1577-collective-reconsideration-seam.test.mjs b/packages/api/test/a2a-1577-collective-reconsideration-seam.test.mjs index 316c1b5c025..f8fa34e7788 100644 --- a/packages/api/test/a2a-1577-collective-reconsideration-seam.test.mjs +++ b/packages/api/test/a2a-1577-collective-reconsideration-seam.test.mjs @@ -15,8 +15,13 @@ async function fixture(t) { const threadId = 'collective-reconsider-seam'; const messages = new MessageStore(); const ledger = new InMemoryQueueLedgerStore(); - let queue = new InvocationQueue(ledger); let wakes = 0; + const admission = { + onAdmitted: () => { + wakes++; + }, + }; + let queue = new InvocationQueue(ledger, admission); let revoked = false; const identity = { serviceInstanceId: 'svc_fixture000', @@ -54,11 +59,6 @@ async function fixture(t) { get queue() { return queue; }, - processor: { - processNext: async () => { - wakes++; - }, - }, }; const app = Fastify(); app.addHook('preHandler', async (req) => { @@ -106,7 +106,7 @@ async function fixture(t) { revoked = true; }, restart: async () => { - queue = new InvocationQueue(ledger); + queue = new InvocationQueue(ledger, admission); await queue.hydrateFromLedger(messages); }, }; @@ -182,9 +182,14 @@ for (const state of ['processing', 'completed', 'failed', 'completed-unread']) { threadId: f.threadId, entryId: entry.id, inputMessageIds: [first.json().messageId], - ...(state === 'completed-unread' ? { handed: true } : {}), runs: [ - { targetId: 'opus', invocationId: 'isolated-child', responseMessageId: child.id, dispatchedAt: Date.now() }, + { + targetId: 'opus', + invocationId: 'isolated-child', + responseMessageId: child.id, + dispatchedAt: Date.now(), + ...(state === 'completed-unread' ? { inputReadSupported: true } : {}), + }, ], }); assert.equal(receipt.kind, 'applied'); @@ -202,7 +207,11 @@ for (const state of ['processing', 'completed', 'failed', 'completed-unread']) { } await f.restart(); const replay = await f.post(); - if (status === 'failed' || state === 'completed-unread') assert.notEqual(replay.statusCode, 200); + if (state === 'completed-unread') { + // Native read is presentation evidence, not another execution or recovery gate. + assert.equal(f.messages.getById(first.json().messageId).lifecycle.dispatchRefs[0].inputRead.status, 'pending'); + } + if (status === 'failed') assert.notEqual(replay.statusCode, 200); else { assert.equal(replay.statusCode, 200, replay.body); assert.equal(replay.json().disposition, status === 'completed' ? 'already_classified' : 'already_queued'); diff --git a/packages/api/test/a2a-1577-failure-return-seam.test.mjs b/packages/api/test/a2a-1577-failure-return-seam.test.mjs index 42748ff6c48..dd9c9abb2f9 100644 --- a/packages/api/test/a2a-1577-failure-return-seam.test.mjs +++ b/packages/api/test/a2a-1577-failure-return-seam.test.mjs @@ -9,9 +9,13 @@ import { commitFailedResponseAndEnqueueA2ACaller } from '../src/routes/callback- // A legacy availability resolver is a spy only; ordinary failure return must not consult it. function fixture({ rejected = false } = {}) { const ledger = new InMemoryQueueLedgerStore(); - const queue = new InvocationQueue(ledger); - const messages = new MessageStore(); const drains = []; + const queue = new InvocationQueue(ledger, { + onAdmitted: ({ threadId }) => { + drains.push(threadId); + }, + }); + const messages = new MessageStore(); const business = []; const receipts = []; const checks = []; diff --git a/packages/api/test/a2a-1577-live-inbox-seam.test.mjs b/packages/api/test/a2a-1577-live-inbox-seam.test.mjs index 930a41bb4fb..4070bc3faf7 100644 --- a/packages/api/test/a2a-1577-live-inbox-seam.test.mjs +++ b/packages/api/test/a2a-1577-live-inbox-seam.test.mjs @@ -26,7 +26,7 @@ async function fixture(extra = {}) { timestamp: 1, deliveryStatus: 'queued', }; - const admitted = await queue.appendAndEnqueueDurable(store, message, { + const admitted = await queue.send(store, message, { ...message, kind: 'conversation_input', ownerAuthProvenance: 'unknown', diff --git a/packages/api/test/a2a-1577-managed-retirement-seam.test.mjs b/packages/api/test/a2a-1577-managed-retirement-seam.test.mjs index b7a7cd8f061..f578c5967ef 100644 --- a/packages/api/test/a2a-1577-managed-retirement-seam.test.mjs +++ b/packages/api/test/a2a-1577-managed-retirement-seam.test.mjs @@ -65,7 +65,7 @@ function harness(t) { async admitWake(input) { admissions++; // Same atomic canonical admission as index; a controlled provider proves actual child exposure. - const admitted = await f.queue.appendAndEnqueueDurable(f.messages, input.message, { + const admitted = await f.queue.send(f.messages, input.message, { threadId: input.threadId, userId: input.userId, sourceId: input.message.idempotencyKey, diff --git a/packages/api/test/a2a-1577-message-drill-seam.test.mjs b/packages/api/test/a2a-1577-message-drill-seam.test.mjs index 0339924a30d..05412d12ef7 100644 --- a/packages/api/test/a2a-1577-message-drill-seam.test.mjs +++ b/packages/api/test/a2a-1577-message-drill-seam.test.mjs @@ -34,7 +34,7 @@ async function fixture(t, extra = {}) { executionKind: 'ordinary', startedAt: Date.now(), }); - const admission = await queue.appendAndEnqueueDurable( + const admission = await queue.send( messages, { userId, diff --git a/packages/api/test/a2a-1577-processor-seam.test.mjs b/packages/api/test/a2a-1577-processor-seam.test.mjs index 7870e667566..d2a09e6bb8d 100644 --- a/packages/api/test/a2a-1577-processor-seam.test.mjs +++ b/packages/api/test/a2a-1577-processor-seam.test.mjs @@ -67,7 +67,6 @@ async function fixture(route, overrides = {}) { invocationQueue: queue, messageStore: messages, threadStore: { get: async () => ({ createdBy: task.userId, participants: ['codex'] }) }, - queueProcessor: { processNext: async () => {} }, context: () => ({ resolvePrivate: async () => ({ admitted: true }) }), }); const receipt = await dispatcher.dispatch(task, task.userId, 1, { kind: 'admission' }); diff --git a/packages/api/test/a2a-1577-producer-category-seam.test.mjs b/packages/api/test/a2a-1577-producer-category-seam.test.mjs index 7e7461703dd..6695a1ac3a4 100644 --- a/packages/api/test/a2a-1577-producer-category-seam.test.mjs +++ b/packages/api/test/a2a-1577-producer-category-seam.test.mjs @@ -48,7 +48,7 @@ function fixture({ queue: { getDurableEntry: async () => (retired ? undefined : entry), findAdmittedEntriesForMessages: () => [], - appendAndEnqueueDurable: async () => { + send: async () => { admitted++; return { outcome: 'enqueued', message, entry }; }, diff --git a/packages/api/test/a2a-1577-queue-preview-seam.test.mjs b/packages/api/test/a2a-1577-queue-preview-seam.test.mjs index 1a53bcd5dea..baa9062eee5 100644 --- a/packages/api/test/a2a-1577-queue-preview-seam.test.mjs +++ b/packages/api/test/a2a-1577-queue-preview-seam.test.mjs @@ -19,7 +19,7 @@ test('real preview retains public connector identity and blocks without exposing replyTo: 'owned-reply', contentBlocks: [{ type: 'text', text: 'rich body' }], }; - const admitted = await queue.appendAndEnqueueDurable(messages, message, { + const admitted = await queue.send(messages, message, { ...message, kind: 'conversation_input', ownerAuthProvenance: 'unknown', diff --git a/packages/api/test/a2a-1577-refusal-seam.test.mjs b/packages/api/test/a2a-1577-refusal-seam.test.mjs index f5b5968b366..6e953ba9916 100644 --- a/packages/api/test/a2a-1577-refusal-seam.test.mjs +++ b/packages/api/test/a2a-1577-refusal-seam.test.mjs @@ -17,7 +17,6 @@ async function fixture() { messageStore: messages, invocationQueue: queue, threadStore: { get: async () => ({ createdBy: 'isolated-owner', participants: ['codex'] }) }, - queueProcessor: { processNext: async () => {} }, context: () => ({ resolvePrivate: async () => ({ admitted: true }) }), }); const task = { diff --git a/packages/api/test/a2a-1577-thread-context-seam.test.mjs b/packages/api/test/a2a-1577-thread-context-seam.test.mjs index b0c0e8668ec..8106c644f20 100644 --- a/packages/api/test/a2a-1577-thread-context-seam.test.mjs +++ b/packages/api/test/a2a-1577-thread-context-seam.test.mjs @@ -117,7 +117,7 @@ async function fixture(t, options = {}) { ...extra, }); const enqueue = (content = 'owned queued context body', extra = {}) => - queue.appendAndEnqueueDurable( + queue.send( messages, { userId, diff --git a/packages/api/test/a2a-1577-work-producer-seam.test.mjs b/packages/api/test/a2a-1577-work-producer-seam.test.mjs index bcfc794fbc4..e61159183af 100644 --- a/packages/api/test/a2a-1577-work-producer-seam.test.mjs +++ b/packages/api/test/a2a-1577-work-producer-seam.test.mjs @@ -8,17 +8,16 @@ import { CollectiveWorkDispatcher } from '../src/domains/plugin/builtin-runtime/ // deterministic domain port, not a claim about a real Collective grant/provider. function fixture() { const messages = new MessageStore(); - const queue = new InvocationQueue(); - const state = { available: true, starts: 0 }; + const state = { available: true, notifications: 0 }; + const queue = new InvocationQueue(undefined, { + onAdmitted: () => { + state.notifications++; + }, + }); const dispatcher = new CollectiveWorkDispatcher({ messageStore: messages, invocationQueue: queue, threadStore: { get: async () => ({ createdBy: 'isolated-owner', participants: ['codex'] }) }, - queueProcessor: { - processNext: async () => { - state.starts++; - }, - }, context: () => ({ resolvePrivate: async () => (state.available ? { admitted: true } : null) }), }); const task = { @@ -41,7 +40,7 @@ test('real Work producer writes narrow unknown scope, immutable receipt and one assert.deepEqual(entry.from, { kind: 'system', service: 'collective-work' }); assert.equal(f.messages.getById(first.messageId).extra.collectiveWorkInvocationV1.taskId, f.task.id); assert.deepEqual(f.queue.getQueuedBodyMessagesForCat(f.task.threadId, f.task.userId, 'codex'), []); - assert.equal(f.state.starts, 1); + assert.equal(f.state.notifications, 1); }); test('idempotent Work retry rechecks domain authority without relabeling or starting again', async () => { const f = fixture(); @@ -50,7 +49,7 @@ test('idempotent Work retry rechecks domain authority without relabeling or star await assert.rejects(f.dispatcher.dispatch(f.task, f.task.userId, 1, { kind: 'admission' }), { code: 'OWNER_ADMISSION_UNAVAILABLE', }); - assert.equal(f.state.starts, 1); + assert.equal(f.state.notifications, 1); assert.equal(f.queue.list(f.task.threadId, f.task.userId).length, 1); assert.equal(f.messages.getById(first.messageId).deliveryStatus, 'queued'); }); diff --git a/packages/api/test/a2a-parallel-fanout-custody.test.js b/packages/api/test/a2a-parallel-fanout-custody.test.js index f9bfdee4633..aa25e28972f 100644 --- a/packages/api/test/a2a-parallel-fanout-custody.test.js +++ b/packages/api/test/a2a-parallel-fanout-custody.test.js @@ -117,8 +117,12 @@ describe('Phase C multi_mention lifecycle fan-out', () => { creds = registry.register('opus', 'thread-par-1', 'user-1'); messageStore = new MessageStore(); callerResponse = appendLifecycleSource(messageStore, creds); - invocationQueue = new InvocationQueue(); queueProcessor = createMockQueueProcessor(); + invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); socketMessages = []; app = Fastify({ logger: false }); diff --git a/packages/api/test/approval-hub/dispatch-action-recovery.test.js b/packages/api/test/approval-hub/dispatch-action-recovery.test.js index f4104ae8562..c6829cf8a56 100644 --- a/packages/api/test/approval-hub/dispatch-action-recovery.test.js +++ b/packages/api/test/approval-hub/dispatch-action-recovery.test.js @@ -376,7 +376,17 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou import('../../dist/domains/cats/services/stores/ports/MessageStore.js'), import('../../dist/routes/callback-a2a-trigger.js'), ]); - const invocationQueue = new InvocationQueue(); + let autoExecuteCalls = 0; + const queueProcessor = { + async requestDrain() { + autoExecuteCalls += 1; + }, + }; + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); const messageStore = new MessageStore(); const triggerMessage = messageStore.append( canonicalTestMessageInput({ @@ -400,7 +410,6 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou ); assert.equal(triggerMessage.deliveryStatus, undefined, 'reproduce the persisted visible half-carrier'); - let autoExecuteCalls = 0; const result = await enqueueA2ATargets( { router: {}, @@ -410,11 +419,7 @@ test('recovery adopts the exact legacy-visible carrier as one queued custody sou broadcastToRoom() {}, emitToUser() {}, }, - queueProcessor: { - async requestDrain() { - autoExecuteCalls += 1; - }, - }, + queueProcessor, invocationQueue, messageStore, log: { info() {}, warn() {}, error() {} }, diff --git a/packages/api/test/callback-a2a-postmsg.test.js b/packages/api/test/callback-a2a-postmsg.test.js index 842f1923094..5991f14dc8f 100644 --- a/packages/api/test/callback-a2a-postmsg.test.js +++ b/packages/api/test/callback-a2a-postmsg.test.js @@ -318,7 +318,13 @@ describe('post_message A2A mention invocation', () => { registerEntryCompleteHook() {}, unregisterEntryCompleteHook() {}, }; - const invocationQueue = adaptInvocationQueue(new InvocationQueue()); + const invocationQueue = adaptInvocationQueue( + new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }), + ); const { invocationId, callbackToken } = await registry.create('user-1', 'opus', 't1'); const existing = invocationQueue.enqueue( canonicalTestQueueInput({ @@ -337,6 +343,7 @@ describe('post_message A2A mention invocation', () => { a2aTriggerMessageId: 'earlier-trigger', }), ).entry; + drainCalls.length = 0; const app = await createApp({ invocationQueue, queueProcessor }); const response = await app.inject({ @@ -511,6 +518,10 @@ describe('post_message A2A mention invocation', () => { // Message should still be stored const recent = messageStore.getRecent(10); assert.equal(recent.length, 1, 'Message should still be stored'); + assert.equal(response.json().isError, true, 'The sending agent must receive the target failure'); + assert.deepEqual(response.json().routed, [], 'An invalid A2A target must not choose a conversation fallback'); + assert.equal(response.json().messageId, recent[0].id, 'The failure reply must reference the durable source'); + assert.equal(invocationQueue.list('t1', 'user-1').length, 0); // No A2A invocation should be triggered for invalid catId assert.equal(invocationRecordStore.getRecords().length, 0, 'Invalid catId must not trigger A2A'); assert.equal(mockRouter.getExecutions().length, 0, 'routeExecution should not be called'); diff --git a/packages/api/test/callback-a2a-trigger.test.js b/packages/api/test/callback-a2a-trigger.test.js index e8f5397647e..edf5d7975a2 100644 --- a/packages/api/test/callback-a2a-trigger.test.js +++ b/packages/api/test/callback-a2a-trigger.test.js @@ -9,11 +9,15 @@ const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger. function setup() { const messageStore = new MessageStore(); - const invocationQueue = new InvocationQueue(); const events = []; const requestDrain = mock.fn(async () => { events.push('drain'); }); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void requestDrain(threadId); + }, + }); const tryAutoAppendExactEntry = mock.fn(async () => { events.push('append'); return { outcome: 'rejected' }; @@ -133,7 +137,7 @@ describe('enqueueA2ATargets single durable ledger', () => { assert.deepEqual(await enqueue(deps, trigger), { enqueued: [] }); assert.equal(invocationQueue.list('t1', 'u1').length, 0); - assert.deepEqual(events, ['observe', 'append', 'drain']); + assert.deepEqual(events, ['observe', 'drain']); }); it('keeps distinct source bodies as distinct work orders instead of concatenating them', async () => { @@ -184,7 +188,7 @@ describe('enqueueA2ATargets single durable ledger', () => { assert.equal(invocationQueue.list('t1', 'u1').length, 10); }); - it('offers the row to Active Append without reintroducing ordinary Ball custody', async () => { + it('delegates the admitted row to Queue without ingress-owned Append or Ball custody', async () => { const { deps, events, appendTrigger } = setup(); deps.ballCustody = { record: mock.fn(async () => { @@ -195,7 +199,7 @@ describe('enqueueA2ATargets single durable ledger', () => { await enqueue(deps, trigger); - assert.deepEqual(events, ['observe', 'append', 'drain']); + assert.deepEqual(events, ['observe', 'drain']); }); it('rejects sources that are not persisted public Agent messages', async () => { diff --git a/packages/api/test/callback-message-identity.test.js b/packages/api/test/callback-message-identity.test.js index bda83d721d5..7ae8b752d24 100644 --- a/packages/api/test/callback-message-identity.test.js +++ b/packages/api/test/callback-message-identity.test.js @@ -98,9 +98,9 @@ for (const transport of ['invocation', 'agent-key']) { test(`${transport}: failed atomic admission does not consume the message ID`, async (t) => { const h = await harness(t, transport); - const realAdmission = h.invocationQueue.appendAndEnqueueDurable.bind(h.invocationQueue); + const realAdmission = h.invocationQueue.send.bind(h.invocationQueue); let fail = true; - h.invocationQueue.appendAndEnqueueDurable = async (...args) => { + h.invocationQueue.send = async (...args) => { if (fail) { fail = false; throw new Error('isolated transaction failure'); diff --git a/packages/api/test/callback-routes.test.js b/packages/api/test/callback-routes.test.js index 65860fdeb29..b2db1817e67 100644 --- a/packages/api/test/callback-routes.test.js +++ b/packages/api/test/callback-routes.test.js @@ -5235,7 +5235,7 @@ describe('Callback Routes', () => { timestamp: 100, threadId, }); - const admission = await invocationQueue.appendAndEnqueueDurable( + const admission = await invocationQueue.send( messageStore, { userId: 'user-1', @@ -5594,7 +5594,7 @@ describe('Callback Routes', () => { assert.equal(JSON.parse(history.body).contextScope, 'recent_history'); }); - test('full thread-context adopts an ordinary review return and removes the delivered target from Queue', async () => { + test('full thread-context adopts an explicitly continuing review return and removes the delivered target from Queue', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); invocationQueue = adaptInvocationQueue(new InvocationQueue()); const threadId = 'thread-queued-cat-dedup'; @@ -5607,6 +5607,7 @@ describe('Callback Routes', () => { threadId, deliveryStatus: 'queued', }); + const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); const queued = invocationQueue.enqueue({ kind: 'message_wake', ownerAuthProvenance: 'unknown', @@ -5616,11 +5617,11 @@ describe('Callback Routes', () => { messageId: stored.id, source: 'agent', sourceCategory: 'review', + authorIntentByCatId: { opus: { requested: 'continue_current', boundParentInvocationId: invocationId } }, targetCats: ['opus'], intent: 'execute', callerCatId: 'codex', }); - const { invocationId, callbackToken } = await registry.create('user-1', 'opus', threadId); const { processor, invocationTracker, turnExecutionStore } = await createQueuedReadProcessor({ threadId, invocationId, diff --git a/packages/api/test/cloud-delivery-retry.test.js b/packages/api/test/cloud-delivery-retry.test.js index 15f6ecb8ce9..b8e3d91766e 100644 --- a/packages/api/test/cloud-delivery-retry.test.js +++ b/packages/api/test/cloud-delivery-retry.test.js @@ -20,18 +20,15 @@ describe('cloud delivery retry', () => { const { messagesRoutes } = await import('../dist/routes/messages.js'); messageStore = new MessageStore(); - invocationQueue = new InvocationQueue(new InMemoryQueueLedgerStore()); drainRequests = []; + invocationQueue = new InvocationQueue(new InMemoryQueueLedgerStore(), { + onAdmitted: ({ threadId }) => drainRequests.push(threadId), + }); app = Fastify(); await app.register(messagesRoutes, { registry: new InvocationRegistry(), messageStore, invocationQueue, - queueProcessor: { - requestDrain(threadId) { - drainRequests.push(threadId); - }, - }, socketManager: { emitToUser() {} }, router: { async resolveExplicitTargets(targets) { @@ -102,6 +99,7 @@ describe('cloud delivery retry', () => { const [entry] = invocationQueue.list(source.threadId, 'default-user'); assert.equal(entry.payload.sourceRecordId, retry.id); assert.deepEqual(entry.targets, ['gpt-pro']); + assert.equal(entry.delivery.authorIntentByTarget['gpt-pro'].requested, 'next_work'); assert.deepEqual(drainRequests, [source.threadId]); }); diff --git a/packages/api/test/collective-ingress-dispatcher.test.js b/packages/api/test/collective-ingress-dispatcher.test.js index 7c271cc1945..0ffe31e96b6 100644 --- a/packages/api/test/collective-ingress-dispatcher.test.js +++ b/packages/api/test/collective-ingress-dispatcher.test.js @@ -142,7 +142,7 @@ function harness(events, options = {}) { appendIdempotent: async (input) => persistMessage(input), }, invocationQueue: { - appendAndEnqueueDurable: async (_messageStore, messageInput, input) => { + send: async (_messageStore, messageInput, input) => { if (queueFullRemaining > 0) { queueFullRemaining -= 1; return { outcome: 'full' }; @@ -166,6 +166,8 @@ function harness(events, options = {}) { enqueuedAt: Date.now(), }; queue.push(entry); + await input.onQueueEntriesAdmitted?.([entry], message); + processed.push({ threadId: input.threadId, ownerId: input.userId }); return { outcome: 'enqueued', message, entry, entries: [entry], deduped: false }; }, }, diff --git a/packages/api/test/collective-owner-participation.test.js b/packages/api/test/collective-owner-participation.test.js index e60e448d225..fdcf4425265 100644 --- a/packages/api/test/collective-owner-participation.test.js +++ b/packages/api/test/collective-owner-participation.test.js @@ -21,12 +21,16 @@ const userId = writeHeaders['x-test-session-user']; const base = '/api/plugins/collective-connector/con_aaaaaaaa'; async function harness(delayThreadWrites = false, initialExcludedCatIds = []) { const messages = adaptMessageStore(new MessageStore()); - const persisted = createPersistedQueueFixture(messages); + const admittedWorkSourceIds = new Set(); + const persisted = createPersistedQueueFixture(messages, { + onAdmitted: (message) => { + if (message?.extra?.collectiveWorkInvocationV1) admittedWorkSourceIds.add(message.id); + }, + }); const tasks = new TaskStore(); const threads = new ThreadStore(); let route; let published = false; - let starts = 0; let inbox = []; let assignedWork; let cats = [ @@ -106,11 +110,6 @@ async function harness(delayThreadWrites = false, initialExcludedCatIds = []) { messageStore: messages, threadStore: threads, invocationQueue: persisted.queue, - queueProcessor: { - async processNext() { - starts++; - }, - }, }); const app = Fastify(); app.addHook('preHandler', async (request) => { @@ -208,7 +207,7 @@ async function harness(delayThreadWrites = false, initialExcludedCatIds = []) { source, sourceInput, committedSource, - starts: () => starts, + workSources: () => admittedWorkSourceIds.size, route: () => route, setCats(next) { cats = next; @@ -488,7 +487,7 @@ test('owner view distinguishes a routed named request, private execution, and a deliveryStatus: 'queued', idempotencyKey: 'collective:progress', }; - const admitted = await f.persisted.queue.appendAndEnqueueDurable(f.messages, input, { + const admitted = await f.persisted.queue.send(f.messages, input, { threadId, userId, from: input.from, @@ -743,7 +742,7 @@ test('concurrent owner retries cannot create orphan public or private Threads', ); assert.equal(f.threads.list(userId).length, 2); assert.equal(f.tasks.listByKind('work').length, 1); - assert.equal(f.starts(), 1); + assert.equal(f.workSources(), 1); } finally { await f.app.close(); } @@ -799,7 +798,7 @@ test('the real owner action admits one canonical Work and resumes its exact sour const second = await f.post('/work/admit', { ...payload, requestId: randomUUID() }); assert.equal(second.statusCode, 200, second.payload); assert.equal(first.json().messageId, second.json().messageId); - assert.equal(f.starts(), 1); + assert.equal(f.workSources(), 1); assert.equal(f.tasks.listByKind('work').length, 1); const task = f.tasks.listByKind('work')[0]; assert.deepEqual(task.entrustedWork.admission.sourceRefs, [`message:${source.id}`]); @@ -813,14 +812,14 @@ test('the real owner action admits one canonical Work and resumes its exact sour }); assert.equal(resumed.statusCode, 200, resumed.payload); assert.equal(f.messages.getById(resumed.json().messageId).extra.collectiveWorkInvocationV1.resultRevision, 2); - assert.equal(f.starts(), 2); + assert.equal(f.workSources(), 2); const forged = await f.post('/work/resume', { taskId: task.id, observedRevision: 999, requestId: randomUUID() }); assert.equal(forged.statusCode, 409); - assert.equal(f.starts(), 2); + assert.equal(f.workSources(), 2); await f.put({ ...join, enabled: false, expectedRevision: 1 }); const revoked = await f.post('/work/resume', { taskId: task.id, observedRevision: 1, requestId: randomUUID() }); assert.equal(revoked.statusCode, 409); - assert.equal(f.starts(), 2); + assert.equal(f.workSources(), 2); assert.equal(f.tasks.get(task.id).status, 'todo'); assert.ok(f.messages.getById(source.id)); } finally { @@ -847,7 +846,7 @@ test('manual resume fails closed while Service revision feedback has not reached assert.equal(resumed.statusCode, 409, resumed.payload); assert.equal(resumed.json().code, 'COLLECTIVE_WORK_CONTINUATION_UNAVAILABLE'); - assert.equal(f.starts(), 1); + assert.equal(f.workSources(), 1); } finally { await f.app.close(); } @@ -875,7 +874,7 @@ test('manual resume rejects a retained revision notice after the revised Service assert.equal(resumed.statusCode, 409, resumed.payload); assert.equal(resumed.json().code, 'COLLECTIVE_WORK_CONTINUATION_UNAVAILABLE'); - assert.equal(f.starts(), 1); + assert.equal(f.workSources(), 1); } finally { await f.app.close(); } diff --git a/packages/api/test/collective-queue-recovery.test.js b/packages/api/test/collective-queue-recovery.test.js index 8a355dbfe3d..66256cf1487 100644 --- a/packages/api/test/collective-queue-recovery.test.js +++ b/packages/api/test/collective-queue-recovery.test.js @@ -33,7 +33,7 @@ test('Queue restart retains singleton origin and scope; adjacent Channel request const queue = new InvocationQueue(ledger); const messages = new MessageStore(); const admit = (sourceId, content) => - queue.appendAndEnqueueDurable( + queue.send( messages, { userId: publicInput.userId, diff --git a/packages/api/test/collective-work-return.test.js b/packages/api/test/collective-work-return.test.js index 89aede2381e..5c26b861755 100644 --- a/packages/api/test/collective-work-return.test.js +++ b/packages/api/test/collective-work-return.test.js @@ -387,30 +387,27 @@ test('admission response loss and Host restart recover one durable execution ins const f = fixture(); const result = await admit(f); const task = f.tasks.get(result.subjectRef.slice(10)); - let starts = 0; + const workSources = () => + f.messages.getByThreadIncludingQueued(task.threadId).filter((message) => message.extra?.collectiveWorkInvocationV1) + .length; const restart = () => new CollectiveWorkDispatcher({ context: f.restart, messageStore: f.messages, threadStore: { get: () => ({ createdBy: 'owner', participants: ['codex-astra'] }) }, invocationQueue: new InvocationQueue(), - queueProcessor: { - async processNext() { - starts++; - }, - }, }); const first = await restart().dispatch(task, 'owner', result.revision, { kind: 'admission' }); f.messages.getById(first.messageId).deliveryStatus = 'delivered'; const replay = await restart().dispatch(task, 'owner', result.revision, { kind: 'admission' }); assert.equal(replay.messageId, first.messageId); - assert.equal(starts, 1); + assert.equal(workSources(), 1); // An explicit owner retry has its own stable request, and retrying that request is also durable. const resume = { kind: 'resume', requestId: randomUUID() }; const next = await restart().dispatch(task, 'owner', result.revision, resume); assert.notEqual(next.messageId, first.messageId); await restart().dispatch(task, 'owner', result.revision, resume); - assert.equal(starts, 2); + assert.equal(workSources(), 2); }); test('Work revision and restart re-sign refs while preserving the same result operation and original author', async () => { diff --git a/packages/api/test/connector-router-hub-thread-race.test.js b/packages/api/test/connector-router-hub-thread-race.test.js index c640c5dcdc0..d7cd7cc7dc6 100644 --- a/packages/api/test/connector-router-hub-thread-race.test.js +++ b/packages/api/test/connector-router-hub-thread-race.test.js @@ -34,6 +34,9 @@ function mockMessageStore() { store.admitted = []; store.delivery = { async deliver(input) { + store.requests ??= []; + store.requests.push(input); + input = { ...input, targetCatId: input.targetCatId ?? this.defaultTarget ?? 'opus' }; const msg = { id: `msg-${messages.length + 1}`, ...input, diff --git a/packages/api/test/connector-router-media.test.js b/packages/api/test/connector-router-media.test.js index 41b307f7c68..abe5355c4a2 100644 --- a/packages/api/test/connector-router-media.test.js +++ b/packages/api/test/connector-router-media.test.js @@ -26,6 +26,7 @@ function makeMockDeps(overrides = {}) { // RFC §5.1: the router hands an envelope to the one durable-admission component. persistedQueueDelivery: { async deliver(input) { + input = { ...input, targetCatId: input.targetCatId ?? 'opus' }; messages.push({ ...input, mentions: [input.targetCatId], deliveryStatus: 'queued' }); const id = `msg-${messages.length}`; return { state: 'started', entryId: `entry-${messages.length}`, message: { id, ...input } }; diff --git a/packages/api/test/connector-router.test.js b/packages/api/test/connector-router.test.js index d49839d67f9..11f33036aaf 100644 --- a/packages/api/test/connector-router.test.js +++ b/packages/api/test/connector-router.test.js @@ -35,6 +35,9 @@ function mockMessageStore(opts = {}) { store.admitted = []; store.delivery = { async deliver(input) { + store.requests ??= []; + store.requests.push(input); + input = { ...input, targetCatId: input.targetCatId ?? this.defaultTarget ?? 'opus' }; const msg = { id: `msg-${messages.length + 1}`, ...input, @@ -250,7 +253,7 @@ describe('ConnectorRouter', () => { assert.ok(messageStore.messages[0].threadId); }); - it('routes to last-active cat when no @mention is present', async () => { + it('delegates no-mention targets to common send instead of local participant activity', async () => { const thread = threadStore.create('owner-1', 'existing'); bindingStore.bind('feishu', 'chat-last-active', thread.id, 'owner-1'); threadStore.participantActivity.set(thread.id, [ @@ -261,8 +264,9 @@ describe('ConnectorRouter', () => { await router.route('feishu', 'chat-last-active', '继续', 'ext-last-active-1'); assert.equal(messageStore.messages.length, 1, 'one admitted envelope, which is the wake'); - assert.equal(messageStore.messages[0].targetCatId, 'codex'); - assert.deepEqual(messageStore.messages[0].mentions, ['codex']); + assert.equal(messageStore.requests[0].targetCatId, undefined); + assert.equal(messageStore.messages[0].targetCatId, 'opus'); + assert.deepEqual(messageStore.messages[0].mentions, ['opus']); }); it('keeps explicit @mention priority over last-active cat', async () => { @@ -309,6 +313,7 @@ describe('ConnectorRouter', () => { await dynamicRouter.route('feishu', 'chat-dynamic-default', 'first', 'ext-dynamic-1'); currentDefault = 'codex'; + messageStore.delivery.defaultTarget = currentDefault; await dynamicRouter.route('feishu', 'chat-dynamic-default', 'second', 'ext-dynamic-2'); assert.equal(messageStore.messages[0].targetCatId, 'opus'); diff --git a/packages/api/test/direct-action-carrier-recovery-e2e.test.js b/packages/api/test/direct-action-carrier-recovery-e2e.test.js index 6fc26207a66..074572a94ae 100644 --- a/packages/api/test/direct-action-carrier-recovery-e2e.test.js +++ b/packages/api/test/direct-action-carrier-recovery-e2e.test.js @@ -210,7 +210,7 @@ describe('direct action carrier restart recovery', () => { await appendCarrier(messageStore, invocationQueue, lease, 'interrupted', stores); const clientMessageId = 'review-4058-crash-after-append'; const fence = buildActionSuccessorFence(lease, lease.dispatchId); - const replacement = await invocationQueue.appendAndEnqueueDurable( + const replacement = await invocationQueue.send( messageStore, { threadId: target.id, @@ -254,9 +254,9 @@ describe('direct action carrier restart recovery', () => { test('consecutive atomic failures publish no replacement, and the first healthy retry admits exactly one entry', async () => { await appendCarrier(messageStore, invocationQueue, lease, 'interrupted', stores); - const healthyAdmission = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); + const healthyAdmission = invocationQueue.send.bind(invocationQueue); let failedAdmissions = 0; - invocationQueue.appendAndEnqueueDurable = () => { + invocationQueue.send = () => { failedAdmissions++; throw new Error('admission store unavailable'); }; @@ -270,7 +270,7 @@ describe('direct action carrier restart recovery', () => { assert.equal(replacement(), null, 'failed atomic admission must publish neither source nor pending work'); assert.equal(invocationQueue.list(target.id, 'user-1').length, 0); - invocationQueue.appendAndEnqueueDurable = healthyAdmission; + invocationQueue.send = healthyAdmission; const third = await post('review-4058-retry'); assert.equal(third.statusCode, 200, third.body); const queued = invocationQueue.list(target.id, 'user-1'); @@ -285,7 +285,7 @@ describe('direct action carrier restart recovery', () => { test('a failure BEFORE durable admission is not promised to startup reconciliation', async () => { await appendCarrier(messageStore, invocationQueue, lease, 'interrupted', stores); let attempts = 0; - invocationQueue.appendAndEnqueueDurable = () => { + invocationQueue.send = () => { attempts++; throw new Error('admission write unavailable'); }; @@ -300,9 +300,9 @@ describe('direct action carrier restart recovery', () => { }); test('lost atomic commit receipt replays the existing carrier without a second admission', async () => { await appendCarrier(messageStore, invocationQueue, lease, 'interrupted', stores); - const healthyAdmission = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); + const healthyAdmission = invocationQueue.send.bind(invocationQueue); let commits = 0; - invocationQueue.appendAndEnqueueDurable = async (...args) => { + invocationQueue.send = async (...args) => { commits++; await healthyAdmission(...args); throw new Error('commit receipt lost'); diff --git a/packages/api/test/direct-action-carrier-refresh-e2e.test.js b/packages/api/test/direct-action-carrier-refresh-e2e.test.js index 090c2291712..3b7df29d25d 100644 --- a/packages/api/test/direct-action-carrier-refresh-e2e.test.js +++ b/packages/api/test/direct-action-carrier-refresh-e2e.test.js @@ -164,7 +164,7 @@ describe('direct action carrier refresh, route to provider', () => { leaseStore.install(lease); const fence = buildActionSuccessorFence(lease, lease.dispatchId); const from = { kind: 'agent', catId: PREDECESSOR }; - const queued = await queue.appendAndEnqueueDurable( + const queued = await queue.send( messages, { userId: 'user-1', @@ -217,7 +217,11 @@ describe('direct action carrier refresh, route to provider', () => { leaseStore = createLeaseStore(undefined); app = Fastify(); messages = new MessageStore(); - queue = new InvocationQueue(); + queue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void processor.requestDrain(threadId); + }, + }); records = new InvocationRecordStore(); registry = new InvocationRegistry(); unavailable = []; @@ -466,7 +470,7 @@ describe('direct action carrier refresh, route to provider', () => { const from = { kind: 'agent', catId: PREDECESSOR }; await assert.rejects( - queue.appendAndEnqueueDurable( + queue.send( messages, { threadId: target.id, diff --git a/packages/api/test/f088-gateway-integration.test.js b/packages/api/test/f088-gateway-integration.test.js index 9179cad7069..db859ecb595 100644 --- a/packages/api/test/f088-gateway-integration.test.js +++ b/packages/api/test/f088-gateway-integration.test.js @@ -12,6 +12,7 @@ import { InboundMessageDedup } from '../dist/infrastructure/connectors/InboundMe import { FeishuAdapter } from '../dist/infrastructure/connectors/im-connectors/feishu/FeishuAdapter.js'; import { TelegramAdapter } from '../dist/infrastructure/connectors/im-connectors/telegram/TelegramAdapter.js'; import { OutboundDeliveryHook } from '../dist/infrastructure/connectors/OutboundDeliveryHook.js'; +import { connectorDeliveryHarness } from './helpers/connector-delivery-harness.js'; function assertFeishuCardContains(content, expectedHeader, expectedBody) { const parsed = JSON.parse(content); @@ -37,17 +38,8 @@ function buildTestHarness() { const log = noopLog(); const bindingStore = new MemoryConnectorThreadBindingStore(); const dedup = new InboundMessageDedup(); - const messageStore = { - messages: [], - async append(input) { - const msg = { - id: `msg-${this.messages.length + 1}`, - ...input, - }; - this.messages.push(msg); - return msg; - }, - }; + const deliveryHarness = connectorDeliveryHarness(); + const messageStore = deliveryHarness.messageStore; let threadCounter = 0; const threadStore = { create(userId, title) { @@ -55,12 +47,7 @@ function buildTestHarness() { return { id: `thread-${threadCounter}`, createdBy: userId, title }; }, }; - const triggerCalls = []; - const invokeTrigger = { - trigger(threadId, catId, userId, message, messageId) { - triggerCalls.push({ threadId, catId, userId, message, messageId }); - }, - }; + const triggerCalls = deliveryHarness.wakes; const broadcasts = []; const socketManager = { broadcastToRoom(room, event, data) { @@ -92,29 +79,9 @@ function buildTestHarness() { log, }); - // RFC §5.1/§5.2: admission to the Queue IS the wake, so the durable-admission component is the - // seam a connector input crosses — there is no second trigger call to observe. - const persistedQueueDelivery = { - async deliver(input) { - const msg = { - id: `msg-${messageStore.messages.length + 1}`, - ...input, - userId: input.ownerUserId, - mentions: [input.targetCatId], - deliveryStatus: 'queued', - from: { kind: 'external', connectorId: input.source.connector }, - }; - messageStore.messages.push(msg); - triggerCalls.push({ - threadId: input.threadId, - catId: input.targetCatId, - userId: input.ownerUserId, - message: input.content, - messageId: msg.id, - }); - return { state: 'started', entryId: `entry-${messageStore.messages.length}`, message: msg }; - }, - }; + // Exercise real PersistedQueueDelivery → common send → Message + Queue admission. + // The shared Queue resolver, not this connector fixture, selects the default target. + const persistedQueueDelivery = deliveryHarness.delivery; const router = new ConnectorRouter({ bindingStore, @@ -122,7 +89,6 @@ function buildTestHarness() { messageStore, persistedQueueDelivery, threadStore, - invokeTrigger, socketManager, defaultUserId: 'owner-1', defaultCatId: 'opus', @@ -135,6 +101,10 @@ function buildTestHarness() { bindingStore, messageStore, triggerCalls, + messages: () => + Array.from({ length: threadCounter }, (_, index) => `thread-${index + 1}`).flatMap((threadId) => + messageStore.getByThreadIncludingQueued(threadId, 100, 'owner-1'), + ), telegramSent, feishuSent, broadcasts, @@ -166,9 +136,9 @@ describe('F088 Gateway Integration', () => { assert.ok(result.threadId); // 3. Verify message posted to store - assert.equal(h.messageStore.messages.length, 1); - assert.equal(h.messageStore.messages[0].source.connector, 'telegram'); - assert.equal(h.messageStore.messages[0].content, 'Hello from Telegram!'); + assert.equal(h.messages().length, 1); + assert.equal(h.messages()[0].source.connector, 'telegram'); + assert.equal(h.messages()[0].content, 'Hello from Telegram!'); // 4. Verify cat invocation triggered assert.equal(h.triggerCalls.length, 1); @@ -214,8 +184,8 @@ describe('F088 Gateway Integration', () => { assert.equal(result.kind, 'routed'); // 3. Verify connector source - assert.equal(h.messageStore.messages[0].source.connector, 'feishu'); - assert.equal(h.messageStore.messages[0].source.label, '飞书'); + assert.equal(h.messages()[0].source.connector, 'feishu'); + assert.equal(h.messages()[0].source.label, '飞书'); // 4. Outbound delivery await h.outboundHook.deliver(result.threadId, '猫猫回复!'); @@ -235,7 +205,7 @@ describe('F088 Gateway Integration', () => { assert.equal(r1.kind, 'routed'); assert.equal(r2.kind, 'skipped'); assert.equal(h.triggerCalls.length, 1); - assert.equal(h.messageStore.messages.length, 1); + assert.equal(h.messages().length, 1); }); }); @@ -249,7 +219,7 @@ describe('F088 Gateway Integration', () => { assert.equal(r1.kind, 'routed'); assert.equal(r2.kind, 'routed'); assert.equal(r1.threadId, r2.threadId); - assert.equal(h.messageStore.messages.length, 2); + assert.equal(h.messages().length, 2); }); }); @@ -309,7 +279,7 @@ describe('F088 Gateway Integration', () => { assert.equal(h.triggerCalls[0].catId, 'codex'); // 3. Verify mentions stored in message - assert.deepEqual(h.messageStore.messages[0].mentions, ['codex']); + assert.deepEqual(h.messages()[0].mentions, ['codex']); // 4. Simulate outbound with codex identity await h.outboundHook.deliver(r.threadId, 'LGTM!', 'codex'); @@ -323,7 +293,7 @@ describe('F088 Gateway Integration', () => { const r = await h.router.route('feishu', 'fs-chat', '@布偶猫 帮我看看这个', 'fs-mention-1'); assert.equal(r.kind, 'routed'); assert.equal(h.triggerCalls[0].catId, 'opus'); - assert.deepEqual(h.messageStore.messages[0].mentions, ['opus']); + assert.deepEqual(h.messages()[0].mentions, ['opus']); await h.outboundHook.deliver(r.threadId, '好的!', 'opus'); assertFeishuCardContains(h.feishuSent[0].content, '🐱 布偶猫', '好的!'); diff --git a/packages/api/test/f247-serial-cloud-source.test.js b/packages/api/test/f247-serial-cloud-source.test.js index 1add433836d..acaa09c16e7 100644 --- a/packages/api/test/f247-serial-cloud-source.test.js +++ b/packages/api/test/f247-serial-cloud-source.test.js @@ -25,7 +25,7 @@ async function dispatch({ const records = new InvocationRecordStore(); const turns = new InMemoryTurnExecutionStore(); const initialTarget = directCloud ? 'gpt-pro' : 'opus'; - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messageStore, { userId: 'alice', diff --git a/packages/api/test/f264-author-message-disposition.test.js b/packages/api/test/f264-author-message-disposition.test.js index 8599db0fb58..e097218537d 100644 --- a/packages/api/test/f264-author-message-disposition.test.js +++ b/packages/api/test/f264-author-message-disposition.test.js @@ -6,12 +6,12 @@ import { beforeEach, describe, test } from 'node:test'; import { adaptInvocationQueue } from './helpers/message-from-fixtures.js'; const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); -const { saveMessageDispositionPreference } = await import('../dist/config/user-preferences-store.js'); -const { - resolveFreshnessCarrierCapabilityOrUndeclared, - resolveMessageDispositionForAdmission, - resolveQueueAuthorIntentByCatId, -} = await import('../dist/routes/message-disposition-admission.js'); +const { saveMessageDispositionPreference, resolveMessageDispositionPreference } = await import( + '../dist/config/user-preferences-store.js' +); +const { resolveFreshnessCarrierCapabilityOrUndeclared, resolveQueueAuthorIntentByCatId } = await import( + '../dist/routes/message-disposition-admission.js' +); const { sendMessageSchema } = await import('../dist/routes/messages.schema.js'); function entry(overrides = {}) { @@ -70,7 +70,7 @@ describe('F264 author-declared message disposition', () => { ); }); - test('current-parent exposure is source-domain aware and ignores author intent outside user messages', () => { + test('current-parent exposure obeys the same saved strategy and parent for every source', () => { const cases = [ { name: 'legacy user without intent stays next-work', @@ -98,24 +98,24 @@ describe('F264 author-declared message disposition', () => { readable: true, }, { - name: 'agent A2A remains readable without author intent', + name: 'agent A2A uses the saved product next-work default', entry: entry({ content: 'agent custody', source: 'agent', sourceCategory: 'a2a' }), - readable: true, + readable: false, }, { - name: 'connector event remains readable without author intent', + name: 'connector event uses the saved product next-work default', entry: entry({ content: 'connector custody', source: 'connector', sourceCategory: 'review' }), - readable: true, + readable: false, }, { - name: 'non-user custody ignores a stray next-work-shaped field', + name: 'non-user explicit next-work stays isolated', entry: entry({ content: 'connector with polluted field', source: 'connector', sourceCategory: 'ci', authorIntentByCatId: { opus: { requested: 'next_work' } }, }), - readable: true, + readable: false, }, ]; @@ -352,26 +352,18 @@ describe('F264 author-declared message disposition', () => { }); }); - test('server admission resolves thread over global while an explicit one-shot wins', async () => { + test('scoped preference resolves thread over global without materializing author intent', async () => { const projectRoot = await mkdtemp(join(tmpdir(), 'f264-disposition-admission-')); try { saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'continue_current' }); - assert.equal(resolveMessageDispositionForAdmission({ projectRoot, threadId: 'thread-a' }), 'continue_current'); + assert.equal(resolveMessageDispositionPreference(projectRoot, 'thread-a').effective, 'continue_current'); saveMessageDispositionPreference(projectRoot, { scope: 'thread', threadId: 'thread-a', disposition: 'next_work', }); - assert.equal(resolveMessageDispositionForAdmission({ projectRoot, threadId: 'thread-a' }), 'next_work'); - assert.equal( - resolveMessageDispositionForAdmission({ - explicit: 'continue_current', - projectRoot, - threadId: 'thread-a', - }), - 'continue_current', - ); + assert.equal(resolveMessageDispositionPreference(projectRoot, 'thread-a').effective, 'next_work'); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/packages/api/test/f264-coalesced-body-exposure.test.mjs b/packages/api/test/f264-coalesced-body-exposure.test.mjs index 194c524b65c..1fe5387098d 100644 --- a/packages/api/test/f264-coalesced-body-exposure.test.mjs +++ b/packages/api/test/f264-coalesced-body-exposure.test.mjs @@ -7,7 +7,7 @@ test('a source appended after delivery keeps its own pending entry until exact r t.after(f.close); assert.equal((await f.read()).statusCode, 200); const from = { kind: 'agent', catId: 'opus' }; - const second = await f.queue.appendAndEnqueueDurable( + const second = await f.queue.send( f.store, { userId: 'owner', diff --git a/packages/api/test/f290-communication-bootstrap.fixture.ts b/packages/api/test/f290-communication-bootstrap.fixture.ts index b5a6a3eceb6..d08048aa437 100644 --- a/packages/api/test/f290-communication-bootstrap.fixture.ts +++ b/packages/api/test/f290-communication-bootstrap.fixture.ts @@ -63,7 +63,6 @@ export async function bootstrapFixture({ persistBirth = true } = {}) { messageStore: messages, threadStore: threads, invocationQueue: queue, - queueProcessor: { async processNext() {} }, }); const admission = new CollectiveWorkAdmission({ connector: () => cafe.connector, authority, tasks, dispatcher }); const persist = async (eventId: string) => { diff --git a/packages/api/test/f290-communication-current-execution.fixture.ts b/packages/api/test/f290-communication-current-execution.fixture.ts index f162457c55d..d81a42740a7 100644 --- a/packages/api/test/f290-communication-current-execution.fixture.ts +++ b/packages/api/test/f290-communication-current-execution.fixture.ts @@ -68,11 +68,6 @@ export async function fixture(options: { now?: () => number; expiresAt?: string messageStore: messages, threadStore: threads, invocationQueue: queue, - queueProcessor: { - async processNext() { - return { started: false }; - }, - }, }); const admission = new CollectiveWorkAdmission({ connector: () => cafe.connector, authority, tasks, dispatcher }); const persist = async (eventId: string) => { diff --git a/packages/api/test/f290-communication-queue-refusal.test.ts b/packages/api/test/f290-communication-queue-refusal.test.ts index a916a0127a2..2f2e563837d 100644 --- a/packages/api/test/f290-communication-queue-refusal.test.ts +++ b/packages/api/test/f290-communication-queue-refusal.test.ts @@ -164,7 +164,7 @@ function fixture(refuse: (source: string) => unknown, unsupported = false, retry ); async function enqueue(content: string) { const [taskId, revision] = content.split('@'); - return queue.appendAndEnqueueDurable( + return queue.send( messages, { userId, diff --git a/packages/api/test/f290-communication-reconsideration-queue.fixture.ts b/packages/api/test/f290-communication-reconsideration-queue.fixture.ts index dda3cc56d6a..ddfc3be1d3a 100644 --- a/packages/api/test/f290-communication-reconsideration-queue.fixture.ts +++ b/packages/api/test/f290-communication-reconsideration-queue.fixture.ts @@ -175,7 +175,7 @@ export function reconsiderationQueueFixture(useSourceGuard = true) { participationRevision: 1, actor: { kind: 'human' as const, humanId: 'human_fixture000', displayName: 'Fixture Owner' }, }; - const result = await queue.appendAndEnqueueDurable( + const result = await queue.send( messages, { userId, diff --git a/packages/api/test/f290-communication-reconsideration.fixture.ts b/packages/api/test/f290-communication-reconsideration.fixture.ts index a4012fd21bf..4323418df05 100644 --- a/packages/api/test/f290-communication-reconsideration.fixture.ts +++ b/packages/api/test/f290-communication-reconsideration.fixture.ts @@ -20,7 +20,11 @@ import { CAT } from './f290-communication-validation.host.js'; export async function fixture() { const f = await manualFixture(); const ledger = new InMemoryQueueLedgerStore(); - const queue = new InvocationQueue(ledger); + const queue = new InvocationQueue(ledger, { + onAdmitted: ({ threadId, entries }) => { + if (enabled) void processor.requestDrain(threadId, entries[0]!.owner.userId).catch(() => {}); + }, + }); const turns = new InMemoryTurnExecutionStore(); const registry = new InvocationRegistry(); const records = new Map>(); @@ -199,9 +203,6 @@ export async function fixture() { }, reconsideration: { queue, - processor: { - processNext: (...args) => (enabled ? processor.processNext(...args) : Promise.resolve({ started: false })), - }, }, }); await registerCollectiveParticipationCallbacks(app, { registry, context: f.host.context }); diff --git a/packages/api/test/f290-communication-validation.host.ts b/packages/api/test/f290-communication-validation.host.ts index 8b1870fae30..0b77d0d141a 100644 --- a/packages/api/test/f290-communication-validation.host.ts +++ b/packages/api/test/f290-communication-validation.host.ts @@ -105,7 +105,11 @@ export async function createHost(world: World, cafe: Cafe) { workAuthority: authority, artifactReader: new F232PreparedArtifactReader({ messages }), }); - const queue = new InvocationQueue(); + const queue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); const tracker = new InvocationTracker(); const turnExecutions = new InMemoryTurnExecutionStore(); const registry = new InvocationRegistry(); @@ -402,7 +406,6 @@ export async function createHost(world: World, cafe: Cafe) { threadStore: threads, messageStore: messages, invocationQueue: queue, - queueProcessor, socketManager: { broadcastToRoom() {}, emitToUser() {} }, isCatAvailable: () => true, // Mirrors api/src/index.ts admitStandingWork / resumeWorkRevision. diff --git a/packages/api/test/f290-communication-work-provenance.test.ts b/packages/api/test/f290-communication-work-provenance.test.ts index 90cb9fa62aa..36753afcfaa 100644 --- a/packages/api/test/f290-communication-work-provenance.test.ts +++ b/packages/api/test/f290-communication-work-provenance.test.ts @@ -202,7 +202,7 @@ test('canonical private Work Queue hydrate keeps immutable owner provenance with const queue = new InvocationQueue(ledger); const messages = new MessageStore(); const from = { kind: 'system' as const, service: 'collective-work' }; - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messages, { userId: 'owner', diff --git a/packages/api/test/f295-active-execution-projection.test.js b/packages/api/test/f295-active-execution-projection.test.js index 9f982cba22b..57f8e13e9ef 100644 --- a/packages/api/test/f295-active-execution-projection.test.js +++ b/packages/api/test/f295-active-execution-projection.test.js @@ -168,7 +168,12 @@ function buildDeps() { return { cancelled: true, catIds: [catId], executionIds: [execution.executionId] }; }, }; - const invocationQueue = new InvocationQueue(); + const requestDrain = mock.fn(async () => {}); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void requestDrain(threadId); + }, + }); return { threadStore: { get: mock.fn(async (threadId) => threads.get(threadId) ?? null), @@ -202,7 +207,7 @@ function buildDeps() { ), retirePrestartProcessingGroup: mock.fn(async () => 'retired'), processNext: mock.fn(async () => ({ started: false })), - requestDrain: mock.fn(async () => {}), + requestDrain, isPaused: mock.fn(() => false), getPauseReason: mock.fn(() => undefined), clearPause: mock.fn(), diff --git a/packages/api/test/f317-inbox-redis.test.ts b/packages/api/test/f317-inbox-redis.test.ts index baf8cb149f7..baf965f1ebe 100644 --- a/packages/api/test/f317-inbox-redis.test.ts +++ b/packages/api/test/f317-inbox-redis.test.ts @@ -57,8 +57,8 @@ test( targetCats: [target], intent: 'execute', }; - const first = await queue.appendAndEnqueueDurable(store, messageInput, queueInput); - const repeated = await queue.appendAndEnqueueDurable(store, messageInput, queueInput); + const first = await queue.send(store, messageInput, queueInput); + const repeated = await queue.send(store, messageInput, queueInput); assert.ok(first.message && repeated.message); assert.equal(repeated.message.id, first.message.id); ids.push(first.message.id); diff --git a/packages/api/test/f317-inbox-source.test.ts b/packages/api/test/f317-inbox-source.test.ts index d0442431ed2..8fde5f048cb 100644 --- a/packages/api/test/f317-inbox-source.test.ts +++ b/packages/api/test/f317-inbox-source.test.ts @@ -20,7 +20,7 @@ const scope: LiveInboxScope = { async function append(store: MessageStore, queue: InvocationQueue, index = 1) { const from = { kind: 'agent' as const, catId: createCatId('opus') }; const content = `private-body-${index}`; - const result = await queue.appendAndEnqueueDurable( + const result = await queue.send( store, { userId: scope.userId, diff --git a/packages/api/test/f317-live-crosspost-anchor.test.ts b/packages/api/test/f317-live-crosspost-anchor.test.ts index 406ed15d4ed..0317daed71d 100644 --- a/packages/api/test/f317-live-crosspost-anchor.test.ts +++ b/packages/api/test/f317-live-crosspost-anchor.test.ts @@ -12,7 +12,7 @@ test('cross-post notice and recovery reference the persisted receiving message', const f = recoveryFixture(); const queue = f.queue; const from = { kind: 'agent' as const, catId: scope.catId }; - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( f.messages, { userId: scope.userId, diff --git a/packages/api/test/f317-live-page-action-authority.test.ts b/packages/api/test/f317-live-page-action-authority.test.ts index bfa0136c2b0..a5db06e00fa 100644 --- a/packages/api/test/f317-live-page-action-authority.test.ts +++ b/packages/api/test/f317-live-page-action-authority.test.ts @@ -33,7 +33,7 @@ test('a newer owner turn or revoked approval cannot commit an old action', async const queue = new InvocationQueue(); const content = 'Wait, do something else first'; const from = { kind: 'user' as const, userId: scope.userId }; - await queue.appendAndEnqueueDurable( + await queue.send( f.store, { userId: scope.userId, diff --git a/packages/api/test/f317-live-page-action-order.test.ts b/packages/api/test/f317-live-page-action-order.test.ts index 29801798126..ccb6814bf6e 100644 --- a/packages/api/test/f317-live-page-action-order.test.ts +++ b/packages/api/test/f317-live-page-action-order.test.ts @@ -52,7 +52,7 @@ test('a later queued owner correction with a backdated timeline score never perm const queue = new InvocationQueue(); const content = 'Wait, do something else'; const from = { kind: 'user' as const, userId: scope.userId }; - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( f.store, { userId: scope.userId, diff --git a/packages/api/test/f317-live-source-policy.test.mjs b/packages/api/test/f317-live-source-policy.test.mjs index bb07173544e..605a700acd6 100644 --- a/packages/api/test/f317-live-source-policy.test.mjs +++ b/packages/api/test/f317-live-source-policy.test.mjs @@ -24,7 +24,7 @@ test('human and agent sources remain distinct ledger entries and attach to one e const f = await fixture({ requested: 'continue_current', boundParentInvocationId: 'live-parent' }); t.after(f.close); const from = { kind: 'agent', catId: 'opus' }; - const second = await f.queue.appendAndEnqueueDurable( + const second = await f.queue.send( f.store, { from, diff --git a/packages/api/test/f317-recovery-context.test.ts b/packages/api/test/f317-recovery-context.test.ts index 2396dc04f0b..292227f3f62 100644 --- a/packages/api/test/f317-recovery-context.test.ts +++ b/packages/api/test/f317-recovery-context.test.ts @@ -208,7 +208,7 @@ test('current thread legacy tasks survive missing owner metadata; explicit forei test('queued unread user sources remain body-free references with unknown playback and successor responsibility', async () => { const f = recoveryFixture(); - const admission = await f.queue.appendAndEnqueueDurable( + const admission = await f.queue.send( f.messages, { userId: scope.userId, diff --git a/packages/api/test/fixtures/f290-real-model-host.mjs b/packages/api/test/fixtures/f290-real-model-host.mjs index ff66ff4f692..35cf036f7f8 100644 --- a/packages/api/test/fixtures/f290-real-model-host.mjs +++ b/packages/api/test/fixtures/f290-real-model-host.mjs @@ -157,9 +157,15 @@ export async function createModelHost(world, cafe, db, config, evidence) { const deps = originalStrategyDeps(); return { ...deps, invocationDeps: { ...deps.invocationDeps, apiUrl: callbackUrl } }; }; - const queue = new InvocationQueue(new RedisQueueLedgerStore(redis)); - await queue.hydrateFromLedger(messages); const tracker = new InvocationTracker(); + const queue = new InvocationQueue(new RedisQueueLedgerStore(redis), { + invocationTracker: tracker, + resolveCarrierCapability: (target) => router.freshnessCarrierCapability(target), + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); + await queue.hydrateFromLedger(messages); const queueProcessor = new QueueProcessor({ queue, invocationTracker: tracker, @@ -193,7 +199,6 @@ export async function createModelHost(world, cafe, db, config, evidence) { threadStore: threads, messageStore: messages, invocationQueue: queue, - queueProcessor, socketManager: sockets, isCatAvailable: (id) => id === catId, admitStandingWork: (source, id) => admission.admit(source, id), diff --git a/packages/api/test/helpers/1398-live-source-fixture.mjs b/packages/api/test/helpers/1398-live-source-fixture.mjs index 61158a09276..5c1f9305bca 100644 --- a/packages/api/test/helpers/1398-live-source-fixture.mjs +++ b/packages/api/test/helpers/1398-live-source-fixture.mjs @@ -77,7 +77,7 @@ export async function createCanonicalLiveSourceFixture(intent, source = 'user', : source === 'connector' ? { kind: 'external', connectorId: 'test' } : { kind: 'user', userId: 'owner' }; - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( store, { threadId: 'home', diff --git a/packages/api/test/helpers/canonical-action-history-fixtures.js b/packages/api/test/helpers/canonical-action-history-fixtures.js index c5eeace8d11..85baa2c90b4 100644 --- a/packages/api/test/helpers/canonical-action-history-fixtures.js +++ b/packages/api/test/helpers/canonical-action-history-fixtures.js @@ -101,7 +101,7 @@ export async function canonicalActionFixture({ const messages = stores.messages ?? new MessageStore(); const records = stores.records ?? new InvocationRecordStore(); const turns = stores.turns ?? new InMemoryTurnExecutionStore(); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messages, { threadId: current.holderThreadId, diff --git a/packages/api/test/helpers/connector-delivery-harness.js b/packages/api/test/helpers/connector-delivery-harness.js index 337fb82d8ef..80785c799c1 100644 --- a/packages/api/test/helpers/connector-delivery-harness.js +++ b/packages/api/test/helpers/connector-delivery-harness.js @@ -24,7 +24,9 @@ const { MessageStore } = await import('../../dist/domains/cats/services/stores/p */ export function connectorDeliveryHarness(options = {}) { const messageStore = options.messageStore ?? new MessageStore(); - const queue = options.ledgerStore ? new InvocationQueue(options.ledgerStore) : new InvocationQueue(); + const queue = new InvocationQueue(options.ledgerStore, { + resolveTargets: async (requested) => (requested.length ? [...requested] : ['opus']), + }); const progressed = []; /** Queue drain owes the owner wake, so an admitted entry reaching progress IS the wake. */ const wakes = []; diff --git a/packages/api/test/helpers/ordinary-dispatch-fixture.js b/packages/api/test/helpers/ordinary-dispatch-fixture.js index 7d78f56735a..7124ed5ab1d 100644 --- a/packages/api/test/helpers/ordinary-dispatch-fixture.js +++ b/packages/api/test/helpers/ordinary-dispatch-fixture.js @@ -17,7 +17,7 @@ export async function ordinaryDispatchFixture(t) { } async function addSource(content) { return ( - await f.queue.appendAndEnqueueDurable( + await f.queue.send( f.store, { threadId: 'home', diff --git a/packages/api/test/helpers/persisted-queue-fixture.ts b/packages/api/test/helpers/persisted-queue-fixture.ts index bb34f9fc9be..a979b72c882 100644 --- a/packages/api/test/helpers/persisted-queue-fixture.ts +++ b/packages/api/test/helpers/persisted-queue-fixture.ts @@ -23,11 +23,22 @@ export function createPersistedQueueFixture( ledger?: InMemoryQueueLedgerStore; liveCompanionSessions?: QueueProcessorDeps['liveCompanionSessions']; onExecution?: (options: RouteExecutionOptions) => Promise; + onAdmitted?: ( + message?: import('../../dist/domains/cats/services/stores/ports/MessageStore.js').StoredMessage, + ) => void; } = {}, ) { const ledger = settings.ledger ?? new InMemoryQueueLedgerStore(); - const queue = new InvocationQueue(ledger); + let processor: QueueProcessor; const tracker = new InvocationTracker(); + const queue = new InvocationQueue(ledger, { + invocationTracker: tracker, + resolveTargets: async (requested) => (requested.length ? [...requested] : ['opus']), + onAdmitted: ({ threadId, message }) => { + settings.onAdmitted?.(message); + void processor.requestDrain(threadId); + }, + }); const records = new InvocationRecordStore(); const turns = new InMemoryTurnExecutionStore(); const starts: { @@ -40,7 +51,7 @@ export function createPersistedQueueFixture( }[] = []; const completed: Promise[] = []; const releases: (() => void)[] = []; - const processor = new QueueProcessor({ + processor = new QueueProcessor({ queue, liveCompanionSessions: settings.liveCompanionSessions, invocationTracker: tracker, diff --git a/packages/api/test/helpers/proposal-test-harness.js b/packages/api/test/helpers/proposal-test-harness.js index b934efc3a79..18b15e72150 100644 --- a/packages/api/test/helpers/proposal-test-harness.js +++ b/packages/api/test/helpers/proposal-test-harness.js @@ -14,7 +14,6 @@ export async function createProposalTestContext({ taskStoreOverride, routerOverride, invocationQueueOverride, - queueProcessorOverride, fetchPrTrackingBoundaryOverride, sidebarPresenceSourceOverride, projectRoot, @@ -68,7 +67,6 @@ export async function createProposalTestContext({ socketManager, ...(routerOverride ? { router: routerOverride } : {}), ...(invocationQueueOverride ? { invocationQueue: invocationQueueOverride } : {}), - ...(queueProcessorOverride ? { queueProcessor: queueProcessorOverride } : {}), ...(fetchPrTrackingBoundaryOverride ? { fetchPrTrackingBoundary: fetchPrTrackingBoundaryOverride } : {}), ...(projectRoot ? { projectRoot } : {}), }); @@ -158,7 +156,6 @@ export async function createProposalTestContext({ socketEvents, router: routerOverride, invocationQueue: invocationQueueOverride, - queueProcessor: queueProcessorOverride, propose, approve, reject, diff --git a/packages/api/test/invocation-queue.test.js b/packages/api/test/invocation-queue.test.js index 1c3ed254b46..331eb51b86b 100644 --- a/packages/api/test/invocation-queue.test.js +++ b/packages/api/test/invocation-queue.test.js @@ -425,10 +425,11 @@ describe('InvocationQueue ADR-043 adapter', () => { const admitted = await queue.enqueueDurable(queueInput({ sourceId: 'clone-source', content: 'original' })); const listed = queue.list('thread-1', 'user-1'); listed[0].payload.content = 'mutated'; + const policy = structuredClone(admitted.entry.delivery.authorIntentByTarget); admitted.entry.delivery.authorIntentByTarget = {}; const current = queue.getEntrySnapshot('thread-1', 'user-1', admitted.entry.id); assert.equal(current.payload.content, 'original'); - assert.equal(current.delivery.authorIntentByTarget, undefined); + assert.deepEqual(current.delivery.authorIntentByTarget, policy); }); }); diff --git a/packages/api/test/issue1371-prestart-recovery.test.js b/packages/api/test/issue1371-prestart-recovery.test.js index d95f390add9..7ab4f0ece64 100644 --- a/packages/api/test/issue1371-prestart-recovery.test.js +++ b/packages/api/test/issue1371-prestart-recovery.test.js @@ -121,7 +121,7 @@ async function harness() { targetCats: ['opus', 'codex'], intent: 'execute', }); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( store, canonicalTestMessageInput({ threadId: queueInput.threadId, diff --git a/packages/api/test/issue1371-redis-convergence.test.js b/packages/api/test/issue1371-redis-convergence.test.js index 399afebda73..ec1a01f1c5e 100644 --- a/packages/api/test/issue1371-redis-convergence.test.js +++ b/packages/api/test/issue1371-redis-convergence.test.js @@ -87,7 +87,7 @@ test( targetCats: ['opus', 'codex'], intent: 'execute', }); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( store, canonicalTestMessageInput({ threadId: queueInput.threadId, diff --git a/packages/api/test/message-lifecycle-ingress.test.js b/packages/api/test/message-lifecycle-ingress.test.js index 8447f01248f..6153e9a67c3 100644 --- a/packages/api/test/message-lifecycle-ingress.test.js +++ b/packages/api/test/message-lifecycle-ingress.test.js @@ -9,7 +9,6 @@ const { messagesRoutes } = await import('../dist/routes/messages.js'); const { sendMessageSchema } = await import('../dist/routes/messages.schema.js'); function createDependencies(overrides = {}) { - const invocationQueue = new InvocationQueue(); let messageSequence = 0; const messagesByIdempotencyKey = new Map(); const append = mock.fn(async (message) => ({ id: `message-${++messageSequence}`, ...message })); @@ -30,9 +29,8 @@ function createDependencies(overrides = {}) { if (message.idempotencyKey) messagesByIdempotencyKey.set(message.idempotencyKey, stored); return { outcome: 'enqueued', message: stored, entries: admitted.entries, deduped: false }; }); - return { + const dependencies = { registry: new InvocationRegistry(), - invocationQueue, messageStore: { append, appendWithQueueLedgerAdmission, @@ -84,6 +82,21 @@ function createDependencies(overrides = {}) { }, ...overrides, }; + dependencies.invocationQueue ??= new InvocationQueue(undefined, { + projectRoot: dependencies.projectRoot, + invocationTracker: dependencies.invocationTracker, + resolveCarrierCapability: (catId) => dependencies.router.freshnessCarrierCapability?.(catId), + // HTTP admission updates its target array after commit. Snapshot the mock + // argument so these assertions describe resolution-time input. + resolveTargets: (requested, threadId) => + requested.length > 0 + ? dependencies.router.resolveExplicitTargets(requested, threadId) + : dependencies.router.resolveConversationTargetsAtAdmission([...requested], threadId), + onAdmitted: ({ threadId }) => { + void dependencies.queueProcessor.requestDrain(threadId); + }, + }); + return dependencies; } describe('canonical message lifecycle ingress', () => { @@ -123,7 +136,50 @@ describe('canonical message lifecycle ingress', () => { assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); }); - it('offers an explicitly guided exact running target to auto-append', async () => { + it('HTTP acknowledges durable admission while Queue progress is still pending', async () => { + let release; + const pending = new Promise((resolve) => { + release = resolve; + }); + dependencies.queueProcessor.requestDrain.mock.mockImplementation(() => pending); + try { + const response = await Promise.race([ + app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'accept without native ACK', threadId: 'thread-1' }, + }), + new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error('HTTP waits for Queue progress')), 1000); + timer.unref(); + }), + ]); + assert.equal(response.statusCode, 202, response.body); + assert.ok(dependencies.invocationQueue.list('thread-1', 'user-1')[0]); + assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); + } finally { + release(); + } + }); + + it('common Queue admission persists the product default for an omitted one-shot choice', async () => { + dependencies.invocationTracker.has.mock.mockImplementation(() => true); + dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); + dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); + const response = await app.inject({ + method: 'POST', + url: '/api/messages', + headers: { 'x-cat-cafe-user': 'user-1', 'content-type': 'application/json' }, + payload: { content: 'use the current scoped default', threadId: 'thread-1' }, + }); + assert.equal(response.statusCode, 202, response.body); + const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); + assert.equal(entry.delivery.authorIntentByTarget.opus.requested, 'next_work'); + assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); + }); + + it('persists the guided parent and delegates progress to the Queue drain', async () => { dependencies.invocationTracker.has.mock.mockImplementation(() => true); dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); @@ -133,11 +189,6 @@ describe('canonical message lifecycle ingress', () => { activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', })); - dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ - outcome: 'appended', - entry: null, - acceptedTargetIds: ['opus'], - })); const response = await app.inject({ method: 'POST', @@ -162,12 +213,14 @@ describe('canonical message lifecycle ingress', () => { deliverySemantics: 'exact_active_turn', }, }); - assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ - { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, - ]); + assert.deepEqual( + dependencies.queueProcessor.requestDrain.mock.calls.map((call) => call.arguments), + [['thread-1']], + ); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); }); - it('offers every admitted target independently and returns every exact target identity', async () => { + it('delegates multi-target progress once and returns every exact target identity', async () => { dependencies.router.resolveExplicitTargets.mock.mockImplementation(async (cats) => cats); dependencies.invocationTracker.has.mock.mockImplementation(() => true); dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); @@ -178,10 +231,6 @@ describe('canonical message lifecycle ingress', () => { activeInvocationGuidance: 'supported', deliverySemantics: 'exact_active_turn', })); - dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ - outcome: 'appended', - acceptedTargetIds: ['opus', 'codex'], - })); const response = await app.inject({ method: 'POST', @@ -203,12 +252,10 @@ describe('canonical message lifecycle ingress', () => { ['opus', 'codex'], ); assert.deepEqual( - dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.map((call) => call.arguments[0]), - [ - { threadId: 'thread-1', userId: 'user-1', entryId: body.entries[0].entryId, targetCatId: 'opus' }, - { threadId: 'thread-1', userId: 'user-1', entryId: body.entries[0].entryId, targetCatId: 'codex' }, - ], + dependencies.queueProcessor.requestDrain.mock.calls.map((call) => call.arguments), + [['thread-1']], ); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); assert.equal(new Set(body.entries.map((entry) => entry.entryId)).size, 1); }); @@ -276,10 +323,6 @@ describe('canonical message lifecycle ingress', () => { dependencies.invocationTracker.has.mock.mockImplementation(() => true); dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); - dependencies.queueProcessor.tryAutoAppendExactEntry.mock.mockImplementation(async () => ({ - outcome: 'appended', - acceptedTargetIds: ['opus'], - })); const participants = []; dependencies.threadStore.get.mock.mockImplementation(async () => ({ id: 'thread-1', @@ -320,9 +363,11 @@ describe('canonical message lifecycle ingress', () => { }); assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].mentions, []); assert.deepEqual(dependencies.threadStore.addParticipants.mock.calls[0].arguments, ['thread-1', ['opus']]); - assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ - { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, - ]); + assert.deepEqual( + dependencies.queueProcessor.requestDrain.mock.calls.map((call) => call.arguments), + [['thread-1']], + ); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); assert.deepEqual(response.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); }); @@ -357,12 +402,14 @@ describe('canonical message lifecycle ingress', () => { assert.deepEqual(entry.targets, ['opus']); assert.equal(entry.delivery.authorIntentByTarget.opus.fallbackReason, 'no_active_parent'); assert.deepEqual(response.json().entries, [{ entryId: entry.id, targetCatId: 'opus' }]); - assert.deepEqual(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls[0].arguments, [ - { threadId: 'thread-1', userId: 'user-1', entryId: entry.id, targetCatId: 'opus' }, - ]); + assert.deepEqual( + dependencies.queueProcessor.requestDrain.mock.calls.map((call) => call.arguments), + [['thread-1']], + ); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); }); - it('records a truthful queued fallback when the current reply ends before automatic Append', async () => { + it('leaves cutover fallback to Queue while persisting the exact author intent', async () => { dependencies.invocationTracker.has.mock.mockImplementation(() => true); dependencies.invocationTracker.getUserId = mock.fn(() => 'user-1'); dependencies.invocationTracker.getExecutionId = mock.fn(() => 'parent-1'); @@ -389,8 +436,12 @@ describe('canonical message lifecycle ingress', () => { const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); assert.equal(entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); assert.equal(entry.delivery.authorIntentByTarget.opus.boundParentInvocationId, 'parent-1'); - assert.equal(entry.delivery.authorIntentByTarget.opus.fallbackReason, 'parent_terminal_before_exposure'); - assert.equal(typeof entry.delivery.authorIntentByTarget.opus.fallbackAt, 'number'); + assert.equal(entry.delivery.authorIntentByTarget.opus.fallbackReason, undefined); + assert.deepEqual( + dependencies.queueProcessor.requestDrain.mock.calls.map((call) => call.arguments), + [['thread-1']], + ); + assert.equal(dependencies.queueProcessor.tryAutoAppendExactEntry.mock.calls.length, 0); }); it('routes a composer-selected member through the explicit target field without rewriting visible content', async () => { @@ -412,7 +463,7 @@ describe('canonical message lifecycle ingress', () => { assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].mentions, ['codex']); }); - it('rejects an unavailable composer-selected member instead of silently falling back', async () => { + it('rejects an invalid composer-selected identity before admission', async () => { dependencies.router.resolveExplicitTargets.mock.mockImplementation(async () => []); const response = await app.inject({ @@ -428,17 +479,12 @@ describe('canonical message lifecycle ingress', () => { assert.equal(dependencies.messageStore.append.mock.calls.length, 0); }); - it('keeps routing warnings on the canonical Queue/source payload instead of broadcasting a detached notice', async () => { - const warning = { - kind: 'cat_not_found', - mention: '@missing-cat', - alternatives: [], - }; + it('admits unmatched prose through ordinary fallback without a warning or detached notice', async () => { dependencies.router.resolveTargetsAndIntent.mock.mockImplementation(async () => ({ targetCats: [], intent: { intent: 'execute' }, - hasMentions: true, - routing_warnings: [warning], + hasMentions: false, + routing_warnings: [], })); const response = await app.inject({ @@ -450,10 +496,15 @@ describe('canonical message lifecycle ingress', () => { assert.equal(response.statusCode, 202, response.body); const [entry] = dependencies.invocationQueue.list('thread-1', 'user-1'); - assert.deepEqual(entry.targets, [], 'an invalid authored mention must not become an ordinary fallback send'); - assert.equal(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls.length, 0); - assert.deepEqual(entry.payload.routingWarnings, [warning]); - assert.deepEqual(dependencies.messageStore.append.mock.calls[0].arguments[0].extra.routingWarnings, [warning]); + assert.deepEqual(entry.targets, ['opus']); + assert.equal(dependencies.router.resolveConversationTargetsAtAdmission.mock.calls.length, 1); + assert.equal(entry.payload.content, '@missing-cat please inspect this'); + assert.equal(entry.payload.routingWarnings, undefined); + const source = dependencies.messageStore.append.mock.calls[0].arguments[0]; + assert.equal(source.content, '@missing-cat please inspect this'); + assert.deepEqual(source.mentions, []); + assert.equal(source.extra?.routingWarnings, undefined); + assert.equal(dependencies.queueProcessor.requestDrain.mock.calls.length, 1); assert.equal(dependencies.socketManager.broadcastAgentMessage.mock.calls.length, 0); }); diff --git a/packages/api/test/message-lifecycle-store.test.js b/packages/api/test/message-lifecycle-store.test.js index 121c818bbcf..4c08ec1ed5a 100644 --- a/packages/api/test/message-lifecycle-store.test.js +++ b/packages/api/test/message-lifecycle-store.test.js @@ -474,7 +474,7 @@ describe('MessageStore lifecycle input dispatch CAS', () => { }, }); const queue = new InvocationQueue(); - const admission = await queue.appendAndEnqueueDurable( + const admission = await queue.send( store, canonicalTestMessageInput({ userId: 'owner-1', @@ -776,7 +776,7 @@ describe('MessageStore lifecycle pre-admission failure transaction', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const store = new MessageStore(); const queue = new InvocationQueue(); - const admission = await queue.appendAndEnqueueDurable( + const admission = await queue.send( store, canonicalTestMessageInput({ userId: 'owner-1', diff --git a/packages/api/test/message-store.test.js b/packages/api/test/message-store.test.js index b9be3571a1f..a73cd2613ab 100644 --- a/packages/api/test/message-store.test.js +++ b/packages/api/test/message-store.test.js @@ -256,8 +256,8 @@ describe('MessageStore', () => { autoExecute: true, }; - const first = await queue.appendAndEnqueueDurable(store, message, admission); - const replay = await queue.appendAndEnqueueDurable(store, message, admission); + const first = await queue.send(store, message, admission); + const replay = await queue.send(store, message, admission); assert.equal(first.outcome, 'enqueued'); assert.equal(replay.outcome, 'enqueued'); assert.equal(replay.deduped, true); diff --git a/packages/api/test/messages-endpoint.test.js b/packages/api/test/messages-endpoint.test.js index 1a21daffc36..0121bd51211 100644 --- a/packages/api/test/messages-endpoint.test.js +++ b/packages/api/test/messages-endpoint.test.js @@ -390,7 +390,7 @@ describe('GET /api/messages', () => { }); it('publishes a queued user source only at actual delivery without a Queue receipt', async () => { - const admission = await invocationQueue.appendAndEnqueueDurable( + const admission = await invocationQueue.send( messageStore, canonicalTestMessageInput({ userId: 'default-user', diff --git a/packages/api/test/multi-mention-b6-queue-dispatch.test.js b/packages/api/test/multi-mention-b6-queue-dispatch.test.js index 91d84b95814..14303fea57a 100644 --- a/packages/api/test/multi-mention-b6-queue-dispatch.test.js +++ b/packages/api/test/multi-mention-b6-queue-dispatch.test.js @@ -176,7 +176,13 @@ describe('B6: multi_mention queue dispatch', () => { mockInvocationRecordStore = createMockInvocationRecordStore(); mockInvocationTracker = createMockInvocationTracker(); mockRouter = createMockRouter(); - invocationQueue = adaptInvocationQueue(new InvocationQueue()); + invocationQueue = adaptInvocationQueue( + new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void mockQueueProcessor.requestDrain(threadId); + }, + }), + ); mockQueueProcessor = createMockQueueProcessor(); actionAdmissionCalls = []; actionUnavailableCalls = []; @@ -1389,7 +1395,7 @@ describe('B6: QueueProcessor entryCompleteHook integration', () => { // sibling tests below. const queue = adaptInvocationQueue(new IQ()); // F117: message_wake rows must reference a real persisted History message — - // production admits message+queue atomically (appendAndEnqueueDurable), and + // production admits message+queue atomically (send), and // the QueueProcessor fails a wake closed when the referenced message is absent. const messageStore = adaptMessageStore(new MessageStore()); const recordsByIdempotencyKey = new Map(); @@ -1745,7 +1751,13 @@ describe('B6: canceled hook skips recordResponse in dispatchViaQueue', () => { mockInvocationRecordStore = createMockInvocationRecordStore(); mockInvocationTracker = createMockInvocationTracker(); mockRouter = createMockRouter(); - invocationQueue = adaptInvocationQueue(new InvocationQueue()); + invocationQueue = adaptInvocationQueue( + new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void mockQueueProcessor.requestDrain(threadId); + }, + }), + ); mockQueueProcessor = createMockQueueProcessor(); creds = mockRegistry.register('opus', 'thread-1', 'user-1'); appendTestLifecycleResponseSource(mockMessageStore, creds); diff --git a/packages/api/test/persisted-queue-delivery.test.ts b/packages/api/test/persisted-queue-delivery.test.ts index 428aca64c51..94c1ef3a240 100644 --- a/packages/api/test/persisted-queue-delivery.test.ts +++ b/packages/api/test/persisted-queue-delivery.test.ts @@ -214,7 +214,7 @@ test('concurrent idempotent admission leaves one canonical source and child, nev test('unrelated Queue completion cannot start a reservation before the atomic Message admission', async (t) => { const f = consumerFixture(t); - const append = f.queue.appendAndEnqueueDurable.bind(f.queue); + const append = f.queue.send.bind(f.queue); let entered!: () => void, release!: () => void; const appending = new Promise((resolve) => { entered = resolve; @@ -222,7 +222,7 @@ test('unrelated Queue completion cannot start a reservation before the atomic Me const barrier = new Promise((resolve) => { release = resolve; }); - f.queue.appendAndEnqueueDurable = async (...args) => { + f.queue.send = async (...args) => { entered(); await barrier; return append(...args); diff --git a/packages/api/test/persistence-fault-drill.test.js b/packages/api/test/persistence-fault-drill.test.js index 8632626b98f..7c2bf491c4f 100644 --- a/packages/api/test/persistence-fault-drill.test.js +++ b/packages/api/test/persistence-fault-drill.test.js @@ -94,7 +94,11 @@ async function setupScenario() { const threadStore = new ThreadStore(); const invocationRecordStore = new InvocationRecordStore(); const invocationTracker = new InvocationTracker(); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); const socketManager = createMockSocketManager(); const modeRef = { failPersistence: true }; const router = createFaultDrillRouter(modeRef); diff --git a/packages/api/test/post-message-successor-e2e.test.js b/packages/api/test/post-message-successor-e2e.test.js index f17168990be..0fa8183475c 100644 --- a/packages/api/test/post-message-successor-e2e.test.js +++ b/packages/api/test/post-message-successor-e2e.test.js @@ -41,10 +41,15 @@ async function createHarness({ failEnqueueAttempts = 0, measurementRoutes = fals ); const registry = new InvocationRegistry(); - const invocationQueue = new InvocationQueue(); - const originalEnqueue = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); + const drainNotifications = []; + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ entries }) => { + drainNotifications.push(entries.map((entry) => entry.id)); + }, + }); + const originalEnqueue = invocationQueue.send.bind(invocationQueue); let remainingEnqueueFailures = failEnqueueAttempts; - invocationQueue.appendAndEnqueueDurable = async (...args) => { + invocationQueue.send = async (...args) => { if (remainingEnqueueFailures > 0) { remainingEnqueueFailures -= 1; return { outcome: 'full' }; @@ -63,7 +68,6 @@ async function createHarness({ failEnqueueAttempts = 0, measurementRoutes = fals const thread = await threadStore.create('user-1', 'Local review durable fact'); const auth = await registry.create('user-1', 'opus', thread.id); const admissionCalls = []; - const autoExecuteCalls = []; app = Fastify(); await app.register(callbacksRoutes, { @@ -88,10 +92,6 @@ async function createHarness({ failEnqueueAttempts = 0, measurementRoutes = fals }, }, queueProcessor: { - async tryAutoAppendExactEntry(...args) { - autoExecuteCalls.push(args); - return { outcome: 'rejected' }; - }, async requestDrain() {}, async onInvocationComplete() {}, }, @@ -112,7 +112,7 @@ async function createHarness({ failEnqueueAttempts = 0, measurementRoutes = fals return { admissionCalls, - autoExecuteCalls, + drainNotifications, auth, apiUrl, handlePostMessage, @@ -272,7 +272,7 @@ test('typed local review fact needs no action lease or inherited coordination to 'published Agent review remains visible while Queue owns wake admission in lifecycle metadata', ); assert.deepEqual(harness.invocationQueue.list(harness.thread.id, 'user-1')[0].targets, ['codex']); - assert.equal(harness.autoExecuteCalls.length, 1); + assert.equal(new Set(harness.drainNotifications.flat()).size, 1); const replay = toolJson(await harness.handlePostMessage(input)); assert.equal(replay.status, 'duplicate'); @@ -284,7 +284,7 @@ test('typed local review fact needs no action lease or inherited coordination to ); assert.equal(harness.messageStore.getByThreadIncludingQueued(harness.thread.id, 20, 'user-1').length, 1); assert.equal(harness.invocationQueue.list(harness.thread.id, 'user-1').length, 1); - assert.equal(harness.autoExecuteCalls.length, 1); + assert.equal(new Set(harness.drainNotifications.flat()).size, 1); }); test('typed local review fact fails closed without an exact reviewed HEAD', async () => { @@ -391,7 +391,7 @@ test('a durable review fact recovers one author wake after transient Queue failu assert.equal(replay.json().status, 'ok'); assert.equal(harness.messageStore.getByThreadIncludingQueued(harness.thread.id, 20, 'user-1').length, 1); assert.equal(harness.invocationQueue.list(harness.thread.id, 'user-1').length, 1); - assert.equal(harness.autoExecuteCalls.length, 1); + assert.equal(new Set(harness.drainNotifications.flat()).size, 1); }); test('a new HEAD stores a fresh review fact while retaining the old HEAD as history', async () => { diff --git a/packages/api/test/proposal-approve-auth-provenance.test.js b/packages/api/test/proposal-approve-auth-provenance.test.js index 1ef6ac9e5ed..dd384afa9f0 100644 --- a/packages/api/test/proposal-approve-auth-provenance.test.js +++ b/packages/api/test/proposal-approve-auth-provenance.test.js @@ -19,11 +19,6 @@ describe('F275 proposal approval owner-auth provenance', () => { }, }, invocationQueueOverride: invocationQueue, - queueProcessorOverride: { - async processNext() { - return { started: true }; - }, - }, }); const source = await ctx.threadStore.create('default-user', 'Source'); const { proposalId } = JSON.parse( diff --git a/packages/api/test/proposal-approve-dispatch.test.js b/packages/api/test/proposal-approve-dispatch.test.js index 252427fdb09..ed4ec558f40 100644 --- a/packages/api/test/proposal-approve-dispatch.test.js +++ b/packages/api/test/proposal-approve-dispatch.test.js @@ -24,7 +24,11 @@ import { createProposalTestContext } from './helpers/proposal-test-harness.js'; describe('F128 approve dispatch — initialMessage routing', () => { test('approve dispatches initialMessage through the queue processor', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const resolveCalls = []; const processCalls = []; const router = { @@ -42,7 +46,6 @@ describe('F128 approve dispatch — initialMessage routing', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source', '/projects/source-repo'); const { proposalId } = JSON.parse( @@ -118,7 +121,11 @@ describe('F128 approve dispatch — initialMessage routing', () => { // user's next manual message. With fallback, the proposal's chosen members get woken up // immediately as the user intended when picking them on the card. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { // Simulate the real router behaviour for a no-@-mention message: 0 targets. @@ -135,7 +142,6 @@ describe('F128 approve dispatch — initialMessage routing', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source', '/projects/source-repo'); const { proposalId } = JSON.parse( @@ -175,7 +181,11 @@ describe('F128 approve dispatch — initialMessage routing', () => { // explicitly in initialMessage. Fallback must NOT clobber that intent down // to execute — explicit user tags always win. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { targetCats: [], intent: { intent: 'ideate' }, hasMentions: false }; @@ -189,7 +199,6 @@ describe('F128 approve dispatch — initialMessage routing', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( @@ -222,7 +231,11 @@ describe('F128 approve dispatch — initialMessage routing', () => { // Server defensively injects the header so cats who forget to write it in // initialMessage still preserve the fork-and-return loop. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { targetCats: ['opus'], intent: { intent: 'execute' }, hasMentions: false }; @@ -236,7 +249,6 @@ describe('F128 approve dispatch — initialMessage routing', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Strategy Discussion'); const { proposalId } = JSON.parse( @@ -301,7 +313,11 @@ describe('F128 approve dispatch — initialMessage routing', () => { // "@opus46 把球传过去" inside instructions). They must NOT override the // card's first-picked member. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { targetCats: ['codex'], intent: { intent: 'execute' }, hasMentions: true }; @@ -315,7 +331,6 @@ describe('F128 approve dispatch — initialMessage routing', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( diff --git a/packages/api/test/proposal-approve-reconcile.test.js b/packages/api/test/proposal-approve-reconcile.test.js index 42092ac4580..5478f272c1a 100644 --- a/packages/api/test/proposal-approve-reconcile.test.js +++ b/packages/api/test/proposal-approve-reconcile.test.js @@ -69,10 +69,14 @@ const threadReadOptions = { describe('F128 proposal seed reconcile — exactly-once dispatch', () => { test('queue-full seed is reconciled and processed exactly once', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let proposalEnqueueAttempt = 0; - const originalEnqueue = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); - invocationQueue.appendAndEnqueueDurable = (...args) => { + const originalEnqueue = invocationQueue.send.bind(invocationQueue); + invocationQueue.send = (...args) => { const input = args[2]; if (input.idempotencyKey?.startsWith('proposal-initial:')) { proposalEnqueueAttempt += 1; @@ -94,7 +98,6 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); @@ -142,7 +145,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { test('processNext throw leaves a reconcilable seed with no duplicate invocation', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let shouldThrow = true; const processCalls = []; const queueProcessor = { @@ -158,7 +165,6 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); @@ -176,7 +182,10 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const first = await ctx.approve('alice', proposalId); assert.equal(first.statusCode, 200); const firstBody = JSON.parse(first.body); - assert.ok(firstBody.warnings?.some((w) => w.includes('auto-start failed'))); + assert.ok( + firstBody.warnings?.every((warning) => warning.includes('projectPath=default')), + JSON.stringify(firstBody), + ); const childId = firstBody.threadId; // The seed is materialized and queued, but the queue processor failed to wake. @@ -205,7 +214,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { test('processNext started:false leaves a reconcilable seed with no duplicate invocation', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let shouldStart = false; const processCalls = []; const queueProcessor = { @@ -221,7 +234,6 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); @@ -239,7 +251,10 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const first = await ctx.approve('alice', proposalId); assert.equal(first.statusCode, 200); const firstBody = JSON.parse(first.body); - assert.ok(firstBody.warnings?.some((w) => w.includes('did not start automatically'))); + assert.ok( + firstBody.warnings?.every((warning) => warning.includes('projectPath=default')), + JSON.stringify(firstBody), + ); const childId = firstBody.threadId; shouldStart = true; @@ -262,7 +277,11 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { test('legacy queue-full seed is repaired and processed exactly once', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const processCalls = []; const queueProcessor = { async processNext(threadId, userId) { @@ -274,7 +293,6 @@ describe('F128 proposal seed reconcile — exactly-once dispatch', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); diff --git a/packages/api/test/proposal-chain-protocol.test.js b/packages/api/test/proposal-chain-protocol.test.js index b768f2406d0..e377d2b122e 100644 --- a/packages/api/test/proposal-chain-protocol.test.js +++ b/packages/api/test/proposal-chain-protocol.test.js @@ -18,7 +18,11 @@ import { createProposalTestContext } from './helpers/proposal-test-harness.js'; describe('F128 chain protocol injection', () => { test('approve injects chain protocol with order + handoff instructions when preferredCats has multiple cats', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -36,7 +40,6 @@ describe('F128 chain protocol injection', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( @@ -96,7 +99,11 @@ describe('F128 chain protocol injection', () => { // regardless of what the user typed in initialMessage. Pin the contract: // user intent comes from the raw user-typed initialMessage only. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -114,7 +121,6 @@ describe('F128 chain protocol injection', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); // Parent thread title intentionally contains the literal `#ideate` tag. const source = await ctx.threadStore.create('alice', 'Parent #ideate title'); @@ -176,7 +182,11 @@ describe('F128 chain protocol injection', () => { // to assert addParticipants was never invoked with `opus`; we rely on // the input-boundary assertion as the necessary and sufficient cut. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let routerReceivedMessage = null; const router = { async resolveTargetsAndIntent(message) { @@ -200,7 +210,6 @@ describe('F128 chain protocol injection', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); // Parent thread title intentionally contains a `@opus` mention. const source = await ctx.threadStore.create('alice', 'Parent @opus thread'); @@ -262,7 +271,11 @@ describe('F128 chain protocol injection', () => { test('approve omits chain protocol when preferredCats is empty (no chain to orchestrate)', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -280,7 +293,6 @@ describe('F128 chain protocol injection', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( diff --git a/packages/api/test/proposal-explicit-intent.test.js b/packages/api/test/proposal-explicit-intent.test.js index 46e2ae25be5..2814d630753 100644 --- a/packages/api/test/proposal-explicit-intent.test.js +++ b/packages/api/test/proposal-explicit-intent.test.js @@ -39,7 +39,11 @@ describe('F128 explicit intent override (round-5)', () => { // + duplicate report-back. Fix: detect explicit `#ideate` from raw // initialMessage in enrich, omit chain protocol in parallel mode. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -57,7 +61,6 @@ describe('F128 explicit intent override (round-5)', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( @@ -140,7 +143,11 @@ describe('F128 explicit intent override (round-5)', () => { // them as serial multi-cat execution (the F088 router contract for // `#execute` outside F128-specific override). const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { // Simulate real router: raw `#execute @kimi @gemini @codex` → @@ -160,7 +167,6 @@ describe('F128 explicit intent override (round-5)', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( diff --git a/packages/api/test/proposal-legacy-cancel.test.js b/packages/api/test/proposal-legacy-cancel.test.js index 8b92c924645..0c4fbc9b9d4 100644 --- a/packages/api/test/proposal-legacy-cancel.test.js +++ b/packages/api/test/proposal-legacy-cancel.test.js @@ -102,7 +102,11 @@ describe('F128 proposal legacy seed cancel — terminal state', () => { test('legacy seed is canceled when no target cats resolve', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { targetCats: [], intent: { intent: 'execute' }, hasMentions: false }; @@ -116,7 +120,6 @@ describe('F128 proposal legacy seed cancel — terminal state', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const { proposalId, childId, seedId } = await createApprovedProposalWithLegacySeed(ctx, { initialMessage: 'No targets here', diff --git a/packages/api/test/proposal-legacy-dedup.test.js b/packages/api/test/proposal-legacy-dedup.test.js index c8e0e62eebc..f8039e08b14 100644 --- a/packages/api/test/proposal-legacy-dedup.test.js +++ b/packages/api/test/proposal-legacy-dedup.test.js @@ -34,11 +34,6 @@ async function createDispatchContext() { }, }, invocationQueueOverride: invocationQueue, - queueProcessorOverride: { - async processNext() { - return { started: true }; - }, - }, }); } diff --git a/packages/api/test/proposal-participant-admission.test.js b/packages/api/test/proposal-participant-admission.test.js index 96984f13ccb..076db19f003 100644 --- a/packages/api/test/proposal-participant-admission.test.js +++ b/packages/api/test/proposal-participant-admission.test.js @@ -22,7 +22,11 @@ function participantEvents(ctx, threadId) { describe('F128 proposal approval — participant admission', () => { test('no @ + preferredCats persists the final chain starter before Queue starts', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let ctx; const processSnapshots = []; const persistOptions = []; @@ -45,7 +49,6 @@ describe('F128 proposal approval — participant admission', () => { ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const proposed = await ctx.propose({ @@ -72,7 +75,11 @@ describe('F128 proposal approval — participant admission', () => { test('preferredCats override does not persist a narrative raw-message mention', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let ctx; const router = { async resolveTargetsAndIntent(_content, threadId, options) { @@ -88,7 +95,6 @@ describe('F128 proposal approval — participant admission', () => { ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const proposed = await ctx.propose({ @@ -108,7 +114,11 @@ describe('F128 proposal approval — participant admission', () => { test('#ideate persists every final parallel target with one user-scoped event', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let ctx; const snapshots = []; const router = { @@ -125,7 +135,6 @@ describe('F128 proposal approval — participant admission', () => { ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const proposed = await ctx.propose({ @@ -143,7 +152,7 @@ describe('F128 proposal approval — participant admission', () => { test('queue rejection leaves no participant or participant event', async () => { const invocationQueue = { - async appendAndEnqueueDurable() { + async send() { return { outcome: 'full', entries: [] }; }, }; @@ -160,7 +169,6 @@ describe('F128 proposal approval — participant admission', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const proposed = await ctx.propose({ @@ -179,7 +187,11 @@ describe('F128 proposal approval — participant admission', () => { test('Sidebar snapshot keeps C2 participants while presence transitions working → idle', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const workingThreads = new Set(); const router = { async resolveTargetsAndIntent() { @@ -204,7 +216,6 @@ describe('F128 proposal approval — participant admission', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, sidebarPresenceSourceOverride: presenceSource, }); const source = await ctx.threadStore.create('alice', 'Source'); diff --git a/packages/api/test/proposal-phase-aa.test.js b/packages/api/test/proposal-phase-aa.test.js index 592fa70ec79..89ccea69b48 100644 --- a/packages/api/test/proposal-phase-aa.test.js +++ b/packages/api/test/proposal-phase-aa.test.js @@ -20,7 +20,11 @@ import { createProposalTestContext } from './helpers/proposal-test-harness.js'; describe('F128 Phase AA — seed message source attribution', () => { test('AC-AA4: seed message catId = sourceCatId (proposing cat, not approver)', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -38,7 +42,6 @@ describe('F128 Phase AA — seed message source attribution', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); // Cat 'codex' proposes, user 'alice' approves @@ -71,7 +74,11 @@ describe('F128 Phase AA — seed message source attribution', () => { test('AC-AA5: seed message has extra.crossPost with sourceThreadId + sourceInvocationId', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -89,7 +96,6 @@ describe('F128 Phase AA — seed message source attribution', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source Thread'); const { proposalId } = JSON.parse( @@ -124,7 +130,7 @@ describe('F128 Phase AA — seed message source attribution', () => { }); test('AC-AA4 fallback: no-router path still attributes to source cat', async () => { - // When router/invocationQueue/queueProcessor are unavailable (fallback path), + // When router/invocationQueue are unavailable (fallback path), // the seed message must still carry sourceCatId attribution. const ctx = await createProposalTestContext({ // No router/queue overrides → hits the fallback path in dispatch diff --git a/packages/api/test/proposal-race-rollback.test.js b/packages/api/test/proposal-race-rollback.test.js index 5f4d53c7f42..0397295cee6 100644 --- a/packages/api/test/proposal-race-rollback.test.js +++ b/packages/api/test/proposal-race-rollback.test.js @@ -26,10 +26,14 @@ const threadReadOptions = { describe('F128 proposal seed race — orphan carrier rollback', () => { test('race: concurrent terminal transition after enqueue rolls back orphan carrier', async () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); let proposalEnqueueAttempt = 0; - const originalAdmission = invocationQueue.appendAndEnqueueDurable.bind(invocationQueue); - invocationQueue.appendAndEnqueueDurable = async (messageStore, message, input) => { + const originalAdmission = invocationQueue.send.bind(invocationQueue); + invocationQueue.send = async (messageStore, message, input) => { if (input.idempotencyKey?.startsWith('proposal-initial:')) { proposalEnqueueAttempt += 1; if (proposalEnqueueAttempt === 1) { @@ -50,7 +54,6 @@ describe('F128 proposal seed race — orphan carrier rollback', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); diff --git a/packages/api/test/proposal-reporter-handle.test.js b/packages/api/test/proposal-reporter-handle.test.js index e4070f29b28..a147ea6904f 100644 --- a/packages/api/test/proposal-reporter-handle.test.js +++ b/packages/api/test/proposal-reporter-handle.test.js @@ -38,7 +38,11 @@ describe('F128 parallel reporter handle resolution', () => { // Originally砚砚 round-7 P1 (raw fallback). Round-9 plan-based: // reporter = primaryMentionHandleForCatId(resolved.targetCats[0]). const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -56,7 +60,6 @@ describe('F128 parallel reporter handle resolution', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( @@ -101,7 +104,11 @@ describe('F128 parallel reporter handle resolution', () => { // is the canonical configured handle of resolved.targetCats[0] — // regardless of which alias the user typed in raw. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { // Router resolves Chinese alias `@砚砚` → catId `codex` per cat-template.json. @@ -120,7 +127,6 @@ describe('F128 parallel reporter handle resolution', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( @@ -155,7 +161,11 @@ describe('F128 parallel reporter handle resolution', () => { // reads router-resolved catId `gpt-5.2`, so the dot is naturally // preserved — there's no raw regex to misconfigure. const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); - const invocationQueue = new InvocationQueue(); + const invocationQueue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId, entries }) => { + void queueProcessor.processNext(threadId, entries[0].owner.userId).catch(() => {}); + }, + }); const router = { async resolveTargetsAndIntent() { return { @@ -173,7 +183,6 @@ describe('F128 parallel reporter handle resolution', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: invocationQueue, - queueProcessorOverride: queueProcessor, }); const source = await ctx.threadStore.create('alice', 'Source'); const { proposalId } = JSON.parse( diff --git a/packages/api/test/proposal-seed-materialization.test.js b/packages/api/test/proposal-seed-materialization.test.js index 96b0e5f0407..7965dee68f6 100644 --- a/packages/api/test/proposal-seed-materialization.test.js +++ b/packages/api/test/proposal-seed-materialization.test.js @@ -17,11 +17,6 @@ describe('F128 proposal seed materialization', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const ctx = await createProposalTestContext({ invocationQueueOverride: new InvocationQueue(), - queueProcessorOverride: { - async processNext() { - return { started: true }; - }, - }, }); const source = await ctx.threadStore.create('alice', 'Community gatekeeper'); const contentBlocks = [ @@ -67,11 +62,6 @@ describe('F128 proposal seed materialization', () => { const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const ctx = await createProposalTestContext({ invocationQueueOverride: new InvocationQueue(), - queueProcessorOverride: { - async processNext() { - return { started: true }; - }, - }, }); const source = await ctx.threadStore.create('alice', 'Community gatekeeper'); const contentBlocks = [ diff --git a/packages/api/test/proposal-source-envelope.test.js b/packages/api/test/proposal-source-envelope.test.js index 76975f773e7..0e5995f4aff 100644 --- a/packages/api/test/proposal-source-envelope.test.js +++ b/packages/api/test/proposal-source-envelope.test.js @@ -121,11 +121,6 @@ describe('F128 proposal source envelope', () => { const ctx = await createProposalTestContext({ routerOverride: router, invocationQueueOverride: new InvocationQueue(), - queueProcessorOverride: { - async processNext() { - return { started: true }; - }, - }, }); const source = await ctx.threadStore.create('alice', 'Community gatekeeper'); diff --git a/packages/api/test/queue-enrichment-connector-preview.test.js b/packages/api/test/queue-enrichment-connector-preview.test.js index 938fc8fdfa8..5baa12e4787 100644 --- a/packages/api/test/queue-enrichment-connector-preview.test.js +++ b/packages/api/test/queue-enrichment-connector-preview.test.js @@ -7,7 +7,7 @@ import { enrichQueueEntries } from '../dist/utils/queue-enrichment.js'; async function enqueueConnectorMessage(source) { const queue = new InvocationQueue(); const store = new MessageStore(); - const result = await queue.appendAndEnqueueDurable( + const result = await queue.send( store, { from: { kind: 'external', connectorId: source?.connector ?? 'test-fixture' }, diff --git a/packages/api/test/queue-ledger-redis.test.js b/packages/api/test/queue-ledger-redis.test.js index 43dbd0142e6..9afe7503a8b 100644 --- a/packages/api/test/queue-ledger-redis.test.js +++ b/packages/api/test/queue-ledger-redis.test.js @@ -291,7 +291,7 @@ describe('RFC #1356 Redis Queue ledger', { skip: redisIsolationSkipReason(REDIS_ it('retires one unread-adopted target while preserving the remaining target on the same row', async () => { const queue = new InvocationQueue(store); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messageStore, { from: { kind: 'user', userId: 'owner-1' }, @@ -368,7 +368,7 @@ describe('RFC #1356 Redis Queue ledger', { skip: redisIsolationSkipReason(REDIS_ for (let index = 0; index < 5; index += 1) { const id = `request-${index}`; - const admitted = await queue.appendAndEnqueueDurable(messageStore, message(id), input(id)); + const admitted = await queue.send(messageStore, message(id), input(id)); assert.equal(admitted.outcome, 'enqueued'); assert.equal(admitted.entries.length, 1); assert.deepEqual(admitted.entry.targets, ['opus', 'codex']); @@ -377,15 +377,11 @@ describe('RFC #1356 Redis Queue ledger', { skip: redisIsolationSkipReason(REDIS_ assert.equal((await store.list('thread-redis')).length, 5); assert.deepEqual(await messageStore.getByThreadAfter('thread-redis', undefined, undefined, 'owner-1'), []); - const replay = await queue.appendAndEnqueueDurable(messageStore, message('request-0'), input('request-0')); + const replay = await queue.send(messageStore, message('request-0'), input('request-0')); assert.equal(replay.deduped, true); assert.equal((await store.list('thread-redis')).length, 5); - const rejected = await queue.appendAndEnqueueDurable( - messageStore, - message('over-capacity'), - input('over-capacity'), - ); + const rejected = await queue.send(messageStore, message('over-capacity'), input('over-capacity')); assert.deepEqual(rejected, { outcome: 'full' }); assert.equal(await messageStore.getByIdempotencyKey('owner-1', 'thread-redis', 'over-capacity'), null); @@ -496,7 +492,7 @@ describe('RFC #1356 Redis Queue ledger', { skip: redisIsolationSkipReason(REDIS_ it('expands a targetless source on the same row and preserves FIFO metadata', async () => { const queue = new InvocationQueue(store); - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messageStore, { from: { kind: 'user', userId: 'owner-1' }, diff --git a/packages/api/test/queue-message-admission.test.js b/packages/api/test/queue-message-admission.test.js index e7405adadfe..af6f0b61ac7 100644 --- a/packages/api/test/queue-message-admission.test.js +++ b/packages/api/test/queue-message-admission.test.js @@ -40,7 +40,7 @@ describe('ADR-043 atomic memory message + Queue admission', () => { const queue = new InvocationQueue(ledger); const messages = new MessageStore(); - const first = await queue.appendAndEnqueueDurable(messages, message('request-1'), queueInput('request-1')); + const first = await queue.send(messages, message('request-1'), queueInput('request-1')); assert.equal(first.outcome, 'enqueued'); assert.equal(first.deduped, false); assert.equal(first.entries.length, 1); @@ -50,7 +50,7 @@ describe('ADR-043 atomic memory message + Queue admission', () => { [[first.message.id, first.message.id]], ); - const replay = await queue.appendAndEnqueueDurable(messages, message('request-1'), queueInput('request-1')); + const replay = await queue.send(messages, message('request-1'), queueInput('request-1')); assert.equal(replay.outcome, 'enqueued'); assert.equal(replay.deduped, true); assert.equal(replay.message.id, first.message.id); @@ -64,15 +64,76 @@ describe('ADR-043 atomic memory message + Queue admission', () => { for (let index = 0; index < 5; index += 1) { const id = `fill-${index}`; - assert.equal((await queue.appendAndEnqueueDurable(messages, message(id), queueInput(id))).outcome, 'enqueued'); + assert.equal((await queue.send(messages, message(id), queueInput(id))).outcome, 'enqueued'); } - const rejected = await queue.appendAndEnqueueDurable( - messages, - message('over-capacity'), - queueInput('over-capacity'), - ); + const rejected = await queue.send(messages, message('over-capacity'), queueInput('over-capacity')); assert.deepEqual(rejected, { outcome: 'full' }); assert.equal(messages.getByIdempotencyKey('owner-1', 'thread-admission', 'over-capacity'), null); assert.equal((await ledger.list('thread-admission')).length, 5); }); }); + +it('common send keeps durable acceptance when its wake fails and registers observers before wake', async () => { + const events = []; + const messages = new MessageStore(); + const queue = new InvocationQueue(undefined, { + onAdmitted: ({ message: source, entries }) => { + events.push('wake'); + assert.ok(messages.getById(source.id)); + assert.equal(entries.length, 1); + throw new Error('owned wake failure'); + }, + }); + const admitted = await queue.send(messages, message('observer-order'), { + ...queueInput('observer-order'), + onQueueEntriesAdmitted: () => events.push('observer'), + }); + assert.equal(admitted.outcome, 'enqueued'); + assert.deepEqual(events, ['observer', 'wake']); + assert.equal(queue.list('thread-admission', 'owner-1').length, 1); +}); + +it('an exact Live recipient cannot be replaced with a conversation fallback', async () => { + const messages = new MessageStore(); + const queue = new InvocationQueue(undefined, { resolveTargets: async () => ['opus'] }); + await assert.rejects( + () => + queue.send(messages, message('explicit-conflict'), { + ...queueInput('explicit-conflict'), + liveSessionId: 'exact-live-session', + targetCats: ['codex'], + }), + /explicit targets cannot be rerouted/, + ); + assert.equal(messages.getByIdempotencyKey('owner-1', 'thread-admission', 'explicit-conflict'), null); + assert.equal(queue.list('thread-admission', 'owner-1').length, 0); +}); + +it('a failed atomic write does not consume the source identity or freeze a default policy', async (t) => { + const { mkdtemp, rm } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const { saveMessageDispositionPreference } = await import('../dist/config/user-preferences-store.js'); + const projectRoot = await mkdtemp(join(tmpdir(), 'f117-admission-failure-')); + t.after(() => rm(projectRoot, { recursive: true, force: true })); + const ledger = new InMemoryQueueLedgerStore(); + const enqueue = ledger.enqueueNow.bind(ledger); + let fail = true; + ledger.enqueueNow = (...args) => { + if (fail) throw new Error('owned precommit failure'); + return enqueue(...args); + }; + const messages = new MessageStore(); + const queue = new InvocationQueue(ledger, { projectRoot }); + await assert.rejects( + () => queue.send(messages, message('recover-policy'), queueInput('recover-policy')), + /owned precommit failure/, + ); + assert.equal(messages.getByIdempotencyKey('owner-1', 'thread-admission', 'recover-policy'), null); + assert.equal((await ledger.list('thread-admission')).length, 0); + saveMessageDispositionPreference(projectRoot, { scope: 'global', disposition: 'continue_current' }); + fail = false; + const recovered = await queue.send(messages, message('recover-policy'), queueInput('recover-policy')); + assert.equal(recovered.deduped, false); + assert.equal(recovered.entry.delivery.authorIntentByTarget.opus.requested, 'continue_current'); +}); diff --git a/packages/api/test/queue-processor-zombie.test.js b/packages/api/test/queue-processor-zombie.test.js index 41d5b8a6b2a..eb253e3cf70 100644 --- a/packages/api/test/queue-processor-zombie.test.js +++ b/packages/api/test/queue-processor-zombie.test.js @@ -113,7 +113,7 @@ describe('QueueProcessor explicit stale-owner recovery (F118)', () => { const messageStore = new MessageStore(); const deps = stubDeps({ messageStore }); const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); - const admitted = await deps.queue.appendAndEnqueueDurable( + const admitted = await deps.queue.send( messageStore, { from: { kind: 'user', userId: 'u1' }, @@ -162,7 +162,7 @@ describe('QueueProcessor explicit stale-owner recovery (F118)', () => { const messageStore = new MessageStore(); const deps = stubDeps({ messageStore }); const processor = new QueueProcessor(deps, { processingSlotTtlMs: SHORT_TTL }); - const admitted = await deps.queue.appendAndEnqueueDurable( + const admitted = await deps.queue.send( messageStore, { from: { kind: 'user', userId: 'u1' }, diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index 2d2ddd801ea..2009e229a10 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -77,8 +77,19 @@ function createHarness({ turnExecutionStore, actionSuccessorLeaseStore, deploymentWaitStartGuard, + autoDrain = false, } = {}) { - const queue = new InvocationQueue(); + let processor; + const queue = new InvocationQueue( + undefined, + autoDrain + ? { + onAdmitted: ({ threadId }) => { + void processor.requestDrain(threadId); + }, + } + : {}, + ); const messageStore = new MessageStore(); const invocationRecordStore = createInvocationRecordStore(); const routeCalls = []; @@ -125,7 +136,8 @@ function createHarness({ ...(actionSuccessorLeaseStore ? { actionSuccessorLeaseStore } : {}), ...(deploymentWaitStartGuard ? { deploymentWaitStartGuard } : {}), }; - return { ...deps, processor: new QueueProcessor(deps, processorOptions), routeCalls }; + processor = new QueueProcessor(deps, processorOptions); + return { ...deps, processor, routeCalls }; } /** Records and ends the child turn the way invoke-single-cat does, entering the response-pending ledger. */ @@ -213,7 +225,7 @@ async function admitMessage(harness, overrides = {}) { timestamp: Date.now(), deliveryStatus: 'queued', }); - const result = await harness.queue.appendAndEnqueueDurable(harness.messageStore, messageInput, queueInput); + const result = await harness.queue.send(harness.messageStore, messageInput, queueInput); assert.equal(result.outcome, 'enqueued'); assert.ok(result.entry); return result; @@ -400,7 +412,7 @@ describe('QueueProcessor over the source-row pending Queue', () => { targetCats: ['opus'], intent: 'execute', }); - const admitted = await harness.queue.appendAndEnqueueDurable( + const admitted = await harness.queue.send( harness.messageStore, canonicalTestMessageInput({ threadId: queueInput.threadId, @@ -2174,6 +2186,7 @@ describe('F117 soak: a waiting entry does not stop its thread’s queue', () => const attempts = []; const retryAt = Date.now() + 200; const harness = createHarness({ + autoDrain: true, processorOptions: { retryDeferral: { baseDelayMs: 20 } }, routeExecution: routeFailingBeforeAcceptance(attempts, ['opus']), }); diff --git a/packages/api/test/route-serial-callback-dedup.test.js b/packages/api/test/route-serial-callback-dedup.test.js index abfb901c012..06992a4777c 100644 --- a/packages/api/test/route-serial-callback-dedup.test.js +++ b/packages/api/test/route-serial-callback-dedup.test.js @@ -296,7 +296,7 @@ describe('#573/#1332: explicit callback/final persistence semantics', () => { timestamp: Date.now(), }; if (!scenario.failed) { - const admitted = await queue.appendAndEnqueueDurable( + const admitted = await queue.send( messages, { from: { kind: 'agent', catId: 'opus' }, diff --git a/packages/api/test/routing-dispatch-preflight.test.js b/packages/api/test/routing-dispatch-preflight.test.js index 422d8d622f0..4d46962dcfa 100644 --- a/packages/api/test/routing-dispatch-preflight.test.js +++ b/packages/api/test/routing-dispatch-preflight.test.js @@ -547,14 +547,29 @@ describe('F117 normal dispatch without availability gating', () => { assert.deepEqual([...calls].sort(), ['codex', 'opus', 'opus']); }); - test('callback queue and Append never consult or expose availability', async () => { + test('callback admits exact targets without availability checks and notifies the shared drain once', async () => { const { enqueueA2ATargets } = await import('../dist/routes/callback-a2a-trigger.js'); const { InvocationQueue } = await import('../dist/domains/cats/services/agents/invocation/InvocationQueue.js'); const { MessageStore } = await import('../dist/domains/cats/services/stores/ports/MessageStore.js'); - const queue = new InvocationQueue(); + const drainThreads = []; + let appendCalls = 0; + let availabilityCalls = 0; + const queueProcessor = { + async requestDrain(threadId) { + drainThreads.push(threadId); + }, + async tryAutoAppendExactEntry() { + appendCalls++; + throw new Error('callback producer must not orchestrate Append'); + }, + }; + const queue = new InvocationQueue(undefined, { + onAdmitted: ({ threadId }) => { + void queueProcessor.requestDrain(threadId); + }, + }); const messageStore = new MessageStore(); const broadcasts = []; - const appendedTargets = []; const triggerMessage = messageStore.append({ from: { kind: 'agent', catId: 'terra' }, threadId: 'thread-callback', @@ -568,12 +583,7 @@ describe('F117 normal dispatch without availability gating', () => { { invocationQueue: queue, messageStore, - queueProcessor: { - async requestDrain() {}, - async tryAutoAppendExactEntry(input) { - appendedTargets.push(input.targetCatId); - }, - }, + queueProcessor, socketManager: { broadcastAgentMessage(message, threadId) { broadcasts.push({ message, threadId }); @@ -582,7 +592,10 @@ describe('F117 normal dispatch without availability gating', () => { emitToUser() {}, }, routingDispatchPreflight: { - preflight: async (input) => decision(input, { opus: 'rejected', codex: 'warned' }), + preflight: async (input) => { + availabilityCalls++; + return decision(input, { opus: 'rejected', codex: 'warned' }); + }, }, log: { error() {}, warn() {}, info() {} }, }, @@ -598,7 +611,9 @@ describe('F117 normal dispatch without availability gating', () => { ); assert.deepEqual(result.enqueued, ['opus', 'codex']); - assert.deepEqual(appendedTargets, ['opus', 'codex'], 'every requested target may Append'); + assert.deepEqual(drainThreads, ['thread-callback'], 'one common-admission wake covers the complete fan-out'); + assert.equal(appendCalls, 0, 'Append belongs to Queue execution, not the callback producer'); + assert.equal(availabilityCalls, 0); assert.equal(result.routingPreflight, undefined); assert.deepEqual( queue.list('thread-callback', 'owner-1').flatMap((entry) => entry.targets), diff --git a/packages/api/test/routing-warning-feedback.test.js b/packages/api/test/routing-warning-feedback.test.js index d1f8cf172b3..cc2c6210a18 100644 --- a/packages/api/test/routing-warning-feedback.test.js +++ b/packages/api/test/routing-warning-feedback.test.js @@ -1,16 +1,7 @@ /** - * @not-found feedback regression - * - * Bug (docs/bug-report/2026-05-29-invocation-stale-active-recovery §3.2 Thread 1 trigger): - * When user sends "@kimi 你来做" but kimi is disabled/not-in-roster: - * - `resolveCatTarget` → cat_not_found error → routing_warnings populated - * - AgentRouter.resolveTargets finds no valid mentions → falls back to default cat - * - User gets NO feedback that @kimi was not found → confusion (opus answers unexpectedly) - * - * Fix: resolveTargetsAndIntent returns routing_warnings alongside targetCats/intent/hasMentions. - * Callers (messages.ts) can use this to emit a user-visible warning. - * - * RED → GREEN after modifying AgentRouter.resolveTargetsAndIntent to expose routing_warnings + * Ordinary prose mention parsing does not invent routing errors for unmatched @ text. + * Valid members remain targets; common send applies fallback when none are available. + * Explicit A2A recipient validation is a separate contract. */ import './helpers/setup-cat-registry.js'; @@ -64,7 +55,7 @@ function buildRouter() { }); } -describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found cats', () => { +describe('AgentRouter.resolveTargetsAndIntent: ordinary prose mention contract', () => { it('keeps prose parsing targetless so the message ingress can apply the canonical fallback', async () => { const router = buildRouter(); const result = await router.resolveTargetsAndIntent('continue', 'thread-1', { allowFallback: false }); @@ -74,7 +65,7 @@ describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found ca assert.deepEqual(result.routing_warnings, []); }); - it('does not substitute the default cat for an invalid explicit mention', async () => { + it('ignores unmatched prose before common admission without inventing a warning', async () => { const router = buildRouter(); const result = await router.resolveTargetsAndIntent('@ghostcat continue', 'thread-1', { allowFallback: false, @@ -82,14 +73,13 @@ describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found ca assert.deepEqual(result.targetCats, []); assert.equal(result.hasMentions, false); - assert.equal(result.routing_warnings[0]?.kind, 'cat_not_found'); + assert.deepEqual(result.routing_warnings, []); }); it('returns routing_warnings field (at minimum an empty array)', async () => { const router = buildRouter(); const result = await router.resolveTargetsAndIntent('help me', 'thread-1'); - // RED: currently resolveTargetsAndIntent does not return routing_warnings assert.ok( 'routing_warnings' in result, `resolveTargetsAndIntent must return a routing_warnings field. Got keys: ${Object.keys(result).join(', ')}`, @@ -106,25 +96,20 @@ describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found ca assert.deepEqual(result.targetCats.map(String), ['opus'], 'targetCats should be opus for @opus mention'); }); - it('returns cat_not_found routing_warning for an unknown line-start handle', async () => { + it('uses ordinary fallback for an unmatched line-start handle without a warning', async () => { const router = buildRouter(); - // @ghostcat is NOT in cat-template.json → matches no registered cat pattern → unknown handle. - // (The earlier version used @kimi, but kimi IS in cat-template.json — so @kimi matched a real - // pattern via the main loop and never exercised the unknown path. Combined with the old - // Array.isArray-only assertion, that was a double false-green. @ghostcat guarantees the - // unknown-handle path codex flagged in P2.) + // @ghostcat does not match any registered member; its text stays ordinary prose. const result = await router.resolveTargetsAndIntent('@ghostcat 你来做这个', 'thread-1'); assert.ok('routing_warnings' in result, 'routing_warnings field must exist'); - assert.ok( - result.routing_warnings.length > 0, - 'unknown line-start @handle must produce a routing_warning, not silently fall back to default cat', - ); - assert.equal(result.routing_warnings[0]?.kind, 'cat_not_found', 'warning kind must be cat_not_found'); + assert.deepEqual(result.routing_warnings, []); + assert.equal(result.hasMentions, false); + assert.deepEqual(result.targetCats, ['opus']); + assert.deepEqual(await router.resolveSendTargets([], 'thread-1', '@ghostcat 你来做这个'), ['opus']); }); - it('preserves warning for breed with service but all cats unavailable (#826 maintainer re-review)', async () => { + it('uses available fallback when a mentioned breed has no available member', async () => { // "antigravity" (breedId: bengal) has available:false in cat-template.json. // Register a service for it so it's service-backed but unavailable. const agentRegistry = new AgentRegistry(); @@ -138,16 +123,10 @@ describe('AgentRouter.resolveTargetsAndIntent: routing_warnings for not-found ca messageStore: createMockMessageStore(), }); - // antigravity has a service but available:false → isRoutableCat returns false. - // @all-bengal should NOT be suppressed — user should know the breed didn't route. + // A service object does not make a disabled breed available for ordinary delivery. const result = await router.resolveTargetsAndIntent('@thread\n@all-bengal hi', 'thread-1'); - const bengalWarning = result.routing_warnings.find( - (w) => w.kind === 'cat_not_found' && w.mention.toLowerCase().includes('bengal'), - ); - assert.ok( - bengalWarning, - `expected cat_not_found warning for unavailable @all-bengal breed, got warnings: ${JSON.stringify(result.routing_warnings)}`, - ); + assert.deepEqual(result.routing_warnings, []); + assert.deepEqual(await router.resolveSendTargets([], 'thread-1', '@thread\n@all-bengal hi'), ['opus']); }); }); diff --git a/packages/api/test/signal-intake/meeting-intake-action-service.test.js b/packages/api/test/signal-intake/meeting-intake-action-service.test.js index 00a75327c60..627470ca838 100644 --- a/packages/api/test/signal-intake/meeting-intake-action-service.test.js +++ b/packages/api/test/signal-intake/meeting-intake-action-service.test.js @@ -216,12 +216,11 @@ describe('F292 MeetingIntakeActionService', () => { }, messageStore, invocationQueue: { - async appendAndEnqueueDurable(store, messageInput) { + async send(store, messageInput) { const message = await store.append(messageInput); return { outcome: 'enqueued', entry: { id: `queue-${++queueSequence}` }, message }; }, }, - queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: { emitToUser() {} }, supportsPresentationRetry: () => true, now: () => 12_000, diff --git a/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js b/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js index bfa4ce57506..6ee7fba6f2e 100644 --- a/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js +++ b/packages/api/test/signal-intake/thread-artifact-dispatcher.test.js @@ -51,7 +51,7 @@ describe('F292 private-thread artifact handoff', () => { const appended = []; const enqueued = []; const queue = { - async appendAndEnqueueDurable(messageStore, messageInput, queueInput) { + async send(messageStore, messageInput, queueInput) { const message = await messageStore.append(messageInput); enqueued.push(queueInput); return { outcome: 'enqueued', entry: { id: 'q-1' }, message }; @@ -66,7 +66,6 @@ describe('F292 private-thread artifact handoff', () => { }, }, invocationQueue: queue, - queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: noopSocketManager, supportsPresentationRetry: () => true, now: () => 12_000, @@ -127,7 +126,7 @@ describe('F292 private-thread artifact handoff', () => { assert.equal(buildMeetingArtifactPrompt(intake, artifact), appended[0].content); }); - it('persists the durable Host receipt before processNext even when execution does not start', async () => { + it('confirms the durable Host receipt without scheduling in the producer', async () => { const order = []; const durableInputs = []; const dispatcher = new ThreadMeetingArtifactDispatcher({ @@ -142,20 +141,11 @@ describe('F292 private-thread artifact handoff', () => { invocationQueue: { // F117: the dispatcher now routes admission through the durable // append+enqueue contract instead of enqueue + backfill. - async appendAndEnqueueDurable(messageStore, messageInput) { + async send(messageStore, messageInput) { const message = await messageStore.append(messageInput); order.push('durableAppend'); return { outcome: 'enqueued', entry: { id: 'queue-visible', messageId: message.id }, message }; }, - enqueue: () => ({ outcome: 'enqueued', entry: { id: 'queue-visible', messageId: null } }), - backfillMessageId() {}, - rollbackEnqueue() {}, - }, - queueProcessor: { - processNext: async () => { - order.push('processNext'); - return { started: false }; - }, }, socketManager: { emitToUser() { @@ -191,9 +181,9 @@ describe('F292 private-thread artifact handoff', () => { assert.equal(receipt, undefined); // F117 new contract: the owner-visible admission anchor is the durable message - // append (via appendAndEnqueueDurable), ordered before processNext; the old + // append (via send); the shared Queue owner controls progress. The old // 'messages_queued' socket publication was retired from this dispatcher. - assert.deepEqual(order, ['durableAppend', 'processNext']); + assert.deepEqual(order, ['durableAppend']); assert.equal(durableInputs.length, 1); assert.equal(durableInputs[0].threadId, 'thread-1'); assert.equal(durableInputs[0].userId, 'owner-1'); @@ -209,7 +199,6 @@ describe('F292 private-thread artifact handoff', () => { it('admits the Alpha canary through the canonical meeting write-opportunity producer', async () => { const appended = []; - const processed = []; const dispatcher = new ThreadMeetingArtifactDispatcher({ threadStore: { get: async () => thread }, messageStore: { @@ -221,19 +210,10 @@ describe('F292 private-thread artifact handoff', () => { }, invocationQueue: { // F117: same durable append+enqueue contract as the production InvocationQueue. - async appendAndEnqueueDurable(messageStore, messageInput) { + async send(messageStore, messageInput) { const message = await messageStore.append(messageInput); return { outcome: 'enqueued', entry: { id: 'q-alpha', messageId: message.id }, message }; }, - enqueue: () => ({ outcome: 'enqueued', entry: { id: 'q-alpha', messageId: null } }), - backfillMessageId() {}, - rollbackEnqueue() {}, - }, - queueProcessor: { - processNext: async (...args) => { - processed.push(args); - return { started: true }; - }, }, socketManager: noopSocketManager, supportsPresentationRetry: () => true, @@ -251,9 +231,8 @@ describe('F292 private-thread artifact handoff', () => { sourceMessageId: 'msg-alpha', targetCatId: 'codex-sol', deduped: false, - started: true, + started: false, }); - assert.deepEqual(processed, [['thread-1', 'owner-1']]); assert.equal(appended.length, 1); assert.deepEqual(appended[0].source, { connector: 'cat-cafe-alpha', @@ -339,7 +318,7 @@ describe('F292 private-thread artifact handoff', () => { const enqueued = []; const published = []; const queue = { - async appendAndEnqueueDurable(messageStore, messageInput, queueInput) { + async send(messageStore, messageInput, queueInput) { const message = await messageStore.append(messageInput); enqueued.push(queueInput); return { outcome: 'enqueued', entry: { id: 'q-retry' }, message }; @@ -362,7 +341,6 @@ describe('F292 private-thread artifact handoff', () => { }, }, invocationQueue: queue, - queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: { emitToUser(...args) { published.push(args); @@ -480,7 +458,7 @@ describe('F292 private-thread artifact handoff', () => { appended.length > 0 ? { ...appended[0], id: 'meeting-message-1', threadId: appended[0].threadId } : null, }, invocationQueue: { - async appendAndEnqueueDurable(messageStore, messageInput) { + async send(messageStore, messageInput) { enqueueCalls += 1; if (enqueueCalls === 1) { const message = await messageStore.append(messageInput); @@ -494,7 +472,6 @@ describe('F292 private-thread artifact handoff', () => { }; }, }, - queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: noopSocketManager, supportsPresentationRetry: () => true, now: () => 2, @@ -585,12 +562,11 @@ describe('F292 private-thread artifact handoff', () => { append: async () => assert.fail('must not append'), }, invocationQueue: { - appendAndEnqueueDurable() { + send() { enqueueCount += 1; return { outcome: 'enqueued', entry: { id: 'unexpected' } }; }, }, - queueProcessor: { processNext: async () => ({ started: true }) }, socketManager: noopSocketManager, supportsPresentationRetry: () => false, }); diff --git a/packages/web/src/components/ChatInput.tsx b/packages/web/src/components/ChatInput.tsx index 53c649d1779..41b07492589 100644 --- a/packages/web/src/components/ChatInput.tsx +++ b/packages/web/src/components/ChatInput.tsx @@ -230,9 +230,7 @@ export function ChatInput({ .filter((cat) => activeCatIds.has(cat.id) && cat.messageDeliveryCapabilities?.guideReply === true) .map(formatCatDisplayName); const guideReplyHint = - guideReplyNames.length > 0 - ? `执行发生了偏离?${guideReplyNames.join('/')}支持引导回复,可继续输入直接发送消息而不中断当前回复` - : undefined; + guideReplyNames.length > 0 ? `想调整方向?可直接给${guideReplyNames.join('/')}发消息,引导回复。` : undefined; const [addMenuOpen, setAddMenuOpen] = useState(false); const [contextPickerMode, setContextPickerMode] = useState(null); diff --git a/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts b/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts index 848163d3d31..13f22740034 100644 --- a/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts +++ b/packages/web/src/components/__tests__/chat-input-message-disposition.test.ts @@ -196,11 +196,11 @@ describe('F264 author message disposition selector', () => { useChatStore.setState({ targetCats: ['opus', 'kimi', 'sol'] }); await renderThreadInput({ threadId: 'thread-active', onSend, hasActiveInvocation: true }); expect(container.querySelector('textarea')?.placeholder).toBe( - '执行发生了偏离?布偶猫/缅因猫(sol)支持引导回复,可继续输入直接发送消息而不中断当前回复', + '想调整方向?可直接给布偶猫/缅因猫(sol)发消息,引导回复。', ); await chooseContinueCurrent(); expect(container.querySelector('textarea')?.placeholder).toBe( - '执行发生了偏离?布偶猫/缅因猫(sol)支持引导回复,可继续输入直接发送消息而不中断当前回复', + '想调整方向?可直接给布偶猫/缅因猫(sol)发消息,引导回复。', ); expect(container.querySelector('[data-testid="active-invocation-banner"]')).toBeNull();