Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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 @@ -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.
Expand Down Expand Up @@ -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));

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 Strip trailing cat signatures before checking external waits

When a cat follows the L0 identity rule and signs after the routing line, e.g. External Wait: event-driven (pr:35) followed by [砚砚/GPT-5.5], finalRoutingSlot(text) returns the signature paragraph, so this helper returns false. That makes the new 2b external-wait exit still trip the remedial/verdict/void guards for signed outputs; verdict-detect.ts already had to strip trailing cat signatures for the same final-slot failure mode, so this shared exit predicate should do the same before selecting the slot.

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 602e3c11d0be8df9fef1559f2c68db99ffdc449d. hasEventDrivenExternalWaitExit() now strips trailing cat signatures before selecting the final slot, and verdict-detect reuses the same shared stripper. Added regression coverage for External Wait: event-driven (...) followed by [砚砚/GPT-5.5]. Verification: build passed, expanded guard suite 123/123, git diff --check clean, pnpm check passed.

}

/**
* Find inline @handle mentions in slot (= not at line-start position).
*
Expand Down Expand Up @@ -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.
Expand All @@ -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' };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* KD-8 safe:只看"有无机械出口信号",零意图分类器。
*/

import { hasEventDrivenExternalWaitExit } from '../final-routing-slot.js';

/** Routing-tool substrings that count as a legitimate exit (持球/群发传球). */
const ROUTING_TOOL_SUBSTRINGS = ['hold_ball', 'multi_mention'] as const;

Expand All @@ -23,6 +25,8 @@ function hasRoutingToolCall(toolNames: readonly string[]): boolean {
}

export interface RoutingExitInput {
/** Stored output text. Only the final routing slot is inspected for structural external-wait exits. */
readonly text?: string;
/** Line-start @cat mentions parsed this turn (parseA2AMentions). */
readonly lineStartMentions: readonly string[];
/** Tool names invoked this turn (scan for hold_ball / multi_mention). */
Expand All @@ -36,13 +40,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 +80,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
11 changes: 11 additions & 0 deletions packages/api/test/final-routing-slot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
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,146 @@
# 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.

## 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
Loading