Skip to content

feat: [ENG-2929] add brv channel core interfaces and enforce server/c…#745

Merged
leehpham merged 1 commit into
proj/multi-agent-collabfrom
feat/ENG-2929
May 30, 2026
Merged

feat: [ENG-2929] add brv channel core interfaces and enforce server/c…#745
leehpham merged 1 commit into
proj/multi-agent-collabfrom
feat/ENG-2929

Conversation

@leehpham
Copy link
Copy Markdown
Member

…ore import boundary.

Summary

  • Problem:
  • Why it matters:
  • What changed:
  • What did NOT change (scope boundary):

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Test
  • Chore (build, dependencies, CI)

Scope (select all touched areas)

  • TUI / REPL
  • Agent / Tools
  • LLM Providers
  • Server / Daemon
  • Shared (constants, types, transport events)
  • CLI Commands (oclif)
  • Hub / Connectors
  • Cloud Sync
  • CI/CD / Infra

Linked issues

  • Closes #
  • Related #

Root cause (bug fixes only, otherwise write N/A)

  • Root cause:
  • Why this was not caught earlier:

Test plan

  • Coverage added:
    • Unit test
    • Integration test
    • Manual verification only
  • Test file(s):
  • Key scenario(s) covered:

User-visible changes

List user-visible changes (including defaults, config, or CLI output).
If none, write None.

Evidence

Attach at least one:

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording

Checklist

  • Tests added or updated and passing (npm test)
  • Lint passes (npm run lint)
  • Type check passes (npm run typecheck)
  • Build succeeds (npm run build)
  • Commits follow Conventional Commits format
  • Documentation updated (if applicable)
  • No breaking changes (or clearly documented above)
  • Branch is up to date with main

Risks and mitigations

List real risks for this PR. If none, write None.

  • Risk:
    • Mitigation:

@leehpham leehpham requested a review from bao-byterover as a code owner May 30, 2026 11:04
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @leehpham's task in 5m 35s —— View job


Claude Code Review — feat: [ENG-2929] add brv channel core interfaces and enforce server/core import boundary

  • Read changed files
  • Verify ESLint boundary rule
  • Review each new interface for correctness & style
  • Review unrelated edits (CLAUDE.md, curate-prompt-builder, channel.ts, i-curate-executor)
  • Post inline comments
  • Post summary review

Summary

Overall score: 4 / 5

