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 b8975eae98..c9a210794b 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/InvocationQueue.ts @@ -46,6 +46,8 @@ export interface QueueEntry { position?: number; /** F175: skill hint for connector triggers — flows through as promptTags on execution */ suggestedSkill?: string; + /** True only for connector wakes backed by verified external callback/tracking coverage. */ + eventDrivenExternalWaitCoverage?: boolean; callerTraceContext?: CallerTraceContext; /** Explicit A2A trigger message for stream reply threading. */ a2aTriggerMessageId?: string; @@ -183,6 +185,9 @@ export class InvocationQueue { if (input.sourceCategory && !existing.sourceCategory) { existing.sourceCategory = input.sourceCategory; } + if (input.eventDrivenExternalWaitCoverage) { + existing.eventDrivenExternalWaitCoverage = true; + } } const position = q.findIndex((entry) => entry.id === existing.id); return { @@ -222,6 +227,7 @@ export class InvocationQueue { sourceCategory: input.sourceCategory, continuationKey: input.continuationKey, suggestedSkill: input.suggestedSkill, + eventDrivenExternalWaitCoverage: input.eventDrivenExternalWaitCoverage, callerTraceContext: input.callerTraceContext, a2aTriggerMessageId: input.a2aTriggerMessageId, position: undefined, 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 fd170e926e..634ce7dc4e 100644 --- a/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts +++ b/packages/api/src/domains/cats/services/agents/invocation/QueueProcessor.ts @@ -1267,6 +1267,8 @@ export class QueueProcessor { // #949 P1-1: Connector-sourced queue entries have no ball-pass expectation. // A2A/agent entries still get the verdict-pass handoff guard. verdictPassWarningEnabled: entry.source !== 'connector', + // Only policy-backed connector wakes prove a future callback/tracking path. + eventDrivenExternalWaitCoverage: entry.eventDrivenExternalWaitCoverage === true, }, )) { if (controller.signal.aborted) { 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 e103a8319e..6f70c75260 100644 --- a/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts +++ b/packages/api/src/domains/cats/services/agents/routing/AgentRouter.ts @@ -1500,6 +1500,8 @@ export class AgentRouter { /** #949 P2: Whether verdict-without-pass warning fires at route end. * true/undefined = warn (default). false = suppress for connector-sourced flows only. */ verdictPassWarningEnabled?: boolean; + /** Whether event-driven external waits are backed by verified callback/tracking coverage. */ + eventDrivenExternalWaitCoverage?: boolean; }, ): AsyncIterable { const cleanMessage = stripIntentTags(message); @@ -1619,6 +1621,9 @@ export class AgentRouter { ...(options?.verdictPassWarningEnabled !== undefined ? { verdictPassWarningEnabled: options.verdictPassWarningEnabled } : {}), + ...(options?.eventDrivenExternalWaitCoverage !== undefined + ? { eventDrivenExternalWaitCoverage: options.eventDrivenExternalWaitCoverage } + : {}), }; try { diff --git a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts index 2f9e366a78..c313bb4aa1 100644 --- a/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts +++ b/packages/api/src/domains/cats/services/agents/routing/final-routing-slot.ts @@ -24,6 +24,8 @@ export interface ValidationInput { readonly structuredTargetCats: readonly string[]; /** Roster handle whitelist (from cat-config). Non-roster @ mentions are ignored. */ readonly rosterHandles: readonly string[]; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } export type ValidationResult = @@ -37,6 +39,50 @@ export type ValidationResult = const MARKDOWN_LINE_PREFIX_RE = /^(?:(?:>\s*)|(?:[-*+]\s+)|(?:\d+[.)]\s+))+/; const URL_RE = /https?:\/\/[^\s)\]]+/g; const FENCED_CODE_RE = /```[\s\S]*?```/g; +const EVENT_DRIVEN_EXTERNAL_WAIT_RE = + /^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i; +const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u; + +/** + * Strip trailing cat-signature paragraphs so final-slot checks land on the last + * content paragraph. Body bracket tokens like `[Phase B]` are preserved because + * they do not match the slashed-or-paw signature shape. + */ +export function stripTrailingCatSignatures(text: string): string { + if (!text) return text; + const lines = text.split(/\r?\n/); + let lastContentIdx = lines.length - 1; + while (lastContentIdx >= 0) { + const line = lines[lastContentIdx] ?? ''; + if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) { + lastContentIdx--; + continue; + } + break; + } + if (lastContentIdx < 0) return ''; + return lines.slice(0, lastContentIdx + 1).join('\n'); +} + +function selectFinalRoutingSlot(text: string, options: { stripUrls: boolean }): string { + if (!text) return ''; + + const noFence = text.replace(FENCED_CODE_RE, ''); + + const noQuote = noFence + .split(/\r?\n/) + .filter((line) => !/^\s*>/.test(line)) + .join('\n'); + + const slotSource = options.stripUrls ? noQuote.replace(URL_RE, '') : noQuote; + + const paragraphs = slotSource + .split(/\n\s*\n/) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; +} /** * Extract final routing slot = structurally-stripped last non-empty paragraph. @@ -53,23 +99,26 @@ const FENCED_CODE_RE = /```[\s\S]*?```/g; * later (via optional param), this function's signature can be extended. */ export function finalRoutingSlot(text: string): string { - if (!text) return ''; - - const noFence = text.replace(FENCED_CODE_RE, ''); - - const noQuote = noFence - .split(/\r?\n/) - .filter((line) => !/^\s*>/.test(line)) - .join('\n'); + return selectFinalRoutingSlot(text, { stripUrls: true }); +} - const noUrl = noQuote.replace(URL_RE, ''); +function finalRoutingSlotPreservingUrls(text: string): string { + return selectFinalRoutingSlot(text, { stripUrls: false }); +} - const paragraphs = noUrl - .split(/\n\s*\n/) - .map((p) => p.trim()) - .filter((p) => p.length > 0); +function slotHasEventDrivenExternalWaitExit(slot: string): boolean { + if (!slot) return false; + return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim())); +} - return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; +/** + * True iff the final routing slot contains the documented structural 2b external-wait exit. + * + * This is deliberately a slot-template check, not a natural-language intent classifier. + */ +export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { + if (!text) return false; + return slotHasEventDrivenExternalWaitExit(finalRoutingSlotPreservingUrls(stripTrailingCatSignatures(text))); } /** @@ -132,6 +181,7 @@ export function findInlineMentionsInSlot(slot: string, rosterHandles: readonly s * - legitimate line-start @mention present * - hold_ball tool call present * - structured MCP routing (targetCats / multi_mention targets) present + * - structural 2b external wait slot present with verified callback coverage * - no inline @handle inside final routing slot * * Returns `invalid_route_syntax` when NONE of the above AND slot has inline @handle. @@ -142,6 +192,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult if (input.structuredTargetCats.length > 0) return { kind: 'ok' }; const slot = finalRoutingSlot(input.text); + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return { kind: 'ok' }; + const inlineMentions = findInlineMentionsInSlot(slot, input.rosterHandles); if (inlineMentions.length === 0) return { kind: 'ok' }; diff --git a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts index 297dae3660..6c7db8ecc9 100644 --- a/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts @@ -12,6 +12,8 @@ * KD-8 safe:只看"有无机械出口信号",零意图分类器。 */ +import { hasEventDrivenExternalWaitExit } from '../final-routing-slot.js'; + /** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */ const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const; @@ -23,6 +25,8 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean { } export interface RoutingExitInput { + /** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */ + readonly text?: string; /** Line-start @cat mentions parsed this turn (parseA2AMentions). */ readonly lineStartMentions: readonly string[]; /** Tool names invoked this turn (scan for hold_ball / multi_mention). */ @@ -31,18 +35,22 @@ export interface RoutingExitInput { readonly structuredTargetCats: readonly string[]; /** Line-start @co-creator / @co-creator escalation to co-creator. */ readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } /** * True iff the turn has a legitimate routing exit (传球 / 持球 / 升级). * Mirrors the suppression set of evaluateVoidHold + F177-G hook - * (line-start @, hold_ball, multi_mention, targetCats, co-creator). + * (line-start @, hold_ball, multi_mention, targetCats, co-creator, structural + * 2b external wait slot with verified callback coverage). */ export function hasValidRoutingExit(input: RoutingExitInput): boolean { if (input.lineStartMentions.length > 0) return true; if (input.structuredTargetCats.length > 0) return true; if (input.hasCoCreatorLineStartMention) return true; if (hasRoutingToolCall(input.toolNames)) return true; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return true; return false; } @@ -74,6 +82,7 @@ export const REMEDIAL_PROMPT = '请只补一个出口,不要重做刚才的工作:\n' + '- 传球:另起一行,行首独立写 @句柄(如 @opus48)\n' + '- 持球等外部条件:调用 cat_cafe_hold_ball\n' + + '- 事件驱动外部等待(已有结构化回调 + EYES>0):另起一行写 External Wait: event-driven ()\n' + '- 升级co-creator:另起一行行首写 @co-creator'; export function buildRemedialPrompt(): string { diff --git a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts index ff3a49c9a3..53fd6a78ed 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-helpers.ts @@ -167,6 +167,10 @@ export interface RouteOptions { * Separate from frustrationAutoIssueEligible because A2A/multi-mention callbacks * suppress frustration issues but still need verdict-pass handoff guards. */ verdictPassWarningEnabled?: boolean | undefined; + /** Whether `External Wait: event-driven (...)` may count as a routing exit. + * Must be true only when the caller has verified callback/tracking coverage for + * the external id; text alone does not create a wake-up. */ + eventDrivenExternalWaitCoverage?: boolean | undefined; } export interface IncrementalContextResult { diff --git a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 3b3012cf59..e3f4242742 100644 --- a/packages/api/src/domains/cats/services/agents/routing/route-serial.ts +++ b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts @@ -107,7 +107,11 @@ import { import { accumulateTextAggregate } from '../text-aggregation.js'; import { formatA2AHandoffContent } from './a2a-handoff-label.js'; import { extractContextEvalSignals } from './context-eval.js'; -import { validateRoutingSyntax } from './final-routing-slot.js'; +import { + hasEventDrivenExternalWaitExit, + stripTrailingCatSignatures, + validateRoutingSyntax, +} from './final-routing-slot.js'; import { buildBriefingMessage } from './format-briefing.js'; import { buildRemedialPrompt, hasValidRoutingExit, shouldRemediateRouting } from './guards/routing-guard-remedial.js'; import { extractRichFromText, isValidRichBlock } from './rich-block-extract.js'; @@ -174,14 +178,23 @@ function stripMarkdownRoutePrefix(line: string): string { return line.replace(/^(?:[-*+]\s+|>\s*|\d+[.)]\s+)/, '').trim(); } -function normalizeRouteOnlyRemedialText(text: string): string | null { - const lines = text +function normalizeRouteOnlyRemedialText(text: string, hasEventDrivenExternalWaitCoverage: boolean): string | null { + const lines = stripTrailingCatSignatures(text) .trim() .split(/\r?\n/) .map((line) => stripMarkdownRoutePrefix(line)) .filter((line) => line.length > 0); if (lines.length !== 1) return null; - return ROUTE_ONLY_REMEDIAL_TEXT_RE.test(lines[0]) ? lines[0] : null; + const line = lines[0]!; + if (ROUTE_ONLY_REMEDIAL_TEXT_RE.test(line)) return line; + return hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(line) ? line : null; +} + +function buildRoutingAnalysisContent(storedContent: string, routingContent: string): string { + if (!routingContent.trim()) return storedContent; + if (!storedContent.trim()) return routingContent; + if (routingContent.trim() === storedContent.trim()) return storedContent; + return `${storedContent}\n\n${routingContent}`; } function collectStructuredTargetCatsFromInput(input: unknown): string[] { @@ -224,6 +237,13 @@ function isCrossPostMessageToolName(toolName: string | undefined): boolean { return toolName === 'mcp:cat-cafe/cross_post_message' || toolName === 'cat_cafe_cross_post_message'; } +function isSameTurnEventDrivenCoverageToolName(toolName: string | undefined): boolean { + const normalized = normalizeMcpToolName(toolName); + // PR tracking registration only proves the watcher was registered. The actual + // 2b condition requires a later review/CI callback with pickup coverage. + return normalized === 'register_issue_tracking'; +} + function isCallbackContentRoutingToolName(toolName: string | undefined): boolean { return isPostMessageToolName(toolName) || isCrossPostMessageToolName(toolName); } @@ -383,6 +403,7 @@ export async function* routeSerial( } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; + const initialEventDrivenExternalWaitCoverage = options.eventDrivenExternalWaitCoverage === true; // P2-3 fix: also consider default MCP server path (ClaudeAgentService has fallback resolution) const mcpServerPath = process.env.CAT_CAFE_MCP_SERVER_PATH || resolveDefaultClaudeMcpServerPath(); const incrementalMode = Boolean(currentUserMessageId && deps.deliveryCursorStore); @@ -511,6 +532,9 @@ export async function* routeSerial( // Only pass images/uploads for the first cat (user's original target) const isOriginalTarget = index < targetCats.length; + // Event-driven wait coverage proves a wake path for the current invocation target, + // not for later A2A worklist entries. + let hasEventDrivenExternalWaitCoverage = initialEventDrivenExternalWaitCoverage && isOriginalTarget; const targetContentBlocks = isOriginalTarget ? routeContentBlocksForCat(catId, contentBlocks) : undefined; const targetUploadDir = targetContentBlocks ? uploadDir : undefined; @@ -1215,6 +1239,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName)) { + hasEventDrivenExternalWaitCoverage = true; + } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); } // F188 Phase F AC-F10 (砚砚 六审 P1-B: also scope by catId for serial route consistency). @@ -1483,6 +1510,7 @@ export async function* routeSerial( allRichBlocks: RichBlock[]; a2aMentions: CatId[]; hasCoCreatorLineStartMention: boolean; + routingContent: string; streamEvents: AgentMessage[]; }> => { routingGuardAttempted = true; @@ -1639,6 +1667,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName)) { + hasEventDrivenExternalWaitCoverage = true; + } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); } } @@ -1658,7 +1689,9 @@ export async function* routeSerial( const remedialSanitized = sanitizeInjectedContent(textContent); const remedialExtracted = extractRichFromText(remedialSanitized); const remedialCleanText = remedialExtracted.cleanText; - const remedialRouteOnlyContent = remedialCleanText ? normalizeRouteOnlyRemedialText(remedialCleanText) : null; + const remedialRouteOnlyContent = remedialCleanText + ? normalizeRouteOnlyRemedialText(remedialCleanText, hasEventDrivenExternalWaitCoverage) + : null; const remedialIsRouteOnly = remedialRouteOnlyContent !== null; // Route-only remedial text (`@cat` / `@co-creator`) is an exit patch, not a replacement artifact. // Use it for routing validation, but keep first-pass visible content so F5/history hydration @@ -1717,6 +1750,7 @@ export async function* routeSerial( allRichBlocks: remedialAllRichBlocks, a2aMentions: remedialA2aMentions, hasCoCreatorLineStartMention: remedialHasCoCreatorLineStartMention, + routingContent: remedialRoutingContent, // Exit-only remedials validate the original text instead of replacing it; surface it after validation. streamEvents: preservesOriginalVisibleContent ? [...visibleRemedialStreamEvents, ...originalVisibleStreamEventsForRemedialTurn] @@ -1732,10 +1766,12 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: '', lineStartMentions: getRoutingExitLineStartMentions(), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: hasRoutingExitCoCreatorLineStartMention(''), + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial( @@ -1748,10 +1784,12 @@ export async function* routeSerial( noTextBlocksOverride = result.allRichBlocks; if ( !hasValidRoutingExit({ + text: result.routingContent, lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: result.hasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -1765,6 +1803,7 @@ export async function* routeSerial( // F22: Extract cc_rich blocks from text (Route B fallback for non-MCP cats) const { cleanText, blocks: textBlocks } = extractRichFromText(sanitized); let storedContent = cleanText; + let routingAnalysisContent = storedContent; let allRichBlocks = [...bufferedBlocks, ...textBlocks, ...streamRichBlocks]; // F34-b: Resolve voice blocks (audio with text, no url) — Route B path. @@ -1799,16 +1838,19 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: storedContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial(storedContent, allRichBlocks, [...collectedToolEvents]); for (const event of result.streamEvents) yield event; await flushDeferredVoice(); storedContent = result.storedContent; + routingAnalysisContent = buildRoutingAnalysisContent(storedContent, result.routingContent); allRichBlocks = result.allRichBlocks; a2aMentions = result.a2aMentions; routingExitLineStartMentions = getRoutingExitLineStartMentions(a2aMentions); @@ -1816,10 +1858,12 @@ export async function* routeSerial( if ( !hasValidRoutingExit({ + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -1851,11 +1895,12 @@ export async function* routeSerial( } } const phaseHResult = validateRoutingSyntax({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], rosterHandles: phaseHRosterHandles, + hasEventDrivenExternalWaitCoverage, }); const phaseHHit = phaseHResult.kind === 'invalid_route_syntax'; if (phaseHHit && phaseHResult.kind === 'invalid_route_syntax') { @@ -2005,11 +2050,12 @@ export async function* routeSerial( // frustrationAutoIssueEligible=false but still need verdict-pass handoff guards. options.verdictPassWarningEnabled !== false && shouldWarnVerdictWithoutPass({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { try { @@ -2030,7 +2076,7 @@ export async function* routeSerial( }); const verdictFireAttr: Record = { ...c2BaseAttr, - [TRIGGER]: detectMatchedVerdictKeyword(storedContent) ?? 'unknown', + [TRIGGER]: detectMatchedVerdictKeyword(routingAnalysisContent) ?? 'unknown', }; c2VerdictHintEmitted.add(1, verdictFireAttr); c2VerdictWithoutPassCount.add(1, verdictFireAttr); @@ -2067,11 +2113,12 @@ export async function* routeSerial( // hold-claim message, so drilldown lands on the original content, not on the hint. let pendingC2VoidHoldSampleTrigger: string | null = null; const voidHoldEval = evaluateVoidHold({ - text: storedContent, + text: routingAnalysisContent, toolNames: collectedToolNames, lineStartMentions: routingExitLineStartMentions, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }); if (voidHoldEval.shouldEmit) { try { diff --git a/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts b/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts index 82cc5919f5..248adf6098 100644 --- a/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts +++ b/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts @@ -13,60 +13,7 @@ * shared-rules §10 已落地,本模块是不依赖猫配合的兜底信号。 */ -import { finalRoutingSlot } from './final-routing-slot.js'; - -/** - * Cat signature line pattern: must have a slash OR a paw 🐾. - * - * Per L0 identity rule (`assets/system-prompts/system-prompt-l0.md`) and - * `cat-cafe-skills/refs/commit-signatures.md`, valid cat signatures are: - * - Slashed: `[昵称/变体]` or `[昵称/变体🐾]` — e.g. `[宪宪/Opus-46🐾]`, - * `[砚砚/GPT-5.5]` - * - Slashless: `[昵称🐾]` — e.g. `[Spark🐾]`, - * `[烁烁🐾]` - * (paw REQUIRED — used by cats whose nickname is the full identifier) - * - * NOT a signature (per source-of-truth `commit-signatures.md` lines 12-13): - * - Slashless without paw — `[Spark]`, `[note]`, `[Phase B]` — these are - * just bracketed body tokens. R6 P1 (砚砚): the previous broader regex - * stripped these too, reintroducing the narrative-body false-positive - * class this PR is fixing (e.g. `"approved earlier.\n\n[Phase B]"` - * would strip `[Phase B]`, slot falls back to the prior paragraph, and - * `approved` fires.) - * - * Match: `[non-bracket-non-newline]` containing EITHER an internal `/` - * (matches both `[name/model]` and `[name/model🐾]`) OR a `🐾` (matches - * `[Spark🐾]` and `[烁烁🐾]`). A bare `[Phase B]` matches neither alternative. - * - * Stripping these before slot detection prevents `finalRoutingSlot('LGTM\n\n[宪宪/Opus-46🐾]')` - * from returning just the signature (which would make `shouldWarnVerdictWithoutPass` - * return false for a real signed verdict-without-pass). - */ -const CAT_SIGNATURE_LINE_RE = /^\s*\[(?:[^[\]\n]+\/[^[\]\n]+|[^[\]\n]+🐾)\]\s*$/u; - -/** - * Strip trailing cat-signature paragraphs (and blank lines) so the slot picker - * lands on the last *content* paragraph. Body brackets that happen to match the - * signature shape are preserved — only TRAILING signature lines are stripped. - * - * Iterates from the last line backwards: blank lines and signature lines are - * dropped; the first non-empty, non-signature line stops the walk. - */ -function stripTrailingCatSignatures(text: string): string { - if (!text) return text; - const lines = text.split(/\r?\n/); - let lastContentIdx = lines.length - 1; - while (lastContentIdx >= 0) { - const line = lines[lastContentIdx] ?? ''; - if (line.trim() === '' || CAT_SIGNATURE_LINE_RE.test(line)) { - lastContentIdx--; - continue; - } - break; - } - if (lastContentIdx < 0) return ''; - return lines.slice(0, lastContentIdx + 1).join('\n'); -} +import { finalRoutingSlot, hasEventDrivenExternalWaitExit, stripTrailingCatSignatures } from './final-routing-slot.js'; /** * Review verdict 关键词。保守集,避免常见日常用语误报: @@ -193,6 +140,8 @@ export interface VerdictWarningInput { * pass to co-creator) was being flagged as "verdict without pass". */ readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } /** @@ -211,5 +160,6 @@ export function shouldWarnVerdictWithoutPass(input: VerdictWarningInput): boolea if (hasHoldBallCall(input.toolNames)) return false; if (input.structuredTargetCats.length > 0) return false; if (input.hasCoCreatorLineStartMention) return false; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return false; return true; } diff --git a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts index 0c9221b1ad..66cff59427 100644 --- a/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts +++ b/packages/api/src/domains/cats/services/agents/routing/void-hold-detect.ts @@ -12,6 +12,8 @@ * keyword). `shouldWarnVoidHold` is preserved as a backward-compatible shim. */ +import { hasEventDrivenExternalWaitExit } from './final-routing-slot.js'; + const FENCED_CODE_RE = /```[\s\S]*?```/g; const URL_RE = /https?:\/\/[^\s)\]]+/g; @@ -81,6 +83,8 @@ export interface VoidHoldInput { readonly lineStartMentions: readonly string[]; readonly structuredTargetCats: readonly string[]; readonly hasCoCreatorLineStartMention?: boolean; + /** True only when the route has verified callback/EYES coverage for a 2b event-driven wait. */ + readonly hasEventDrivenExternalWaitCoverage?: boolean; } export interface VoidHoldEvaluation { @@ -98,7 +102,8 @@ export interface VoidHoldEvaluation { /** * Full evaluation: returns both emission decision and matched trigger. * Emission is suppressed if any legitimate exit is present (hold_ball tool, - * line-start @cat / co-creator mention, or structured MCP routing). + * line-start @cat / co-creator mention, structured MCP routing, or a verified + * event-driven external wait). */ export function evaluateVoidHold(input: VoidHoldInput): VoidHoldEvaluation { const matched = matchHoldPattern(input.text); @@ -107,6 +112,9 @@ export function evaluateVoidHold(input: VoidHoldInput): VoidHoldEvaluation { if (input.lineStartMentions.length > 0) return { shouldEmit: false, matchedPattern: matched }; if (input.structuredTargetCats.length > 0) return { shouldEmit: false, matchedPattern: matched }; if (input.hasCoCreatorLineStartMention) return { shouldEmit: false, matchedPattern: matched }; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) { + return { shouldEmit: false, matchedPattern: matched }; + } return { shouldEmit: true, matchedPattern: matched }; } diff --git a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts index 3bdcb4b255..22f7e31c22 100644 --- a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts @@ -87,12 +87,17 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe const routeResult = await opts.cicdRouter.route(pollResult); if (routeResult.kind !== 'notified' || !opts.invokeTrigger) return; + const intent = signal.task.automationState?.intent ?? 'review'; + // CI fail → always wake (urgent, must fix) — independent of intent. + // Event-driven wait coverage is stricter: only merge intent guarantees + // the follow-up CI-pass transition will invoke this cat again. if (routeResult.bucket === 'fail') { const policy: ConnectorTriggerPolicy = { priority: 'urgent', reason: 'github_ci_failure', sourceCategory: 'ci', + eventDrivenExternalWaitCoverage: intent === 'merge', }; void opts.invokeTrigger .trigger( @@ -113,7 +118,6 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe // 'review' (default): the cat is waiting on review feedback → CI-pass is noise. CiCdRouter has // already posted the "CI 通过" thread message (visible whenever the cat looks), so stay silent. // 'merge': the cat is waiting on CI-green to merge → CI-pass is the action signal → merge-gate. - const intent = signal.task.automationState?.intent ?? 'review'; if (intent !== 'merge') { opts.log.info( `[cicd-check] CI pass for ${routeResult.catId} — silent (intent=${intent}; thread message only)`, @@ -126,6 +130,7 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe reason: 'github_ci_pass', sourceCategory: 'ci', suggestedSkill: 'merge-gate', + eventDrivenExternalWaitCoverage: true, }; void opts.invokeTrigger .trigger( diff --git a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index d03675006c..75f701f2c4 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -55,6 +55,12 @@ export interface ConnectorTriggerPolicy { readonly sourceCategory?: 'ci' | 'review' | 'conflict' | 'scheduled' | 'a2a' | 'issue'; /** F140 Phase C: hint which Skill to auto-load (not a hard constraint — cat can override) */ readonly suggestedSkill?: string; + /** + * True only when this connector wake comes from a structured external callback/tracking + * path that can wake the cat again for the waited condition. Plain bound-chat connector + * messages do not imply 2b event-driven wait coverage. + */ + readonly eventDrivenExternalWaitCoverage?: boolean; /** * Optional queue coalescing key for connector bursts that supersede earlier queued work. * Later hits reuse the first queued entry: messageIds are merged, but the original content/body stays in place. @@ -113,6 +119,7 @@ export class ConnectorInvokeTrigger { ): Promise { const { invocationTracker } = this.opts; const priority = policy?.priority ?? 'normal'; + const eventDrivenExternalWaitCoverage = policy?.eventDrivenExternalWaitCoverage === true; // F185 AC-1: thread-level queue/processingSlots gate if (this.opts.queueProcessor?.isThreadBusy(threadId)) { @@ -127,6 +134,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + eventDrivenExternalWaitCoverage, ); } @@ -144,6 +152,7 @@ export class ConnectorInvokeTrigger { policy?.sourceCategory, policy?.suggestedSkill, policy?.coalesceKey, + eventDrivenExternalWaitCoverage, ); } @@ -159,6 +168,7 @@ export class ConnectorInvokeTrigger { policy?.suggestedSkill, sender, controller, + eventDrivenExternalWaitCoverage, ).catch((err) => { this.opts.log.error(`[ConnectorInvokeTrigger] Unhandled: ${err instanceof Error ? err.message : String(err)}`); }); @@ -176,6 +186,7 @@ export class ConnectorInvokeTrigger { sourceCategory?: string, suggestedSkill?: string, coalesceKey?: string, + eventDrivenExternalWaitCoverage = false, ): Promise<'full' | 'enqueued'> { const { invocationQueue, socketManager, log } = this.opts; @@ -206,6 +217,7 @@ export class ConnectorInvokeTrigger { : {}), ...(sender ? { senderMeta: sender } : {}), ...(suggestedSkill ? { suggestedSkill } : {}), + eventDrivenExternalWaitCoverage, }); if (result.outcome === 'full') { @@ -262,6 +274,7 @@ export class ConnectorInvokeTrigger { suggestedSkill?: string, sender?: { id: string; name?: string }, preAcquiredController?: AbortController, + eventDrivenExternalWaitCoverage = false, ): Promise { const { router, socketManager, invocationRecordStore, invocationTracker, invocationQueue, log } = this.opts; const targetCats: CatId[] = [catId]; @@ -386,6 +399,8 @@ export class ConnectorInvokeTrigger { frustrationAutoIssueEligible: false, // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning verdictPassWarningEnabled: false, + // Only policy-backed connector wakes prove a future callback/tracking path. + eventDrivenExternalWaitCoverage, })) { // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. if (!intentModeBroadcast) { diff --git a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts index f1567c454c..bc0c6baf31 100644 --- a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts +++ b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts @@ -26,6 +26,7 @@ export interface IssueCommentSignal { repoFullName: string; issueNumber: number; newComments: IssueComment[]; + eventDrivenExternalWaitCoverage?: boolean; commitCursor: () => Promise; } @@ -279,6 +280,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments: pendingDelivery, + eventDrivenExternalWaitCoverage: false, commitCursor: async () => { await advanceDeliveryCursor(task.id, issueKey, maxDeliveryId); // Cloud R15 P1: only mark done when collection is COMPLETE. @@ -346,6 +348,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T newComments: pendingDelivery, // In dual-cursor mode, commitCursor only advances the delivery cursor. // The collection cursor was already advanced above in the collection pass. + eventDrivenExternalWaitCoverage: true, commitCursor: () => advanceDeliveryCursor(task.id, issueKey, maxDeliveryId), }, subjectKey: task.subjectKey!, @@ -381,6 +384,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments, + eventDrivenExternalWaitCoverage: false, commitCursor: async () => { await advanceCursor(task.id, issueKey, maxCommentId, 'memoryFirst'); await opts.taskStore.update(task.id, { status: 'done' }); @@ -409,6 +413,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T repoFullName, issueNumber, newComments, + eventDrivenExternalWaitCoverage: true, commitCursor: () => advanceCursor(task.id, issueKey, maxCommentId, 'memoryFirst'), }, subjectKey: task.subjectKey!, @@ -468,6 +473,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T priority: 'normal', reason: 'github_issue_comment', sourceCategory: 'issue', + eventDrivenExternalWaitCoverage: signal.eventDrivenExternalWaitCoverage === true, coalesceKey: `${subjectKey}:issue-comment:${coalesceTargetCatId}`, }; void opts.invokeTrigger diff --git a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts index 627a936367..6ae7298208 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts @@ -485,12 +485,15 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions const hasApproved = !hasChangesRequested && signal.newDecisions.some((d) => d.state === 'APPROVED'); const suggestedSkill = hasChangesRequested ? 'receive-review' : hasApproved ? 'merge-gate' : undefined; const coalesceTargetCatId = routeResult.catId || task.ownerCatId || 'unassigned'; + const intent = task.automationState?.intent ?? 'review'; + const eventDrivenExternalWaitCoverage = hasApproved ? intent === 'merge' : true; const policy: ConnectorTriggerPolicy = { priority: hasChangesRequested ? 'urgent' : 'normal', reason: 'github_review_feedback', sourceCategory: 'review', suggestedSkill, + eventDrivenExternalWaitCoverage, coalesceKey: `${subjectKey}:review-feedback:${coalesceTargetCatId}`, }; void opts.invokeTrigger diff --git a/packages/api/test/connector-invoke-trigger.test.js b/packages/api/test/connector-invoke-trigger.test.js index 50eaf09a58..9a408d22de 100644 --- a/packages/api/test/connector-invoke-trigger.test.js +++ b/packages/api/test/connector-invoke-trigger.test.js @@ -271,6 +271,40 @@ describe('ConnectorInvokeTrigger', () => { ); }); + it('connector direct route without callback policy does not mark event-driven waits covered', async () => { + const trigger = createTrigger(); + trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Plain connector msg', 'msg-plain'); + await waitForTrigger(); + + assert.strictEqual(routerMock.calls.length, 1); + assert.notStrictEqual( + routerMock.calls[0].options?.eventDrivenExternalWaitCoverage, + true, + 'plain bound-chat connector messages must not count as callback-covered external waits', + ); + }); + + it('connector direct route carries explicit event-driven callback coverage policy', async () => { + const trigger = createTrigger(); + trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + 'Review feedback msg', + 'msg-review-feedback', + undefined, + { + reason: 'github_review_feedback', + sourceCategory: 'review', + eventDrivenExternalWaitCoverage: true, + }, + ); + await waitForTrigger(); + + assert.strictEqual(routerMock.calls.length, 1); + assert.strictEqual(routerMock.calls[0].options?.eventDrivenExternalWaitCoverage, true); + }); + it('broadcasts agent messages to WebSocket room', async () => { const trigger = createTrigger(); trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Review msg', 'msg-1'); @@ -1099,6 +1133,40 @@ describe('ConnectorInvokeTrigger', () => { assert.strictEqual(entries[0].priority, 'urgent'); }); + it('queued connector without callback policy does not mark event-driven waits covered', async () => { + trackerMock.setActive('thread-1', 'user-1'); + const trigger = createTrigger(); + trigger.trigger('thread-1', /** @type {any} */ ('opus'), 'user-1', 'Plain connector msg', 'msg-plain'); + await waitForTrigger(); + + const entries = queue.list('thread-1', 'user-1'); + assert.strictEqual(entries.length, 1); + assert.notStrictEqual(entries[0].eventDrivenExternalWaitCoverage, true); + }); + + it('queued connector preserves explicit event-driven callback coverage policy', async () => { + trackerMock.setActive('thread-1', 'user-1'); + const trigger = createTrigger(); + trigger.trigger( + 'thread-1', + /** @type {any} */ ('opus'), + 'user-1', + 'Review feedback msg', + 'msg-review-feedback', + undefined, + { + reason: 'github_review_feedback', + sourceCategory: 'review', + eventDrivenExternalWaitCoverage: true, + }, + ); + await waitForTrigger(); + + const entries = queue.list('thread-1', 'user-1'); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].eventDrivenExternalWaitCoverage, true); + }); + it('urgent connector with owner mismatch still enqueues without cancel (F175)', async () => { trackerMock.setActive('thread-1', 'owner-user'); const trigger = createTrigger(); diff --git a/packages/api/test/f168-phase-b-dual-cursor.test.js b/packages/api/test/f168-phase-b-dual-cursor.test.js index 95e611db30..308b2fe967 100644 --- a/packages/api/test/f168-phase-b-dual-cursor.test.js +++ b/packages/api/test/f168-phase-b-dual-cursor.test.js @@ -983,6 +983,49 @@ describe('IssueCommentTaskSpec: with eventLog — dual-cursor', () => { ); }); + it('closed issue final delivery does not grant event-driven wait coverage after task is done (Cloud PR35 P2)', async () => { + assert.ok(createIssueCommentTaskSpec); + const taskStore = makeTaskStore(); + taskStore.addTask(makeTask({ id: 'task-closed-final-coverage', subjectKey: 'issue:owner/repo#42' })); + const eventLog = makeEventLog(); + const policies = []; + const comments = [ + { + id: 7001, + author: 'external', + body: 'final user comment', + authorAssociation: 'NONE', + createdAt: '2026-01-01T00:00:00Z', + }, + ]; + const { spec } = makeBaseSpec({ + taskStore, + comments, + extra: { + eventLog, + fetchIssueState: async () => 'closed', + invokeTrigger: { + trigger: async (_threadId, _catId, _userId, _message, _messageId, _contentBlocks, policy) => { + policies.push(policy); + return 'dispatched'; + }, + }, + }, + }); + + const gate = await runGate(spec); + await runExecute(spec, gate); + + const taskAfter = taskStore.tasks.get('task-closed-final-coverage'); + assert.strictEqual(taskAfter?.status, 'done', 'closed issue final delivery should complete tracking task'); + assert.strictEqual(policies.length, 1, 'final issue comment should still wake the owner once'); + assert.strictEqual( + policies[0]?.eventDrivenExternalWaitCoverage, + false, + 'closed final delivery has no active issue poller left, so it must not validate later 2b event-driven waits', + ); + }); + // ───────────────────────────────────────────────────────────────────────── // Cloud R8 P1-1: duplicate comment (appended:false) must NOT call projector // Applying stale events out of temporal order corrupts awaiting_external state. diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 7d5d9b4e30..809504b971 100644 --- a/packages/api/test/final-routing-slot.test.js +++ b/packages/api/test/final-routing-slot.test.js @@ -12,6 +12,7 @@ import { describe, test } from 'node:test'; import { finalRoutingSlot, findInlineMentionsInSlot, + hasEventDrivenExternalWaitExit, validateRoutingSyntax, } from '../dist/domains/cats/services/agents/routing/final-routing-slot.js'; @@ -151,6 +152,62 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { }); assert.equal(result.kind, 'ok'); }); + + test('2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const result = validateRoutingSyntax({ + text: '不再 @codex。\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('signed 2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const text = '不再 @codex。\nExternal Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]'; + + assert.equal(hasEventDrivenExternalWaitExit(text), true); + + const result = validateRoutingSyntax({ + text, + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('URL callback id in 2b event-driven external wait exit suppresses inline mention syntax warning', () => { + const text = + '不再 @codex;等 GitHub 回调。\nExternal Wait: event-driven (https://github.com/clowder-labs/clowder-ai/pull/35)'; + + assert.equal(hasEventDrivenExternalWaitExit(text), true); + + const result = validateRoutingSyntax({ + text, + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.kind, 'ok'); + }); + + test('2b event-driven external wait text without verified callback coverage does not suppress inline mention syntax warning', () => { + const result = validateRoutingSyntax({ + text: '不再 @codex。\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + rosterHandles: roster, + }); + assert.equal(result.kind, 'invalid_route_syntax'); + }); }); describe('F167 Phase H AC-H6: structural exemptions', () => { diff --git a/packages/api/test/queue-processor.test.js b/packages/api/test/queue-processor.test.js index 92e02880c9..c67dab3ae5 100644 --- a/packages/api/test/queue-processor.test.js +++ b/packages/api/test/queue-processor.test.js @@ -1577,6 +1577,37 @@ describe('QueueProcessor', () => { assert.equal(opts.a2aTriggerMessageId, undefined); }); + it('executeEntry does not treat connector source alone as event-driven wait coverage', async () => { + const entry = enqueueEntry(deps.queue, { source: 'connector' }); + deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector'); + + await processor.processNext('t1', 'u1'); + await new Promise((r) => setTimeout(r, 50)); + + assert.ok(deps.router.routeExecution.mock.calls.length > 0); + const call = deps.router.routeExecution.mock.calls[0]; + const opts = call.arguments[6]; + assert.ok(opts && typeof opts === 'object', 'expected opts object'); + assert.notEqual(opts.eventDrivenExternalWaitCoverage, true); + }); + + it('executeEntry passes explicit queued event-driven wait coverage to routeExecution', async () => { + const entry = enqueueEntry(deps.queue, { + source: 'connector', + eventDrivenExternalWaitCoverage: true, + }); + deps.queue.backfillMessageId('t1', 'u1', entry.id, 'msg-connector-covered'); + + await processor.processNext('t1', 'u1'); + await new Promise((r) => setTimeout(r, 50)); + + assert.ok(deps.router.routeExecution.mock.calls.length > 0); + const call = deps.router.routeExecution.mock.calls[0]; + const opts = call.arguments[6]; + assert.ok(opts && typeof opts === 'object', 'expected opts object'); + assert.equal(opts.eventDrivenExternalWaitCoverage, true); + }); + it('degrades when messageStore.getById throws: still executes without contentBlocks', async () => { deps.messageStore.getById = mock.fn(async () => { throw new Error('redis down'); diff --git a/packages/api/test/route-serial-routing-guard-remedial.test.js b/packages/api/test/route-serial-routing-guard-remedial.test.js index a6604ba033..b3b3cb9d85 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -140,7 +140,7 @@ async function loadRealRoster() { async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) { return withCatRegistryLock(async () => { - const { thinkingMode = 'play', ...depsOptions } = mockOptions; + const { thinkingMode = 'play', routeOptions = {}, ...depsOptions } = mockOptions; const original = catRegistry.getAllConfigs(); await loadRealRoster(); const appended = []; @@ -150,6 +150,7 @@ async function runRoute(service, threadId, extraServices = {}, mockOptions = {}) const yielded = []; for await (const msg of routeSerial(deps, ['codex'], 'guard test', 'user1', threadId, { thinkingMode, + ...routeOptions, })) { yielded.push(msg); } @@ -325,6 +326,25 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.deepEqual(codexMessages[0].mentions, ['opus']); }); + test('event-driven wait coverage does not leak from first cat to A2A worklist targets', async () => { + const codexService = createSequenceService('codex', ['@opus']); + const opusService = createSequenceService('opus', ['External Wait: event-driven (pr:35)', '@co-creator']); + + const { appended } = await runRoute( + codexService, + 'thread-routing-guard-event-driven-coverage-per-cat', + { opus: opusService }, + { routeOptions: { eventDrivenExternalWaitCoverage: true } }, + ); + + assert.equal(opusService.calls.length, 2, 'A2A target must not inherit connector callback coverage'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting leaked coverage', + ); + }); + test('debug A2A prompt sees validated first-pass content routed by remedial exit', async () => { const codexService = createSequenceService('codex', ['First-pass debug context.', '@opus']); const opusService = createSequenceService('opus', ['ack from opus'], { needsGuard: false }); @@ -380,6 +400,98 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); + test('event-driven external-wait remedial with verified callback coverage counts as route-only and keeps first-pass text visible', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [firstPass, 'External Wait: event-driven (pr:35)']); + + const { appended, calls, yielded } = await runRoute( + service, + 'thread-routing-guard-event-driven-remedial', + {}, + { + routeOptions: { eventDrivenExternalWaitCoverage: true }, + }, + ); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'event-driven route-only remedial should count as a valid routing exit', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-syntax-hint'), + undefined, + 'event-driven remedial exit should suppress inline-mention syntax hints for the preserved first-pass text', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'void-hold-hint'), + undefined, + 'event-driven remedial exit should not be treated as a void hold', + ); + + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.equal(codexMessages[0].content, firstPass); + assert.deepEqual(codexMessages[0].mentions, []); + assert.notEqual(codexMessages[0].mentionsUser, true); + assert.deepEqual( + yielded.filter((m) => m.type === 'text').map((m) => m.content), + [firstPass], + 'live stream must surface the first-pass text, not the bare event-driven exit patch', + ); + }); + + test('signed event-driven external-wait remedial with verified callback coverage counts as route-only and keeps first-pass text visible', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [ + firstPass, + 'External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]', + ]); + + const { appended, calls, yielded } = await runRoute( + service, + 'thread-routing-guard-event-driven-remedial-signed', + {}, + { routeOptions: { eventDrivenExternalWaitCoverage: true } }, + ); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'signed event-driven route-only remedial should count as a valid routing exit', + ); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-syntax-hint'), + undefined, + 'signed event-driven remedial exit should suppress inline-mention syntax hints for the preserved text', + ); + + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.equal(codexMessages[0].content, firstPass); + assert.deepEqual( + yielded.filter((m) => m.type === 'text').map((m) => m.content), + [firstPass], + 'live stream must surface the first-pass text, not the signed event-driven exit patch', + ); + }); + + test('event-driven external-wait remedial without verified callback coverage is rejected as missing route', async () => { + const firstPass = '我查完 current truth;不再 @codex,只剩外部 CI gate。'; + const service = createSequenceService('codex', [firstPass, 'External Wait: event-driven (pr:35)']); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-remedial-no-coverage'); + + assert.equal(calls.length, 2, 'first-pass no-exit text should trigger one remedial invoke'); + assert.notEqual( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'text-only event-driven wait must not count as a valid routing exit', + ); + }); + test('tool-only no-text initial output still gets the remedial guard instead of silent completion', async () => { const service = createSequenceService('codex', [ [ @@ -664,6 +776,115 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.deepEqual(spokenChunks, ['我先持球继续。'], 'voice TTS should match the preserved live text'); }); + test('2b event-driven external wait final slot with verified callback coverage counts as a routing exit without remedial invoke', async () => { + const service = createSequenceService('codex', [ + 'cloud / CI 已有结构化回调覆盖,不需要 hold_ball。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#32)', + '@co-creator', + ]); + + const { appended, calls } = await runRoute( + service, + 'thread-routing-guard-event-driven-wait', + {}, + { + routeOptions: { eventDrivenExternalWaitCoverage: true }, + }, + ); + + assert.equal(calls.length, 1, 'explicit 2b event-driven external wait should not trigger remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'event-driven external wait should not emit routing guard failure', + ); + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.match(codexMessages[0].content, /External Wait: event-driven/); + }); + + test('2b event-driven external wait rejects PR tracking registration without pickup proof', async () => { + const service = createSequenceService('codex', [ + [ + { + type: 'tool_use', + toolName: 'cat_cafe_register_pr_tracking', + toolInput: { repoFullName: 'clowder-labs/clowder-ai', prNumber: 35 }, + }, + { + type: 'tool_result', + toolName: 'cat_cafe_register_pr_tracking', + content: '{"status":"ok","threadId":"thread-routing-guard-event-driven-register"}', + }, + { + type: 'text', + content: + '已注册 PR tracking,后续 review/CI 会结构化回调。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#35)', + }, + ], + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-register'); + + assert.equal(calls.length, 2, 'same-turn PR tracking registration alone should not prevent a remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting PR tracking registration alone', + ); + }); + + test('2b event-driven external wait honors issue tracking registered earlier in the same turn', async () => { + const service = createSequenceService('codex', [ + [ + { + type: 'tool_use', + toolName: 'cat_cafe_register_issue_tracking', + toolInput: { repoFullName: 'clowder-labs/clowder-ai', issueNumber: 35 }, + }, + { + type: 'tool_result', + toolName: 'cat_cafe_register_issue_tracking', + content: '{"status":"ok","threadId":"thread-routing-guard-event-driven-issue-register"}', + }, + { + type: 'text', + content: + '已注册 issue tracking,后续评论会结构化回调。\n\nExternal Wait: event-driven (issue:clowder-labs/clowder-ai#35)', + }, + ], + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-issue-register'); + + assert.equal(calls.length, 1, 'same-turn issue tracking registration should prevent a remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'confirmed issue tracking registration should count as verified callback coverage for 2b', + ); + const codexMessages = appended.filter((m) => m.catId === 'codex' && m.origin === 'stream'); + assert.equal(codexMessages.length, 1); + assert.match(codexMessages[0].content, /External Wait: event-driven/); + }); + + test('2b event-driven external wait final slot without verified callback coverage still gets remedial invoke', async () => { + const service = createSequenceService('codex', [ + 'cloud / CI 也许会回调,不需要 hold_ball。\n\nExternal Wait: event-driven (pr:clowder-labs/clowder-ai#32)', + '@co-creator', + ]); + + const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-wait-no-coverage'); + + assert.equal(calls.length, 2, 'text-only event-driven external wait should still trigger remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'valid follow-up remedial exit should avoid failure after rejecting the text-only event wait', + ); + }); + test('guard-disabled cat still runs once and keeps legacy non-blocking hint behavior', async () => { const service = createSequenceService('codex', ['I will keep going from here.'], { needsGuard: false }); diff --git a/packages/api/test/routing-guard-remedial.test.js b/packages/api/test/routing-guard-remedial.test.js index 3a11a7e927..2960e518ef 100644 --- a/packages/api/test/routing-guard-remedial.test.js +++ b/packages/api/test/routing-guard-remedial.test.js @@ -75,6 +75,31 @@ describe('F177 Phase H — shouldRemediateRouting', () => { true, ); }); + + test('2b External Wait event-driven 槽位 + verified callback coverage → 不触发 remedial', () => { + assert.equal( + shouldRemediateRouting({ + ...base, + text: 'cloud / CI 已有结构化回调覆盖。\n\nExternal Wait: event-driven (pr:clowder-ai#32)', + hasEventDrivenExternalWaitCoverage: true, + needsGuard: true, + attempted: false, + }), + false, + ); + }); + + test('2b External Wait event-driven 槽位 without verified callback coverage → still triggers remedial', () => { + assert.equal( + shouldRemediateRouting({ + ...base, + text: 'cloud / CI 可能会回调。\n\nExternal Wait: event-driven (pr:clowder-ai#32)', + needsGuard: true, + attempted: false, + }), + true, + ); + }); }); describe('F177 Phase H — hasValidRoutingExit', () => { @@ -88,13 +113,35 @@ describe('F177 Phase H — hasValidRoutingExit', () => { assert.equal(hasValidRoutingExit({ ...base, structuredTargetCats: ['x'] }), true); assert.equal(hasValidRoutingExit({ ...base, hasCoCreatorLineStartMention: true }), true); }); + + test('External Wait: event-driven() counts as a valid 2b external-wait exit with verified coverage', () => { + assert.equal( + hasValidRoutingExit({ + ...base, + text: '结论:已有结构化回调 + EYES>0,不续 hold_ball。\n\nExternal Wait: event-driven (github-pr-32)', + hasEventDrivenExternalWaitCoverage: true, + }), + true, + ); + }); + + test('External Wait: event-driven() alone is not a valid routing exit', () => { + assert.equal( + hasValidRoutingExit({ + ...base, + text: '结论:没有确认 EYES。\n\nExternal Wait: event-driven (github-pr-32)', + }), + false, + ); + }); }); describe('F177 Phase H — buildRemedialPrompt', () => { - test('含路由指引(行首 @ / hold_ball / @co-creator)且明确不重做工作', () => { + test('含路由指引(行首 @ / hold_ball / event-driven / @co-creator)且明确不重做工作', () => { const p = buildRemedialPrompt(); assert.match(p, /行首/); assert.match(p, /hold_ball/); + assert.match(p, /event-driven/); assert.match(p, /@co-creator/); assert.match(p, /不要重做/); }); diff --git a/packages/api/test/scheduler/cicd-check-spec.test.js b/packages/api/test/scheduler/cicd-check-spec.test.js index 235b768226..b47a9683bb 100644 --- a/packages/api/test/scheduler/cicd-check-spec.test.js +++ b/packages/api/test/scheduler/cicd-check-spec.test.js @@ -132,9 +132,10 @@ describe('CiCdCheckTaskSpec', () => { assert.equal(policy.priority, 'normal'); assert.equal(policy.reason, 'github_ci_pass'); assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); - it('execute triggers invokeTrigger for CI fail with urgent priority (unchanged)', async () => { + it('execute triggers CI fail for default review intent without event-driven wait coverage', async () => { const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); const triggered = []; const tasks = [mockTask({ repoFullName: 'a/b', prNumber: 1, userId: 'u1' })]; @@ -165,6 +166,47 @@ describe('CiCdCheckTaskSpec', () => { const policy = triggered[0][6]; assert.equal(policy.priority, 'urgent'); assert.equal(policy.reason, 'github_ci_failure'); + assert.notEqual( + policy.eventDrivenExternalWaitCoverage, + true, + 'review-intent CI failure must not claim pass-wakeup coverage', + ); + }); + + it('execute marks CI fail as event-driven covered only when intent=merge', async () => { + const { createCiCdCheckTaskSpec } = await import('../../dist/infrastructure/email/CiCdCheckTaskSpec.js'); + const triggered = []; + const tasks = [ + mockTask({ repoFullName: 'a/b', prNumber: 1, userId: 'u1' }, { automationState: { intent: 'merge' } }), + ]; + const spec = createCiCdCheckTaskSpec({ + taskStore: mockTaskStore(tasks), + cicdRouter: { + route: async () => ({ + kind: 'notified', + bucket: 'fail', + threadId: 't1', + catId: 'opus', + messageId: 'm1', + content: 'CI failed', + }), + }, + fetchPrStatus: async () => ({ checks: [], headSha: 'sha1', prNumber: 1, repoFullName: 'a/b' }), + invokeTrigger: { + trigger: (...args) => { + triggered.push(args); + return Promise.resolve(); + }, + }, + log: { info: () => {}, error: () => {}, warn: () => {} }, + }); + const gateResult = await spec.admission.gate({ taskId: 'cicd-check', lastRunAt: null, tickCount: 1 }); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:a/b#1', {}); + assert.equal(triggered.length, 1); + const policy = triggered[0][6]; + assert.equal(policy.priority, 'urgent'); + assert.equal(policy.reason, 'github_ci_failure'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); it('gate filters out ci.enabled=false', async () => { diff --git a/packages/api/test/scheduler/conflict-check-spec.test.js b/packages/api/test/scheduler/conflict-check-spec.test.js index 5f35bfcecd..fc153b08ff 100644 --- a/packages/api/test/scheduler/conflict-check-spec.test.js +++ b/packages/api/test/scheduler/conflict-check-spec.test.js @@ -166,6 +166,11 @@ describe('ConflictCheckTaskSpec', () => { assert.equal(triggerCalls[0][1], 'opus'); // catId assert.equal(triggerCalls[0][6].priority, 'urgent'); assert.equal(triggerCalls[0][6].reason, 'github_pr_conflict'); + assert.notEqual( + triggerCalls[0][6].eventDrivenExternalWaitCoverage, + true, + 'conflict wake must not claim follow-up callback coverage', + ); }); it('execute does not trigger when router skips', async () => { diff --git a/packages/api/test/scheduler/review-feedback-spec.test.js b/packages/api/test/scheduler/review-feedback-spec.test.js index 75e3178372..cc6354a95e 100644 --- a/packages/api/test/scheduler/review-feedback-spec.test.js +++ b/packages/api/test/scheduler/review-feedback-spec.test.js @@ -730,6 +730,53 @@ describe('ReviewFeedbackTaskSpec', () => { const policy = triggered[0][6]; assert.equal(policy.priority, 'normal'); assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.notEqual( + policy.eventDrivenExternalWaitCoverage, + true, + 'review-intent approval wake must not claim CI-pass callback coverage', + ); + }); + + it('APPROVED merge-intent wake grants event-driven wait coverage (Phase C)', async () => { + const { createReviewFeedbackTaskSpec } = await import('../../dist/infrastructure/email/ReviewFeedbackTaskSpec.js'); + const triggered = []; + const mergeIntentTask = mockTask( + { + repoFullName: 'owner/repo', + prNumber: 42, + catId: 'opus', + threadId: 'th-1', + userId: 'u-1', + }, + { automationState: { intent: 'merge' } }, + ); + const spec = createReviewFeedbackTaskSpec({ + taskStore: mockTaskStore([mergeIntentTask]), + fetchComments: async () => [], + fetchReviews: async () => [ + { id: 1, author: 'reviewer', state: 'APPROVED', body: 'LGTM', submittedAt: '2026-01-01' }, + ], + reviewFeedbackRouter: { + async route() { + return { kind: 'notified', threadId: 't1', catId: 'opus', messageId: 'm1', content: 'approved' }; + }, + }, + invokeTrigger: { + trigger: (...args) => { + triggered.push(args); + return Promise.resolve(); + }, + }, + log: noopLog, + }); + const gateResult = await spec.admission.gate({ taskId: spec.id, lastRunAt: null, tickCount: 1 }); + assert.equal(gateResult.run, true); + await spec.run.execute(gateResult.workItems[0].signal, 'pr:owner/repo#42', {}); + assert.equal(triggered.length, 1); + const policy = triggered[0][6]; + assert.equal(policy.priority, 'normal'); + assert.equal(policy.suggestedSkill, 'merge-gate'); + assert.equal(policy.eventDrivenExternalWaitCoverage, true); }); it('COMMENTED-only triggers with no suggestedSkill (Phase C)', async () => { diff --git a/packages/api/test/verdict-detect.test.js b/packages/api/test/verdict-detect.test.js index 57e5e9d56a..73cfb7b6b1 100644 --- a/packages/api/test/verdict-detect.test.js +++ b/packages/api/test/verdict-detect.test.js @@ -286,6 +286,31 @@ describe('F167 C2 AC-C7: shouldWarnVerdictWithoutPass', () => { ); }); + test('verdict + structural event-driven external wait exit → false', () => { + assert.equal( + shouldWarnVerdictWithoutPass({ + text: 'LGTM locally; waiting on cloud.\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + hasEventDrivenExternalWaitCoverage: true, + }), + false, + ); + }); + + test('verdict + structural event-driven external wait exit without verified callback coverage → true', () => { + assert.equal( + shouldWarnVerdictWithoutPass({ + text: 'LGTM locally; maybe cloud will callback.\nExternal Wait: event-driven (pr:35)', + lineStartMentions: [], + toolNames: [], + structuredTargetCats: [], + }), + true, + ); + }); + test('verdict + co-creator line-start mention (hasCoCreatorLineStartMention=true) → false (砚砚 GPT-5.5 fix)', () => { // 2026-04-25 false-positive root cause: parseA2AMentions only parses cat handles, // never returns co-creator handles like 'you'. route-serial passes that empty diff --git a/packages/api/test/void-hold-detect.test.js b/packages/api/test/void-hold-detect.test.js index 65a0016562..8217f3f67c 100644 --- a/packages/api/test/void-hold-detect.test.js +++ b/packages/api/test/void-hold-detect.test.js @@ -132,6 +132,25 @@ describe('F167 Phase I AC-I1: shouldWarnVoidHold', () => { ); }); + test('does not warn when structural event-driven external wait exit exists', () => { + const result = evaluateVoidHold({ + ...base, + text: '不需要 hold_ball;这是 2b 事件驱动等待。\nExternal Wait: event-driven (pr:35)', + hasEventDrivenExternalWaitCoverage: true, + }); + assert.equal(result.shouldEmit, false); + assert.equal(result.matchedPattern, 'en_hold_ball_underscore'); + }); + + test('warns when structural event-driven external wait exit lacks verified callback coverage', () => { + const result = evaluateVoidHold({ + ...base, + text: '不需要 hold_ball;这是 2b 事件驱动等待。\nExternal Wait: event-driven (pr:35)', + }); + assert.equal(result.shouldEmit, true); + assert.equal(result.matchedPattern, 'en_hold_ball_underscore'); + }); + test('still warns when hold text present but exits are all empty', () => { assert.equal( shouldWarnVoidHold({ ...base, text: '我持球等一下', lineStartMentions: [], structuredTargetCats: [] }), diff --git a/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md b/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md new file mode 100644 index 0000000000..4fd3977fc7 --- /dev/null +++ b/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md @@ -0,0 +1,281 @@ +# Review Request: route guard 2b event-driven external wait + +Review-Target-ID: fix-route-guard-2b-event-driven +Branch: fix/route-guard-2b-event-driven +Target: local branch based on `origin/main` at `8e412d2b` + +## What + +The routing guard now treats a final-slot line of +`External Wait: event-driven ()` as a legitimate 2b external-wait exit. + +Changed paths: +- `packages/api/src/domains/cats/services/agents/routing/guards/routing-guard-remedial.ts` +- `packages/api/src/domains/cats/services/agents/routing/route-serial.ts` +- `packages/api/test/routing-guard-remedial.test.js` +- `packages/api/test/route-serial-routing-guard-remedial.test.js` + +## Why + +Daily patrol found a real routing contradiction: when PR tracking had structured +callback coverage and EYES>0, the collaboration rule said 2b event-driven wait +means no `hold_ball`, but the server-side routing guard rejected the response as +"no legal route exit" unless it saw line-start `@` or `cat_cafe_hold_ball`. +That caused unnecessary remedial churn and local cat ping-pong even though the +next action was an external callback. + +## Original Requirements + +Source: scheduled patrol in `thread_mqcj45byxoka2z7u`, wake +`2026-06-28 00:00 Asia/Shanghai`. + +> 每轮必须先查真相源和证据,再给风险/价值判断与下一步动作。 +> 发现可执行事项后主导闭环:按家规走 feature lifecycle(定位真相源、立项、实现/协调、质量门禁、review、完成记录)。 + +Observed incident source: `clowder-labs/clowder-ai#32` review/check wait path +where current rules selected 2b event-driven waiting but route guard demanded +`@` or `hold_ball`. + +## Tradeoff + +This is intentionally structural: only a final routing slot line matching +`External Wait: event-driven ()` counts. It does not classify natural +language like "I will wait for CI", so the F177/KD-8 guard remains mechanical. + +The remedial prompt now teaches that exact outlet format, so future guard +patches can add the missing exit without redoing work. + +## Architecture Ownership + +Architecture cell: routing / A2A guard +Map delta: none +Why: this extends the existing routing guard exit predicate and route-serial +input plumbing. It does not add a new Store, Queue, Router, Adapter, Dispatcher, +Binding, runtime service, or external contract. + +Please check: +- diff matches `Map delta: none` +- the `External Wait` recognizer is structural enough and not an intent classifier +- route-serial passes the correct stored text at every guard check + +## Quality Gate Evidence + +### Red + +- `routing-guard-remedial.test.js`: `2b External Wait event-driven 槽位 → 不触发 remedial` failed, returning `true` instead of `false`. +- `routing-guard-remedial.test.js`: `External Wait: event-driven() counts as a valid 2b external-wait exit` failed, returning `false` instead of `true`. +- `routing-guard-remedial.test.js`: prompt test failed because `event-driven` was missing. +- `route-serial-routing-guard-remedial.test.js`: event-driven external wait caused two Codex invocations instead of one. + +### Green + +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/routing-guard-remedial.test.js packages/api/test/route-serial-routing-guard-remedial.test.js`: 33 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/final-routing-slot.test.js packages/api/test/verdict-detect.test.js`: 57 tests passed +- `pnpm --dir packages/api run lint`: passed +- `git diff --check`: passed +- `pnpm check`: passed + +### Extra Gate Checks + +- `node scripts/check-hotfix-pattern.mjs`: `{"hotfix":false,"matchedTerms":[],"matches":[]}` +- `node scripts/check-fallback-layers.mjs`: N/A, script is not present in this tree. +- `pnpm run check:architecture-ownership`: N/A, script is not present in this tree. +- `rg --files designs | rg '\.pen$'`: N/A, `designs/` is not present. +- Root artifact hygiene: + - `git status --short | rg '^.. [^/]+\.(png|jpe?g|webp|gif|webm|mp4|mov|wav|pdf|pen)$'`: no output + - `git diff --name-only origin/main...HEAD | rg '^[^/]+\.(png|jpe?g|webp|gif|webm|mp4|mov|wav|pdf|pen)$'`: no output + +### Dogfood-Your-Slice + +Scope verdict: required. This is cat-visible routing behavior. + +Dogfood path: the route-serial integration suite exercises a guarded Codex turn +whose final slot is `External Wait: event-driven (pr:clowder-labs/clowder-ai#32)`. +Before the fix, route-serial invoked Codex twice; after the fix, it persists the +original visible response with one invocation and no routing-guard failure. + +## Open Questions + +### Technical OQ + +- Should we accept only the English `External Wait` template, or also add a + separate Chinese canonical template later? This patch keeps the existing + documented template only. +- Is line-level matching inside the final slot acceptable, or should the entire + final paragraph be exactly one `External Wait` line? + +### Value OQ + +None. + +## Next Action + +Please do a non-author review of `fix/route-guard-2b-event-driven`. If clean, +approve and include the focused validation you ran. If there are P1/P2 findings, +route back to `@codex` for receive-review. + +## Receive-Review Update + +Reviewer found one P2 on current head `a9e177d8`: `External Wait: event-driven` +was accepted by `routing-guard-remedial`, but Phase H `validateRoutingSyntax` +still treated an inline mention in the same final slot as `invalid_route_syntax`. + +Fix: +- moved the structural event-driven external-wait predicate into + `final-routing-slot.ts` +- made `routing-guard-remedial.ts` reuse that shared helper +- taught `validateRoutingSyntax()` to treat the same final-slot event-driven + exit as a legitimate syntax suppressor + +Red→Green: +- `2b event-driven external wait exit suppresses inline mention syntax warning` + failed with `invalid_route_syntax`, now passes with `ok`. + +Failure-mode sweep: +- Pattern: newly added legitimate route exit must be recognized consistently by + every mechanical routing guard in this PR. +- Scanned touched routing guard surfaces: remedial exit predicate, route-serial + guard invocation sites, Phase H final-slot syntax validator, verdict adjacent + tests. +- Result: shared helper now prevents remedial/Phase-H drift for this exit. + +Additional verification after the fix: +- `pnpm --dir packages/api run build`: passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/final-routing-slot.test.js`: 23 tests passed +- `CAT_CAFE_DISABLE_SHARED_STATE_PREFLIGHT=1 bash packages/api/scripts/with-test-home.sh node --import $(pwd)/packages/api/test/helpers/setup-cat-registry.js --test --test-timeout=60000 packages/api/test/routing-guard-remedial.test.js packages/api/test/route-serial-routing-guard-remedial.test.js packages/api/test/verdict-detect.test.js`: 68 tests passed + +## Receive-Review Update 2 + +Cloud review on current head `e44e81c2` found one current P2, plus an older +still-applicable same-family P2: + +1. `External Wait: event-driven (...)` remedials were still not recognized by + `normalizeRouteOnlyRemedialText`, so route-serial treated the bare wait line + as replacement content and discarded useful first-pass text. +2. Valid 2b event-driven waits could still trip `void-hold-hint` when the text + mentioned `hold_ball`, because void-hold suppression only knew `@`, structured + targets, co-creator, or actual hold tool calls. + +Fix: +- `normalizeRouteOnlyRemedialText()` now treats the shared structural + event-driven external wait template as route-only content. +- `runRoutingGuardRemedial()` returns separate `routingContent`; route-serial + persists the original visible text but validates follow-up guards against + `storedContent + routingContent`. +- `void-hold-detect.ts` and `verdict-detect.ts` now reuse + `hasEventDrivenExternalWaitExit()` so direct 2b waits suppress the same + false-positive class without adding semantic intent classification. + +Red→Green: +- `event-driven external-wait remedial counts as route-only and keeps first-pass + text visible` failed by persisting `External Wait: event-driven (pr:35)`, now + persists the original first-pass text and emits no guard/syntax/void-hold hint. +- `does not warn when structural event-driven external wait exit exists` failed + in `void-hold-detect`, now suppresses while preserving the matched hold pattern. +- `verdict + structural event-driven external wait exit → false` failed in + `verdict-detect`, now suppresses as a legitimate external wait exit. + +Failure-mode sweep: +- Invariant: the structural 2b external-wait exit must be recognized consistently + by every mechanical post-output guard, not only the remedial gate. +- Scanned touched sibling guard surfaces: remedial route-only normalization, + Phase H syntax validation, verdict-without-pass detection, void-hold detection, + and route-serial post-remedial validation. +- Result: all current touched surfaces now consume the shared final-slot helper. + +Additional verification after the cloud fix: +- `pnpm --dir packages/api run build`: passed +- Focused red→green suite: + `route-serial-routing-guard-remedial.test.js`, + `void-hold-detect.test.js`, + `verdict-detect.test.js`: 85/85 passed +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 122/122 passed +- `git diff --check`: passed +- `pnpm check:hotfix-pattern`: 24/24 passed +- `pnpm check`: passed +- `scripts/check-fallback-layers.mjs`: unavailable in this tree +- `pnpm check:architecture-ownership`: unavailable in this tree + +## Receive-Review Update 3 + +Cloud review on current head `b41b72a3` found one P2: + +- Signed outputs like + `External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]` + made `finalRoutingSlot()` pick the trailing signature paragraph, so + `hasEventDrivenExternalWaitExit()` returned `false` and the new legal 2b exit + could still trip remedial/verdict/void-hold guards. + +Fix: +- moved trailing cat-signature stripping into `final-routing-slot.ts` +- made `hasEventDrivenExternalWaitExit()` strip signatures before selecting the + final slot +- made `verdict-detect.ts` reuse the same shared signature stripper instead of + keeping a separate local copy + +Red→Green: +- `signed 2b event-driven external wait exit suppresses inline mention syntax + warning` failed because `hasEventDrivenExternalWaitExit()` returned `false`, + now passes. + +Failure-mode sweep: +- Invariant: final-slot guard helpers must treat trailing identity signatures as + metadata, not content. +- Scanned touched sibling surfaces: event-driven exit detection, Phase H syntax + validation, verdict detection, void-hold detection. +- Result: event-driven exit and verdict detection now share the same signature + stripping helper. + +Additional verification after the signed-exit fix: +- `pnpm --dir packages/api run build`: passed +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 123/123 passed +- `git diff --check`: passed +- `pnpm check`: passed + +## Receive-Review Update 4 + +Cloud review on current head `602e3c11` found one P2: + +- Signed remedial patches like + `External Wait: event-driven (pr:35)\n\n[砚砚/GPT-5.5]` + still had two non-empty lines when `normalizeRouteOnlyRemedialText()` ran, so + the remedial turn was treated as replacement content and overwrote the first + pass instead of acting as a route-only exit patch. + +Fix: +- `route-serial.ts` now strips trailing cat signatures before route-only + remedial normalization +- this applies to both line-start `@...` remedials and structural + `External Wait: event-driven (...)` remedials + +Red→Green: +- `signed event-driven external-wait remedial counts as route-only and keeps + first-pass text visible` failed because the visible/persisted content became + the signed remedial patch, now passes and preserves the first-pass text. + +Failure-mode sweep: +- Invariant: trailing identity signatures are metadata anywhere route-only + outlet text is structurally classified. +- Scanned sibling surfaces in this PR: final-slot validation, event-driven exit + detection, verdict/void-hold suppression, and route-only remedial + normalization. +- Result: final-slot and route-serial route-only paths both reuse the shared + trailing signature stripper. + +Additional verification after the signed-remedial fix: +- `pnpm --dir packages/api run build`: passed +- Red test: route-serial remedial suite failed 20/21 with the signed patch + replacing first-pass text +- Green test: route-serial remedial suite passed 21/21 +- Expanded guard suite: + `final-routing-slot.test.js`, `routing-guard-remedial.test.js`, + `route-serial-routing-guard-remedial.test.js`, `verdict-detect.test.js`, + `void-hold-detect.test.js`: 124/124 passed +- `git diff --check`: passed