From a9e177d8670175295aaba2e0d7b3223a2119def6 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 00:09:44 +0800 Subject: [PATCH 01/14] fix: accept event-driven routing exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 2b event-driven external waits are legitimate when structured callback coverage exists, but the server-side routing guard only accepted @/hold/tool exits and forced redundant remedial churn. This adds a structural final-slot exit without adding natural-language intent classification. [砚砚/GPT-5.5🐾] --- .../routing/guards/routing-guard-remedial.ts | 18 ++- .../services/agents/routing/route-serial.ts | 4 + ...oute-serial-routing-guard-remedial.test.js | 19 +++ .../api/test/routing-guard-remedial.test.js | 25 +++- ...te-guard-2b-event-driven-review-request.md | 117 ++++++++++++++++++ 5 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md 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..03d008d488 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,8 +12,12 @@ * KD-8 safe:只看"有无机械出口信号",零意图分类器。 */ +import { finalRoutingSlot } from '../final-routing-slot.js'; + /** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */ const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const; +const EVENT_DRIVEN_EXTERNAL_WAIT_RE = + /^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i; function hasRoutingToolCall(toolNames: readonly string[]): boolean { return toolNames.some((name) => { @@ -22,7 +26,16 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean { }); } +function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { + if (!text) return false; + const slot = finalRoutingSlot(text); + if (!slot) return false; + return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim())); +} + 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). */ @@ -36,13 +49,15 @@ export interface RoutingExitInput { /** * 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). */ 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 (hasEventDrivenExternalWaitExit(input.text)) return true; return false; } @@ -74,6 +89,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-serial.ts b/packages/api/src/domains/cats/services/agents/routing/route-serial.ts index 3b3012cf59..b6865dd477 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 @@ -1732,6 +1732,7 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: '', lineStartMentions: getRoutingExitLineStartMentions(), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -1748,6 +1749,7 @@ export async function* routeSerial( noTextBlocksOverride = result.allRichBlocks; if ( !hasValidRoutingExit({ + text: result.storedContent, lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -1799,6 +1801,7 @@ export async function* routeSerial( shouldRemediateRouting({ needsGuard: needsServerRoutingGuard, attempted: routingGuardAttempted, + text: storedContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -1816,6 +1819,7 @@ export async function* routeSerial( if ( !hasValidRoutingExit({ + text: storedContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], 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..2c474fee00 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -664,6 +664,25 @@ 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 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'); + + 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('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..08d9186c2d 100644 --- a/packages/api/test/routing-guard-remedial.test.js +++ b/packages/api/test/routing-guard-remedial.test.js @@ -75,6 +75,18 @@ describe('F177 Phase H — shouldRemediateRouting', () => { true, ); }); + + test('2b External Wait event-driven 槽位 → 不触发 remedial', () => { + assert.equal( + shouldRemediateRouting({ + ...base, + text: 'cloud / CI 已有结构化回调覆盖。\n\nExternal Wait: event-driven (pr:clowder-ai#32)', + needsGuard: true, + attempted: false, + }), + false, + ); + }); }); describe('F177 Phase H — hasValidRoutingExit', () => { @@ -88,13 +100,24 @@ 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', () => { + assert.equal( + hasValidRoutingExit({ + ...base, + text: '结论:已有结构化回调 + EYES>0,不续 hold_ball。\n\nExternal Wait: event-driven (github-pr-32)', + }), + true, + ); + }); }); 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/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..f072b66ae2 --- /dev/null +++ b/review-notes/2026-06-28-route-guard-2b-event-driven-review-request.md @@ -0,0 +1,117 @@ +# 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. + From e44e81c29c3bf08b531f1fd79cb41c87b25d24e7 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 00:15:21 +0800 Subject: [PATCH 02/14] fix: share event-driven routing exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: review found External Wait: event-driven was accepted by the remedial guard but not by Phase H inline-mention syntax validation. Sharing the structural final-slot predicate keeps the mechanical routing guards aligned without adding intent classification. [砚砚/GPT-5.5🐾] --- .../agents/routing/final-routing-slot.ts | 20 +++++++++++++ .../routing/guards/routing-guard-remedial.ts | 11 +------ packages/api/test/final-routing-slot.test.js | 11 +++++++ ...te-guard-2b-event-driven-review-request.md | 29 +++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) 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..084d51a39c 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 @@ -37,6 +37,8 @@ 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; /** * Extract final routing slot = structurally-stripped last non-empty paragraph. @@ -72,6 +74,21 @@ export function finalRoutingSlot(text: string): string { return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; } +function slotHasEventDrivenExternalWaitExit(slot: string): boolean { + if (!slot) return false; + return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim())); +} + +/** + * 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(finalRoutingSlot(text)); +} + /** * Find inline @handle mentions in slot (= not at line-start position). * @@ -132,6 +149,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 * - no inline @handle inside final routing slot * * Returns `invalid_route_syntax` when NONE of the above AND slot has inline @handle. @@ -142,6 +160,8 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult if (input.structuredTargetCats.length > 0) return { kind: 'ok' }; const slot = finalRoutingSlot(input.text); + if (slotHasEventDrivenExternalWaitExit(slot)) 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 03d008d488..1349bd6cc8 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,12 +12,10 @@ * KD-8 safe:只看"有无机械出口信号",零意图分类器。 */ -import { finalRoutingSlot } from '../final-routing-slot.js'; +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; -const EVENT_DRIVEN_EXTERNAL_WAIT_RE = - /^(?:(?:[-*+]\s+)|(?:\d+[.)]\s+))?External Wait\s*:\s*event-driven\s*\((?!\s*\))[^)\r\n]+\)\s*$/i; function hasRoutingToolCall(toolNames: readonly string[]): boolean { return toolNames.some((name) => { @@ -26,13 +24,6 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean { }); } -function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { - if (!text) return false; - const slot = finalRoutingSlot(text); - if (!slot) return false; - return slot.split(/\r?\n/).some((line) => EVENT_DRIVEN_EXTERNAL_WAIT_RE.test(line.trim())); -} - export interface RoutingExitInput { /** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */ readonly text?: string; diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 7d5d9b4e30..56c61b489a 100644 --- a/packages/api/test/final-routing-slot.test.js +++ b/packages/api/test/final-routing-slot.test.js @@ -151,6 +151,17 @@ 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, + }); + assert.equal(result.kind, 'ok'); + }); }); describe('F167 Phase H AC-H6: structural exemptions', () => { 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 index f072b66ae2..3913ce111d 100644 --- 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 @@ -115,3 +115,32 @@ 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 From b41b72a3ddf26644a73ddbc3f02b4b9cff9a8a60 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 00:28:23 +0800 Subject: [PATCH 03/14] fix: preserve event-driven remedial exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Cloud review found the new 2b external-wait template was accepted by the remedial gate but not treated as route-only in route-serial or downstream guard suppressors, causing original text loss and false void-hold/verdict hints. [砚砚/GPT-5.5🐾] --- .../services/agents/routing/route-serial.ts | 29 +++++++--- .../services/agents/routing/verdict-detect.ts | 3 +- .../agents/routing/void-hold-detect.ts | 3 + ...oute-serial-routing-guard-remedial.test.js | 35 ++++++++++++ packages/api/test/verdict-detect.test.js | 12 ++++ packages/api/test/void-hold-detect.test.js | 9 +++ ...te-guard-2b-event-driven-review-request.md | 55 +++++++++++++++++++ 7 files changed, 137 insertions(+), 9 deletions(-) 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 b6865dd477..689fdc0b22 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,7 @@ 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, 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'; @@ -181,7 +181,16 @@ function normalizeRouteOnlyRemedialText(text: string): string | null { .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 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[] { @@ -1483,6 +1492,7 @@ export async function* routeSerial( allRichBlocks: RichBlock[]; a2aMentions: CatId[]; hasCoCreatorLineStartMention: boolean; + routingContent: string; streamEvents: AgentMessage[]; }> => { routingGuardAttempted = true; @@ -1717,6 +1727,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] @@ -1749,7 +1760,7 @@ export async function* routeSerial( noTextBlocksOverride = result.allRichBlocks; if ( !hasValidRoutingExit({ - text: result.storedContent, + text: result.routingContent, lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions), toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -1767,6 +1778,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. @@ -1812,6 +1824,7 @@ export async function* routeSerial( 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); @@ -1819,7 +1832,7 @@ export async function* routeSerial( if ( !hasValidRoutingExit({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -1855,7 +1868,7 @@ export async function* routeSerial( } } const phaseHResult = validateRoutingSyntax({ - text: storedContent, + text: routingAnalysisContent, lineStartMentions: routingExitLineStartMentions, toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], @@ -2009,7 +2022,7 @@ 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], @@ -2034,7 +2047,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); @@ -2071,7 +2084,7 @@ 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], 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..6872f3eba7 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,7 +13,7 @@ * shared-rules §10 已落地,本模块是不依赖猫配合的兜底信号。 */ -import { finalRoutingSlot } from './final-routing-slot.js'; +import { finalRoutingSlot, hasEventDrivenExternalWaitExit } from './final-routing-slot.js'; /** * Cat signature line pattern: must have a slash OR a paw 🐾. @@ -211,5 +211,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 (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..39756f1048 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; @@ -107,6 +109,7 @@ 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 (hasEventDrivenExternalWaitExit(input.text)) return { shouldEmit: false, matchedPattern: matched }; return { shouldEmit: true, matchedPattern: matched }; } 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 2c474fee00..de76810819 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -380,6 +380,41 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); + test('event-driven external-wait remedial 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'); + + 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('tool-only no-text initial output still gets the remedial guard instead of silent completion', async () => { const service = createSequenceService('codex', [ [ diff --git a/packages/api/test/verdict-detect.test.js b/packages/api/test/verdict-detect.test.js index 57e5e9d56a..3423671776 100644 --- a/packages/api/test/verdict-detect.test.js +++ b/packages/api/test/verdict-detect.test.js @@ -286,6 +286,18 @@ 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: [], + }), + false, + ); + }); + 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..8c004e6b02 100644 --- a/packages/api/test/void-hold-detect.test.js +++ b/packages/api/test/void-hold-detect.test.js @@ -132,6 +132,15 @@ 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)', + }); + assert.equal(result.shouldEmit, false); + 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 index 3913ce111d..9df6928fea 100644 --- 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 @@ -144,3 +144,58 @@ 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 From 602e3c11d0be8df9fef1559f2c68db99ffdc449d Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 00:41:35 +0800 Subject: [PATCH 04/14] fix: honor signed event-driven exits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Cloud review found trailing cat signatures made finalRoutingSlot select the signature paragraph, so signed External Wait: event-driven exits were missed by remedial/verdict/void guards. [砚砚/GPT-5.5🐾] --- .../agents/routing/final-routing-slot.ts | 26 ++++++++- .../services/agents/routing/verdict-detect.ts | 55 +------------------ packages/api/test/final-routing-slot.test.js | 16 ++++++ ...te-guard-2b-event-driven-review-request.md | 39 +++++++++++++ 4 files changed, 80 insertions(+), 56 deletions(-) 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 084d51a39c..28089ed681 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 @@ -39,6 +39,28 @@ 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'); +} /** * Extract final routing slot = structurally-stripped last non-empty paragraph. @@ -86,7 +108,7 @@ function slotHasEventDrivenExternalWaitExit(slot: string): boolean { */ export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { if (!text) return false; - return slotHasEventDrivenExternalWaitExit(finalRoutingSlot(text)); + return slotHasEventDrivenExternalWaitExit(finalRoutingSlot(stripTrailingCatSignatures(text))); } /** @@ -160,7 +182,7 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult if (input.structuredTargetCats.length > 0) return { kind: 'ok' }; const slot = finalRoutingSlot(input.text); - if (slotHasEventDrivenExternalWaitExit(slot)) return { kind: 'ok' }; + if (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/verdict-detect.ts b/packages/api/src/domains/cats/services/agents/routing/verdict-detect.ts index 6872f3eba7..9c543be321 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, hasEventDrivenExternalWaitExit } 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 关键词。保守集,避免常见日常用语误报: diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 56c61b489a..5dced27534 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'; @@ -162,6 +163,21 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { }); 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, + }); + assert.equal(result.kind, 'ok'); + }); }); describe('F167 Phase H AC-H6: structural exemptions', () => { 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 index 9df6928fea..aaf7fb0473 100644 --- 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 @@ -199,3 +199,42 @@ Additional verification after the cloud fix: - `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 From cd0ba30c29d29e52752f473dce6e6f437bee0c24 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 00:50:08 +0800 Subject: [PATCH 05/14] fix: preserve signed event-driven remedials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Cloud review found signed External Wait remedial patches were treated as replacement content because route-only normalization checked for a single line before stripping trailing cat signatures. [砚砚/GPT-5.5🐾] --- .../services/agents/routing/route-serial.ts | 8 +++- ...oute-serial-routing-guard-remedial.test.js | 31 ++++++++++++++ ...te-guard-2b-event-driven-review-request.md | 41 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) 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 689fdc0b22..e8bc1da436 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 { hasEventDrivenExternalWaitExit, 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'; @@ -175,7 +179,7 @@ function stripMarkdownRoutePrefix(line: string): string { } function normalizeRouteOnlyRemedialText(text: string): string | null { - const lines = text + const lines = stripTrailingCatSignatures(text) .trim() .split(/\r?\n/) .map((line) => stripMarkdownRoutePrefix(line)) 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 de76810819..4517f7a6d9 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -415,6 +415,37 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); + test('signed event-driven external-wait remedial 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'); + + 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('tool-only no-text initial output still gets the remedial guard instead of silent completion', async () => { const service = createSequenceService('codex', [ [ 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 index aaf7fb0473..4fd3977fc7 100644 --- 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 @@ -238,3 +238,44 @@ Additional verification after the signed-exit fix: `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 From 218e847338e039aa2b84490b54d96be266f22b59 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:01:23 +0800 Subject: [PATCH 06/14] fix: preserve URL event-driven wait ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: URL callback ids are valid 2b event-driven exits; stripping URLs before the external-wait predicate made guarded outputs with PR/check URLs look unrouted. Adds regression coverage for URL ids while keeping URL stripping for inline-mention slot validation. [砚砚/GPT-5.5🐾] Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex --- .../agents/routing/final-routing-slot.ts | 42 +++++++++++-------- packages/api/test/final-routing-slot.test.js | 16 +++++++ 2 files changed, 41 insertions(+), 17 deletions(-) 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 28089ed681..ba355e5b87 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 @@ -62,6 +62,26 @@ export function stripTrailingCatSignatures(text: string): string { 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. * @@ -77,23 +97,11 @@ export function stripTrailingCatSignatures(text: string): string { * 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'); - - const noUrl = noQuote.replace(URL_RE, ''); - - const paragraphs = noUrl - .split(/\n\s*\n/) - .map((p) => p.trim()) - .filter((p) => p.length > 0); + return selectFinalRoutingSlot(text, { stripUrls: true }); +} - return paragraphs.length > 0 ? paragraphs[paragraphs.length - 1]! : ''; +function finalRoutingSlotPreservingUrls(text: string): string { + return selectFinalRoutingSlot(text, { stripUrls: false }); } function slotHasEventDrivenExternalWaitExit(slot: string): boolean { @@ -108,7 +116,7 @@ function slotHasEventDrivenExternalWaitExit(slot: string): boolean { */ export function hasEventDrivenExternalWaitExit(text: string | undefined): boolean { if (!text) return false; - return slotHasEventDrivenExternalWaitExit(finalRoutingSlot(stripTrailingCatSignatures(text))); + return slotHasEventDrivenExternalWaitExit(finalRoutingSlotPreservingUrls(stripTrailingCatSignatures(text))); } /** diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 5dced27534..36e22d03b4 100644 --- a/packages/api/test/final-routing-slot.test.js +++ b/packages/api/test/final-routing-slot.test.js @@ -178,6 +178,22 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { }); 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, + }); + assert.equal(result.kind, 'ok'); + }); }); describe('F167 Phase H AC-H6: structural exemptions', () => { From dbda45b6afd5bc7dea94ccd1ab41efcbb19059ca Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:14:34 +0800 Subject: [PATCH 07/14] fix: require covered event-driven waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Text alone cannot prove a 2b event-driven wait has a wake-up path. Require callers to pass verified callback/tracking coverage before treating External Wait: event-driven as a routing exit; connector-sourced routes provide that coverage explicitly. Validation: pnpm check; pnpm --dir packages/api run build; targeted routing guard tests 132/132 passed; git diff --check. Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex [砚砚/GPT-5.5🐾] --- .../agents/invocation/QueueProcessor.ts | 2 + .../services/agents/routing/AgentRouter.ts | 5 ++ .../agents/routing/final-routing-slot.ts | 6 +- .../routing/guards/routing-guard-remedial.ts | 6 +- .../services/agents/routing/route-helpers.ts | 4 ++ .../services/agents/routing/route-serial.ts | 16 ++++- .../services/agents/routing/verdict-detect.ts | 4 +- .../agents/routing/void-hold-detect.ts | 9 ++- .../email/ConnectorInvokeTrigger.ts | 2 + packages/api/test/final-routing-slot.test.js | 14 ++++ ...oute-serial-routing-guard-remedial.test.js | 64 +++++++++++++++++-- .../api/test/routing-guard-remedial.test.js | 28 +++++++- packages/api/test/verdict-detect.test.js | 13 ++++ packages/api/test/void-hold-detect.test.js | 10 +++ 14 files changed, 164 insertions(+), 19 deletions(-) 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..5341c8b22b 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', + // Connector entries are produced by structured external callback/tracking tasks. + eventDrivenExternalWaitCoverage: entry.source === 'connector', }, )) { 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 ba355e5b87..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 = @@ -179,7 +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 + * - 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. @@ -190,7 +192,7 @@ export function validateRoutingSyntax(input: ValidationInput): ValidationResult if (input.structuredTargetCats.length > 0) return { kind: 'ok' }; const slot = finalRoutingSlot(input.text); - if (hasEventDrivenExternalWaitExit(input.text)) return { kind: 'ok' }; + 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 1349bd6cc8..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 @@ -35,20 +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, structural - * 2b external wait slot). + * 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 (hasEventDrivenExternalWaitExit(input.text)) return true; + if (input.hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(input.text)) return true; return false; } 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 e8bc1da436..844968e30c 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 @@ -178,7 +178,7 @@ function stripMarkdownRoutePrefix(line: string): string { return line.replace(/^(?:[-*+]\s+|>\s*|\d+[.)]\s+)/, '').trim(); } -function normalizeRouteOnlyRemedialText(text: string): string | null { +function normalizeRouteOnlyRemedialText(text: string, hasEventDrivenExternalWaitCoverage: boolean): string | null { const lines = stripTrailingCatSignatures(text) .trim() .split(/\r?\n/) @@ -187,7 +187,7 @@ function normalizeRouteOnlyRemedialText(text: string): string | null { if (lines.length !== 1) return null; const line = lines[0]!; if (ROUTE_ONLY_REMEDIAL_TEXT_RE.test(line)) return line; - return hasEventDrivenExternalWaitExit(line) ? line : null; + return hasEventDrivenExternalWaitCoverage && hasEventDrivenExternalWaitExit(line) ? line : null; } function buildRoutingAnalysisContent(storedContent: string, routingContent: string): string { @@ -396,6 +396,7 @@ export async function* routeSerial( } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; + const hasEventDrivenExternalWaitCoverage = 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); @@ -1672,7 +1673,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 @@ -1752,6 +1755,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: hasRoutingExitCoCreatorLineStartMention(''), + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial( @@ -1769,6 +1773,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: result.hasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -1822,6 +1827,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { const result = await runRoutingGuardRemedial(storedContent, allRichBlocks, [...collectedToolEvents]); @@ -1841,6 +1847,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { await appendRoutingGuardFailureNotice(); @@ -1877,6 +1884,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], rosterHandles: phaseHRosterHandles, + hasEventDrivenExternalWaitCoverage, }); const phaseHHit = phaseHResult.kind === 'invalid_route_syntax'; if (phaseHHit && phaseHResult.kind === 'invalid_route_syntax') { @@ -2031,6 +2039,7 @@ export async function* routeSerial( toolNames: collectedToolNames, structuredTargetCats: [...structuredTargetCats], hasCoCreatorLineStartMention: routingExitHasCoCreatorLineStartMention, + hasEventDrivenExternalWaitCoverage, }) ) { try { @@ -2093,6 +2102,7 @@ export async function* routeSerial( 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 9c543be321..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 @@ -140,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; } /** @@ -158,6 +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 (hasEventDrivenExternalWaitExit(input.text)) 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 39756f1048..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 @@ -83,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 { @@ -100,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); @@ -109,7 +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 (hasEventDrivenExternalWaitExit(input.text)) 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/ConnectorInvokeTrigger.ts b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts index d03675006c..a2aa1b06c3 100644 --- a/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts +++ b/packages/api/src/infrastructure/email/ConnectorInvokeTrigger.ts @@ -386,6 +386,8 @@ export class ConnectorInvokeTrigger { frustrationAutoIssueEligible: false, // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning verdictPassWarningEnabled: false, + // Connector-triggered routes come from the structured external callback/tracking pipeline. + eventDrivenExternalWaitCoverage: true, })) { // #768: Broadcast intent_mode on first CLI event — proves CLI is alive. if (!intentModeBroadcast) { diff --git a/packages/api/test/final-routing-slot.test.js b/packages/api/test/final-routing-slot.test.js index 36e22d03b4..809504b971 100644 --- a/packages/api/test/final-routing-slot.test.js +++ b/packages/api/test/final-routing-slot.test.js @@ -160,6 +160,7 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { toolNames: [], structuredTargetCats: [], rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, }); assert.equal(result.kind, 'ok'); }); @@ -175,6 +176,7 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { toolNames: [], structuredTargetCats: [], rosterHandles: roster, + hasEventDrivenExternalWaitCoverage: true, }); assert.equal(result.kind, 'ok'); }); @@ -191,9 +193,21 @@ describe('F167 Phase H AC-H3: validateRoutingSyntax trigger conditions', () => { 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/route-serial-routing-guard-remedial.test.js b/packages/api/test/route-serial-routing-guard-remedial.test.js index 4517f7a6d9..d3c6121e78 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); } @@ -380,11 +381,18 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); - test('event-driven external-wait remedial counts as route-only and keeps first-pass text visible', async () => { + 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'); + 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( @@ -415,14 +423,19 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); - test('signed event-driven external-wait remedial counts as route-only and keeps first-pass text visible', async () => { + 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'); + 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( @@ -446,6 +459,20 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { ); }); + 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', [ [ @@ -730,13 +757,20 @@ 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 counts as a routing exit without remedial invoke', async () => { + 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'); + 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( @@ -749,6 +783,22 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { 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 08d9186c2d..2960e518ef 100644 --- a/packages/api/test/routing-guard-remedial.test.js +++ b/packages/api/test/routing-guard-remedial.test.js @@ -76,17 +76,30 @@ describe('F177 Phase H — shouldRemediateRouting', () => { ); }); - test('2b External Wait event-driven 槽位 → 不触发 remedial', () => { + 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', () => { @@ -101,15 +114,26 @@ describe('F177 Phase H — hasValidRoutingExit', () => { assert.equal(hasValidRoutingExit({ ...base, hasCoCreatorLineStartMention: true }), true); }); - test('External Wait: event-driven() counts as a valid 2b external-wait exit', () => { + 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', () => { diff --git a/packages/api/test/verdict-detect.test.js b/packages/api/test/verdict-detect.test.js index 3423671776..73cfb7b6b1 100644 --- a/packages/api/test/verdict-detect.test.js +++ b/packages/api/test/verdict-detect.test.js @@ -293,11 +293,24 @@ describe('F167 C2 AC-C7: shouldWarnVerdictWithoutPass', () => { 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 8c004e6b02..8217f3f67c 100644 --- a/packages/api/test/void-hold-detect.test.js +++ b/packages/api/test/void-hold-detect.test.js @@ -136,11 +136,21 @@ describe('F167 Phase I AC-I1: shouldWarnVoidHold', () => { 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: [] }), From 9261a82c90efbfa372d40bff1daae6b1f3a96339 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:24:59 +0800 Subject: [PATCH 08/14] fix: scope event-driven wait coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Connector-origin text does not prove a future callback wake path. Gate External Wait: event-driven coverage on explicit connector policy from GitHub tracking/callback tasks, and persist that policy through queued entries. Validation: pnpm check; pnpm --dir packages/api run build; expanded routing/connector/queue tests 288/288 passed; git diff --check. Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex [砚砚/GPT-5.5🐾] --- .../agents/invocation/InvocationQueue.ts | 6 ++ .../agents/invocation/QueueProcessor.ts | 4 +- .../infrastructure/email/CiCdCheckTaskSpec.ts | 2 + .../email/ConflictCheckTaskSpec.ts | 1 + .../email/ConnectorInvokeTrigger.ts | 17 ++++- .../email/IssueCommentTaskSpec.ts | 1 + .../email/ReviewFeedbackTaskSpec.ts | 1 + .../api/test/connector-invoke-trigger.test.js | 68 +++++++++++++++++++ packages/api/test/queue-processor.test.js | 31 +++++++++ 9 files changed, 127 insertions(+), 4 deletions(-) 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 5341c8b22b..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,8 +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', - // Connector entries are produced by structured external callback/tracking tasks. - eventDrivenExternalWaitCoverage: 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/infrastructure/email/CiCdCheckTaskSpec.ts b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts index 3bdcb4b255..0b0487bc73 100644 --- a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts @@ -93,6 +93,7 @@ export function createCiCdCheckTaskSpec(opts: CiCdCheckTaskSpecOptions): TaskSpe priority: 'urgent', reason: 'github_ci_failure', sourceCategory: 'ci', + eventDrivenExternalWaitCoverage: true, }; void opts.invokeTrigger .trigger( @@ -126,6 +127,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/ConflictCheckTaskSpec.ts b/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts index ec1aa2744e..4376822056 100644 --- a/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts @@ -102,6 +102,7 @@ export function createConflictCheckTaskSpec(opts: ConflictCheckTaskSpecOptions): priority: 'urgent', reason: 'github_pr_conflict', sourceCategory: 'conflict', + 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 a2aa1b06c3..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,8 +399,8 @@ export class ConnectorInvokeTrigger { frustrationAutoIssueEligible: false, // #949 P2: Connector-sourced flows have no ball-pass expectation — suppress verdict warning verdictPassWarningEnabled: false, - // Connector-triggered routes come from the structured external callback/tracking pipeline. - eventDrivenExternalWaitCoverage: true, + // 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..1ae7ac0182 100644 --- a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts +++ b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts @@ -468,6 +468,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T priority: 'normal', reason: 'github_issue_comment', sourceCategory: 'issue', + 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..9dda943ede 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts @@ -491,6 +491,7 @@ export function createReviewFeedbackTaskSpec(opts: ReviewFeedbackTaskSpecOptions reason: 'github_review_feedback', sourceCategory: 'review', suggestedSkill, + eventDrivenExternalWaitCoverage: true, 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/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'); From f2c697c8568b259d9e898674a9d511496761c0f7 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:32:42 +0800 Subject: [PATCH 09/14] fix: gate CI wait coverage by intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: CI failure wakes for both review and merge intents, but only merge intent guarantees the follow-up CI pass will invoke the cat. Review-intent failures must not make External Wait: event-driven a valid exit. Validation: pnpm check; pnpm --dir packages/api run build; expanded routing/connector/queue/CI tests 297/297 passed; git diff --check. Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex [砚砚/GPT-5.5🐾] --- .../infrastructure/email/CiCdCheckTaskSpec.ts | 7 ++- .../test/scheduler/cicd-check-spec.test.js | 44 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts index 0b0487bc73..22f7e31c22 100644 --- a/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/CiCdCheckTaskSpec.ts @@ -87,13 +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: true, + eventDrivenExternalWaitCoverage: intent === 'merge', }; void opts.invokeTrigger .trigger( @@ -114,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)`, 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 () => { From 46af1362deeb566d182024181d8301d6f4c8bd1d Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:43:59 +0800 Subject: [PATCH 10/14] fix: validate event-driven coverage sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: External Wait: event-driven must only satisfy routing when a real future callback exists. Conflict-only wakes do not emit a resolved follow-up, while same-turn PR/issue tracking registration does create verified callback coverage. Validation: pnpm check; pnpm --dir packages/api run build; scheduler/route-serial Red→Green tests 31/31 passed; expanded routing/connector/queue/CI/conflict regression pack 306/306 passed; git diff --check. Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex [砚砚/GPT-5.5🐾] --- .../services/agents/routing/route-serial.ts | 13 +++- .../email/ConflictCheckTaskSpec.ts | 1 - ...oute-serial-routing-guard-remedial.test.js | 70 +++++++++++++++++++ .../scheduler/conflict-check-spec.test.js | 5 ++ 4 files changed, 87 insertions(+), 2 deletions(-) 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 844968e30c..cc83ecdd1b 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 @@ -237,6 +237,11 @@ function isCrossPostMessageToolName(toolName: string | undefined): boolean { return toolName === 'mcp:cat-cafe/cross_post_message' || toolName === 'cat_cafe_cross_post_message'; } +function isTrackingRegistrationToolName(toolName: string | undefined): boolean { + const normalized = normalizeMcpToolName(toolName); + return normalized === 'register_pr_tracking' || normalized === 'register_issue_tracking'; +} + function isCallbackContentRoutingToolName(toolName: string | undefined): boolean { return isPostMessageToolName(toolName) || isCrossPostMessageToolName(toolName); } @@ -396,7 +401,7 @@ export async function* routeSerial( } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; - const hasEventDrivenExternalWaitCoverage = options.eventDrivenExternalWaitCoverage === true; + let hasEventDrivenExternalWaitCoverage = 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); @@ -1229,6 +1234,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isTrackingRegistrationToolName(completedToolName)) { + hasEventDrivenExternalWaitCoverage = true; + } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); } // F188 Phase F AC-F10 (砚砚 六审 P1-B: also scope by catId for serial route consistency). @@ -1654,6 +1662,9 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { + if (callbackResult.confirmed && isTrackingRegistrationToolName(completedToolName)) { + hasEventDrivenExternalWaitCoverage = true; + } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); } } diff --git a/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts b/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts index 4376822056..ec1aa2744e 100644 --- a/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ConflictCheckTaskSpec.ts @@ -102,7 +102,6 @@ export function createConflictCheckTaskSpec(opts: ConflictCheckTaskSpecOptions): priority: 'urgent', reason: 'github_pr_conflict', sourceCategory: 'conflict', - eventDrivenExternalWaitCoverage: true, }; void opts.invokeTrigger .trigger( 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 d3c6121e78..8d2ad1f9bb 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -783,6 +783,76 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.match(codexMessages[0].content, /External Wait: event-driven/); }); + test('2b event-driven external wait honors tracking registered earlier in the same turn', 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, 1, 'same-turn tracking registration should prevent a remedial invoke'); + assert.equal( + appended.find((m) => m.source?.connector === 'routing-guard-failure'), + undefined, + 'confirmed 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 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)', 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 () => { From 0d59344c9e9e9b9cc96d5085e034c7d73ae8341a Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Sun, 28 Jun 2026 01:53:11 +0800 Subject: [PATCH 11/14] fix: gate approval wait coverage by intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: APPROVED review feedback can wake merge-gate while PR tracking is still in review intent, but CI pass only wakes again after intent becomes merge. Review-intent approval wakes must not validate External Wait: event-driven for CI-pending merge-gate turns. Validation: red review-feedback scheduler regression failed on 46af1362d; pnpm --dir packages/api run build; review-feedback scheduler 37/37 passed; expanded review/CI/conflict/connector/queue/routing pack 234/234 passed; pnpm check; git diff --check. Thread-Context: threadId=thread_mqwmwwc3pb6fh6z9 catId=codex [砚砚/GPT-5.5🐾] --- .../email/ReviewFeedbackTaskSpec.ts | 4 +- .../scheduler/review-feedback-spec.test.js | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts index 9dda943ede..6ae7298208 100644 --- a/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts +++ b/packages/api/src/infrastructure/email/ReviewFeedbackTaskSpec.ts @@ -485,13 +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: true, + eventDrivenExternalWaitCoverage, coalesceKey: `${subjectKey}:review-feedback:${coalesceTargetCatId}`, }; void opts.invokeTrigger 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 () => { From 3bb9edb98e393b9cb68076d0ec9b92e49af70d05 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Mon, 29 Jun 2026 00:11:57 +0800 Subject: [PATCH 12/14] fix: require PR wait pickup proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Cloud review found same-turn PR tracking registration was treated as verified event-driven callback coverage before any reviewer or CI pickup, allowing a cat to exit without hold and potentially never wake. [砚砚/GPT-5.5🐾] --- .../cats/services/agents/routing/route-serial.ts | 10 ++++++---- .../test/route-serial-routing-guard-remedial.test.js | 9 +++------ 2 files changed, 9 insertions(+), 10 deletions(-) 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 cc83ecdd1b..38b6dcef8b 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 @@ -237,9 +237,11 @@ function isCrossPostMessageToolName(toolName: string | undefined): boolean { return toolName === 'mcp:cat-cafe/cross_post_message' || toolName === 'cat_cafe_cross_post_message'; } -function isTrackingRegistrationToolName(toolName: string | undefined): boolean { +function isSameTurnEventDrivenCoverageToolName(toolName: string | undefined): boolean { const normalized = normalizeMcpToolName(toolName); - return normalized === 'register_pr_tracking' || normalized === 'register_issue_tracking'; + // 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 { @@ -1234,7 +1236,7 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { - if (callbackResult.confirmed && isTrackingRegistrationToolName(completedToolName)) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName)) { hasEventDrivenExternalWaitCoverage = true; } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); @@ -1662,7 +1664,7 @@ export async function* routeSerial( if (callbackResult.messageId) callbackPostMessageId = callbackResult.messageId; } if (completedToolName) { - if (callbackResult.confirmed && isTrackingRegistrationToolName(completedToolName)) { + if (callbackResult.confirmed && isSameTurnEventDrivenCoverageToolName(completedToolName)) { hasEventDrivenExternalWaitCoverage = true; } settleCallbackRoutingExit(completedToolName, callbackResult.confirmed); 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 8d2ad1f9bb..5cc9014b02 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -783,7 +783,7 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { assert.match(codexMessages[0].content, /External Wait: event-driven/); }); - test('2b event-driven external wait honors tracking registered earlier in the same turn', async () => { + test('2b event-driven external wait rejects PR tracking registration without pickup proof', async () => { const service = createSequenceService('codex', [ [ { @@ -807,15 +807,12 @@ describe('F177 Phase H — route-serial routing guard remedial invoke', () => { const { appended, calls } = await runRoute(service, 'thread-routing-guard-event-driven-register'); - assert.equal(calls.length, 1, 'same-turn tracking registration should prevent a remedial invoke'); + 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, - 'confirmed tracking registration should count as verified callback coverage for 2b', + 'valid follow-up remedial exit should avoid failure after rejecting PR tracking registration alone', ); - 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 honors issue tracking registered earlier in the same turn', async () => { From b4bca5d3ea258d4edd81b536640b0d25fefadee0 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Mon, 29 Jun 2026 00:21:00 +0800 Subject: [PATCH 13/14] fix: scope wait coverage per cat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Cloud review found event-driven wait coverage was route-scoped, so a later A2A worklist cat could inherit another cat's callback proof and exit without its own wake path. [砚砚/GPT-5.5🐾] --- .../services/agents/routing/route-serial.ts | 5 ++++- ...oute-serial-routing-guard-remedial.test.js | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 38b6dcef8b..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 @@ -403,7 +403,7 @@ export async function* routeSerial( } = options; const previousResponses: { catId: CatId; content: string }[] = []; const thinkingMode = options.thinkingMode ?? 'play'; - let hasEventDrivenExternalWaitCoverage = options.eventDrivenExternalWaitCoverage === true; + 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); @@ -532,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; 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 5cc9014b02..b3b3cb9d85 100644 --- a/packages/api/test/route-serial-routing-guard-remedial.test.js +++ b/packages/api/test/route-serial-routing-guard-remedial.test.js @@ -326,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 }); From 76e66bf174320150f38cde10e82d652e99c56084 Mon Sep 17 00:00:00 2001 From: "MaineCoon-GPT-5.5" Date: Mon, 29 Jun 2026 00:33:23 +0800 Subject: [PATCH 14/14] fix: gate closed issue wait coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Closed issue tracking final deliveries mark the tracking task done before the connector wake, so advertising event-driven wait coverage after that point can validate a wait with no active poller left to wake it. Carry coverage on IssueCommentSignal and grant it only for open issue deliveries that still have an active tracking path. Validation: pnpm --dir packages/api run build; focused issue/factory tests 99/99; git diff --check; pnpm check. [砚砚/GPT-5.5🐾] Thread-Context: threadId=thread_mqcj45byxoka2z7u catId=codex --- .../email/IssueCommentTaskSpec.ts | 7 ++- .../api/test/f168-phase-b-dual-cursor.test.js | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts b/packages/api/src/infrastructure/email/IssueCommentTaskSpec.ts index 1ae7ac0182..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,7 +473,7 @@ export function createIssueCommentTaskSpec(opts: IssueCommentTaskSpecOptions): T priority: 'normal', reason: 'github_issue_comment', sourceCategory: 'issue', - eventDrivenExternalWaitCoverage: true, + eventDrivenExternalWaitCoverage: signal.eventDrivenExternalWaitCoverage === true, coalesceKey: `${subjectKey}:issue-comment:${coalesceTargetCatId}`, }; void opts.invokeTrigger 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.