This is a clean, well-scoped foundation PR. It adds six new core-layer interfaces for the channel subsystem, enforces a src/server/core/**server/infra / oclif / tui import boundary via ESLint, and adds two CLAUDE.md style rules (third-person-singular doc verbs, no ephemeral project-planning labels in comments). The two pre-existing core → infra imports are surfaced honestly with TODO(ENG-3034) markers — that's the right way to pay down the debt rather than hide it.

The interfaces are well-shaped: ports only, args-objects throughout, readonly everywhere, no implementation leakage, and rich doc comments that explain why a seam exists (not what it does). The TurnEventPayload derived type via distributive conditional is elegant — it preserves the discriminated union while structurally stripping the orchestrator-owned base fields.

I could not run npm run lint / npm run typecheck in this environment (no sandbox approval), so the verifications below are by reading.

What's strong

  • praise: Outside-in discipline is visible — interfaces are minimal and tied to concrete consumer needs, not speculative future shape. IChannelOrchestrator explicitly says it will be extended additively rather than replaced, which is the right framing.
  • praise: The new "no ephemeral milestone/issue refs in code, except TODO(ENG-xxxx)" rule in CLAUDE.md is exactly the right shape — see inline comment on CLAUDE.md.
  • praise: The ESLint config carries a load-bearing explanatory comment about infra being a sibling of core, so future readers don't accidentally simplify the pattern list and reopen the hole.
  • praise: Separating IChannelStore (metadata) from ITranscriptStore (events) means retention policy on the event log can evolve without touching channel metadata. Good decomposition.
  • praise: IDriverPool is explicit that it does not spawn drivers — pure lifecycle bookkeeping. That invariant prevents the pool from quietly becoming a god-object later.

Findings (none blocking)

# File Severity Issue
1 src/shared/types/channel.ts issue (non-blocking) Module-level doc contains M0-1, Q2, M5+ references — violates the very CLAUDE.md rule this PR introduces. Clean these up to lead by example.
2 src/server/core/interfaces/channel/i-driver-pool.ts nit Three doc comments coordinate a third-person verb (Stops) with a bare imperative (evict) — should be evicts for verb agreement.
3 src/server/core/interfaces/channel/i-agent-driver.ts (line 58, 82) nit Same coordinated-verb mismatch on prompt and stop (Dispatches … and stream, Tears … and release).
4 src/server/core/interfaces/channel/i-agent-driver.ts (TurnEventPayload) suggestion The Omit-list literal mirrors TurnEventBaseShape by hand. Export a base-key union from shared/types/channel.ts and Omit on keyof of it, so "MUST mirror" becomes structurally enforced rather than comment-policed.
5 src/server/core/interfaces/channel/i-channel-store.ts question addMember is silent on collision semantics when a handle already exists. IChannelOrchestrator.cancelTurn has the same gap on completed/missing turns. createChannel and removeMember set the precedent — worth matching it.
6 src/server/core/interfaces/channel/i-transcript-store.ts question appendTurnEvent doesn't specify behavior on duplicate / out-of-order seq. Given the comment that the orchestrator is the sole seq writer, picking trust vs defensive and stating it pins down the adapter contract.
7 eslint.config.mjs nit (defensive) Sibling-relative patterns top out at ../../../../infra/**. Current core/ max depth has one level of headroom; nesting one more directory reopens the escape hatch. Either extend the pattern list or consider import/no-restricted-paths (resolves to absolute paths before matching).

Out of scope / observations (not flagged inline)

  • src/server/core/interfaces/executor/i-search-executor.ts imports from src/agent/infra/sandbox/tools-sdk.js. The new ESLint rule deliberately scopes to server/infra / oclif / tui — it does not restrict core → agent/infra. That's consistent with the rule's stated message, so not a regression introduced by this PR, but a candidate for a follow-up rule once a parallel core-agent boundary is desired.

Verification

  • ESLint rule scope: ✅ verified the patterns cover today's max depth (src/server/core/interfaces/<a>/<b>/file.ts../../../infra/ — caught).
  • New imports resolve via src/shared/types/index.js (export * from './channel.js'): ✅ verified.
  • All new interfaces use object args for >3-param methods, readonly properties, I-prefix convention: ✅ verified.
    · branch feat/ENG-2929

@leehpham leehpham merged commit ca71dc8 into proj/multi-agent-collab May 30, 2026
3 checks passed
@leehpham leehpham deleted the feat/ENG-2929 branch May 30, 2026 11:05
ResourceLinkContentBlockSchema,
EmbeddedResourceContentBlockSchema,
])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (non-blocking): This file is touched only for blank-line additions, but the same PR adds a CLAUDE.md rule forbidding milestone labels and open-question numbers in code/doc comments. The module-level doc at the top of this file violates the new rule in three places:

  • line 6: subsystem (M0-1)
  • line 14: (open question Q2)
  • line 18-19: re-introduced in M5+

The TODO(ENG-3034) markers added in curate-prompt-builder.ts and i-curate-executor.ts lead by example for the exception clause; cleaning these three sites in the same PR would do the same for the rule itself. Restate the durable why (e.g. "deferred until cross-machine member shapes are defined") instead of the milestone label.

Fix this →

Comment on lines +38 to +45
/** Stops and evict the driver for a single key. No-op when absent. */
release(args: DriverPoolReleaseArgs): Promise<void>

/** Stops and evict every driver in the pool (daemon shutdown). */
releaseAll(): Promise<void>

/** Stops and evict all drivers belonging to a channel (channel close / archive). */
releaseChannel(channelId: string): Promise<void>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (style): Per the new CLAUDE.md rule, doc comments on functions/methods should start with a third-person singular present-tense verb. These three docs start with a third-person verb but then coordinate with a bare imperative (evict) where the conjugated form (evicts) is needed:

  • line 38: "Stops and evict the driver for a single key.""Stops and evicts the driver for a single key."
  • line 41: "Stops and evict every driver in the pool""Stops and evicts every driver in the pool"
  • line 44: "Stops and evict all drivers belonging to a channel""Stops and evicts all drivers belonging to a channel"
