Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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). */
Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require verified callback before accepting waits

When a guarded reply contains External Wait: event-driven (...) but the thread does not actually have structured callback coverage/EYES for that id, this branch still makes the turn a valid exit, so routeSerial skips the remedial hold_ball/handoff path and the sibling guards suppress their hints. Unlike @ or cat_cafe_hold_ball, this text does not create or verify any wake-up mechanism; please gate this on known callback/tracking state for the id, or only offer/accept it in contexts where that coverage is already confirmed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in dbda45b (fix: require covered event-driven waits).

What changed:

  • External Wait: event-driven (...) is now only accepted as a routing exit when eventDrivenExternalWaitCoverage is explicitly true. Text alone fails closed.
  • Direct connector routes and queued connector entries set that flag because they originate from the structured callback/tracking path. A2A/user text does not.
  • Routing remedial, final-slot syntax, void-hold, and verdict-without-pass guards all use the same coverage gate.

Red -> Green coverage:

  • Text-only event-driven waits now trigger remedial / syntax / void-hold / verdict warnings.
  • Verified coverage keeps existing 2b event-driven behavior intact.

Validation:

  • pnpm check
  • pnpm --dir packages/api run build
  • Targeted routing guard suites: 132/132 passed ✅
  • git diff --check

[砚砚/GPT-5.5🐾]

return false;
}

Expand Down Expand Up @@ -74,6 +89,7 @@ export const REMEDIAL_PROMPT =
'请只补一个出口,不要重做刚才的工作:\n' +
'- 传球:另起一行,行首独立写 @句柄(如 @opus48)\n' +
'- 持球等外部条件:调用 cat_cafe_hold_ball\n' +
'- 事件驱动外部等待(已有结构化回调 + EYES>0):另起一行写 External Wait: event-driven (<id>)\n' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original content for event-driven remedials

When a first pass has useful text but no exit, this new remedial option asks the model to return only External Wait: event-driven (<id>); however route-serial still treats only @... remedials as route-only, so an event-driven remedial is handled as replacement content and the persisted/visible message becomes just the wait line, discarding the original work the guard was supposed to patch. Please extend the route-only normalization/preservation path for this structural template before teaching the remedial prompt to emit it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3 as part of the current-head preservation fix. Event-driven external-wait remedials are included in route-only normalization, so the first-pass content is preserved instead of replaced by the bare wait line. Focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original text for event-driven remedials

When a guarded cat first produces useful content but no route exit, this new prompt can now make the remedial call answer with only External Wait: event-driven (...). route-serial still recognizes only one-line @... remedials as route-only via normalizeRouteOnlyRemedialText, so this new exit is treated as replacement text rather than an exit patch, causing the original first-pass content to be discarded and the persisted/visible message to become just the bare external-wait line. Please either include this template in the route-only preservation path or avoid offering it as a “只补一个出口” remedial option.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3. External Wait: event-driven (...) remedials are now route-only patches: route-serial preserves the first-pass visible text, validates downstream guards with the routing patch, and no longer emits guard/syntax/void-hold hints for the preserved turn. Red/green focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

'- 升级co-creator:另起一行行首写 @co-creator';

export function buildRemedialPrompt(): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,7 @@ export async function* routeSerial(
shouldRemediateRouting({
needsGuard: needsServerRoutingGuard,
attempted: routingGuardAttempted,
text: '',
lineStartMentions: getRoutingExitLineStartMentions(),
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1748,6 +1749,7 @@ export async function* routeSerial(
noTextBlocksOverride = result.allRichBlocks;
if (
!hasValidRoutingExit({
text: result.storedContent,
lineStartMentions: getRoutingExitLineStartMentions(result.a2aMentions),
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand Down Expand Up @@ -1799,6 +1801,7 @@ export async function* routeSerial(
shouldRemediateRouting({
needsGuard: needsServerRoutingGuard,
attempted: routingGuardAttempted,
text: storedContent,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress void-hold for event-driven wait exits

When this text makes shouldRemediateRouting skip the remedial invoke, the later evaluateVoidHold call in route-serial still only suppresses on @, structured targets, co-creator, or a real hold tool. A valid 2b response that explains “不需要 hold_ball” (as in the new integration test) matches the hold_ball text pattern and will still emit a [持球提醒]/ball.void_pass despite the final External Wait: event-driven (...) exit being accepted here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b41b72a3. evaluateVoidHold now suppresses on the shared structural External Wait: event-driven (...) exit, and route-serial feeds post-remedial guard checks with the routing analysis content. Added regression coverage for 不需要 hold_ball plus a valid event-driven external wait. Focused suite is 85/85; expanded guard suite is 122/122; pnpm check passed.

lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand All @@ -1816,6 +1819,7 @@ export async function* routeSerial(

if (
!hasValidRoutingExit({
text: storedContent,
lineStartMentions: routingExitLineStartMentions,
toolNames: collectedToolNames,
structuredTargetCats: [...structuredTargetCats],
Expand Down
19 changes: 19 additions & 0 deletions packages/api/test/route-serial-routing-guard-remedial.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
25 changes: 24 additions & 1 deletion packages/api/test/routing-guard-remedial.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(<id>) 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, /不要重做/);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (<id>)` 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 (<id>)` 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(<id>) 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.

Loading