Suggested change
/** Stops and evict the driver for a single key. No-op when absent. */
release(args: DriverPoolReleaseArgs): Promise<void>
/** Stops and evict every driver in the pool (daemon shutdown). */
releaseAll(): Promise<void>
/** Stops and evict all drivers belonging to a channel (channel close / archive). */
releaseChannel(channelId: string): Promise<void>
/** Stops and evicts the driver for a single key. No-op when absent. */
release(args: DriverPoolReleaseArgs): Promise<void>
/** Stops and evicts every driver in the pool (daemon shutdown). */
releaseAll(): Promise<void>
/** Stops and evicts all drivers belonging to a channel (channel close / archive). */
releaseChannel(channelId: string): Promise<void>

Comment on lines +57 to +64
/**
* Dispatches a prompt and stream the turn as it unfolds. Each yielded
* {@link TurnEventPayload} is a base-field-free slice; the orchestrator stamps
* `channelId` / `turnId` / `deliveryId` / `memberHandle` / `seq` / `emittedAt`
* before persisting and broadcasting. The iterator completes when the turn
* reaches a terminal state and may throw to signal a driver-level failure.
*/
prompt(args: AgentDriverPromptArgs): AsyncIterableIterator<TurnEventPayload>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (style): Per the new CLAUDE.md "third-person singular present-tense" rule, the leading verb is conjugated correctly but the coordinated second verb is not:

"Dispatches a prompt and stream the turn as it unfolds."

Should be "streams" to match "dispatches".

Suggested change
/**
* Dispatches a prompt and stream the turn as it unfolds. Each yielded
* {@link TurnEventPayload} is a base-field-free slice; the orchestrator stamps
* `channelId` / `turnId` / `deliveryId` / `memberHandle` / `seq` / `emittedAt`
* before persisting and broadcasting. The iterator completes when the turn
* reaches a terminal state and may throw to signal a driver-level failure.
*/
prompt(args: AgentDriverPromptArgs): AsyncIterableIterator<TurnEventPayload>
/**
* Dispatches a prompt and streams the turn as it unfolds. Each yielded
* {@link TurnEventPayload} is a base-field-free slice; the orchestrator stamps
* `channelId` / `turnId` / `deliveryId` / `memberHandle` / `seq` / `emittedAt`
* before persisting and broadcasting. The iterator completes when the turn
* reaches a terminal state and may throw to signal a driver-level failure.
*/
prompt(args: AgentDriverPromptArgs): AsyncIterableIterator<TurnEventPayload>

/** Current lifecycle status. */
readonly status: AgentDriverStatus

/** Tears the session down and release resources. Idempotent. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (style): Same coordinated-verb mismatch as on prompt:

"Tears the session down and release resources."

Should be "releases" to match "tears".

Suggested change
/** Tears the session down and release resources. Idempotent. */
/** Tears the session down and releases resources. Idempotent. */

Comment on lines +3 to +19
/**
* Payload-only `TurnEvent`: a variant's fields WITHOUT the base coordination
* metadata (`channelId`, `turnId`, `deliveryId`, `memberHandle`, `emittedAt`,
* `seq`). A driver is oblivious to channel-side coordinates — the orchestrator
* stamps the base fields (including the gap-free monotonic `seq`) as it relays
* each payload into the transcript, so correct ordering can only be assigned by
* that single writer.
*
* Derived structurally from {@link TurnEvent} via a distributive conditional so
* the two can never drift: adding a new `TurnEvent` variant updates this type
* automatically. The omitted keys MUST mirror the `TurnEvent` base shape.
*/
export type TurnEventPayload = TurnEvent extends infer T
? T extends TurnEvent
? Omit<T, 'channelId' | 'deliveryId' | 'emittedAt' | 'memberHandle' | 'seq' | 'turnId'>
: never
: never
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (robustness, non-blocking): TurnEventPayload is a brilliant derived type via distributive conditional, but the Omit list is a hand-maintained literal mirror of the TurnEventBaseShape keys defined in src/shared/types/channel.ts:241-248. The two cannot drift today, but nothing prevents them from drifting tomorrow — adding a base field to TurnEventBaseShape silently leaks it through every payload yielded by every IAgentDriver, and TypeScript will not flag it.

The doc comment acknowledges this ("The omitted keys MUST mirror the TurnEvent base shape") — that's an invariant a type check could enforce. Consider exporting a TurnEventBaseFields shape (or just the key-union type TurnEventBaseKey = 'channelId' | 'turnId' | ... derived from the schema) from shared/types/channel.ts and using Omit<T, TurnEventBaseKey> here. The cost is one shared key-union; the gain is "MUST mirror" becomes structurally enforced instead of comment-policed.

Not blocking for this PR, but worth a follow-up before there's more than one driver implementing the interface.

*/
export interface IChannelStore {
/** Persists a new member record under a channel. */
addMember(args: ChannelStoreAddMemberArgs): Promise<void>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question (spec gap): This contract is silent on collision semantics. createChannel explicitly states "rejects when channelId already exists" (line 46) and removeMember explicitly states "No-op when absent" (line 58) — both excellent. But addMember does not say what happens if a member with the same handle already exists under that channelId. Three behaviors are all defensible:

  1. Reject (invariant: handles are unique per channel; caller must check)
  2. Overwrite (last-write-wins; idempotent re-invite)
  3. No-op (first-write-wins; idempotent re-invite)

Each implies a different invite/re-invite flow. Picking one and documenting it here is cheaper than discovering the divergence between two infra adapters later.

Same kind of gap on IChannelOrchestrator.cancelTurn (i-channel-orchestrator.ts:39) — what's the behavior when turnId doesn't exist or is already in a terminal state?

*/
export interface ITranscriptStore {
/** Appends one stamped `TurnEvent` to a turn's log. */
appendTurnEvent(args: AppendTurnEventArgs): Promise<void>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question (spec gap): The contract doesn't say what appendTurnEvent does when an event with an already-persisted seq arrives, or when events are appended in non-monotonic seq order. Given the architectural note in i-agent-driver.ts:3-9 that the orchestrator is the sole writer of seq and treats it as gap-free monotonic, two defensible behaviors:

  1. Trust the orchestrator — append as-given; out-of-order is the caller's bug.
  2. Defensive — reject on gap / duplicate / regression so a buggy orchestrator can't poison the transcript.

Either is fine, but stating it here is cheap and pins down what an adapter must implement. Same kind of clarity that createChannel already provides on IChannelStore.

Comment thread eslint.config.mjs
Comment on lines +102 to +106
{
group: ['**/server/infra/**', '../infra/**', '../../infra/**', '../../../infra/**', '../../../../infra/**'],
message:
'core must not import from server/infra. Depend on an interface in core/interfaces and let infra implement it (dependency inversion).',
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit (defensive): The sibling-relative patterns top out at ../../../../infra/** (4 ups). Current max depth in src/server/core/ is interfaces/<a>/<b>/file.ts../../../infra/ to reach src/server/infra/, so today there's one level of headroom. The day someone adds src/server/core/interfaces/channel/subdir/another/file.ts, the sibling-relative escape route reopens: a 5-up relative import like ../../../../../infra/foo.js won't match **/server/infra/** (no literal server/infra/ substring) and won't match ../../../../infra/** (one .. short).

Cheap fixes (either is fine):

  • Add ../../../../../infra/** and ../../../../../../infra/** to the list for headroom.
  • Or, since the rule message says "core must not import from server/infra", prefer a single pattern based on import/no-restricted-paths if available, which resolves the import to an absolute file path before matching — that closes the relative-import escape hatch by construction.

Not blocking — the rule does what it advertises at the present directory depth. Worth a follow-up before the channel subsystem nests deeper.

Comment thread CLAUDE.md
Comment on lines +39 to +40
**Comments / JSDoc**:
- Doc comments for functions and methods start with a third-person singular present-tense verb (`Creates a channel`, `Returns the driver`, `Appends an event`), not the imperative (`Create`, `Return`, `Append`). This applies to method/function descriptions; type, property, and parameter docs are not constrained this way.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

praise: Strongly agree with formalising the "Linear IDs / milestone labels don't belong in code" guidance — these always rot. The carve-out for TODO(ENG-1234) against a tracked follow-up is exactly the right shape: it points at the issue tracker as the source of truth instead of trying to mirror it inside the comment.

The TODO(ENG-3034) markers added in curate-prompt-builder.ts and i-curate-executor.ts model the exception clause nicely; cleaning up the pre-existing M0-1 / Q2 / M5+ violations in src/shared/types/channel.ts (which is touched by this PR) would model the rule clause in the same change — see the comment on that file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant