Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -78,7 +78,7 @@ describe("crew/channel-server — REAL unix-socket inbox transport", () => {
messageId: "m-roundtrip-1",
from: "inbox://engineering-manager/1",
kind: "IntakePing",
body: {issue: "3489", from: "engineering-manager", at: new Date().toISOString()},
body: {issue: 3489, from: "engineering-manager", at: new Date().toISOString()},
at: new Date().toISOString(),
};
const dialer = yield* Dialer;
Expand Down
4 changes: 2 additions & 2 deletions packages/pipeline-crew-mcp/src/crew/channel-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe("crew/channel-server — announce, discover, claim/collision-check (AC
Effect.provide(channelLayers(tracker, "inbox://a", Dialer.layerFromConnect(connect))),
);

const ack = yield* a.peer.send(roleB, "IntakePing", {issue: "3059", from: roleA});
const ack = yield* a.peer.send(roleB, "IntakePing", {issue: 3059, from: roleA});
assert.strictEqual(ack.by, "inbox://b", "acked by B's inbox ⇒ it went straight to B");
const recorded = yield* Ref.get(wakes);
assert.lengthOf(recorded, 1);
Expand Down Expand Up @@ -320,7 +320,7 @@ describe("crew/channel-server — engine pool discovery + per-instance dial (AC
messageId: "m-1",
from: senderAddr,
kind: "IntakePing",
body: {issue: "x"},
body: {issue: 3059},
at: new Date().toISOString(),
});
}).pipe(Effect.provide(channelLayers(tracker, senderAddr, Dialer.layerFromConnect(connect))));
Expand Down
50 changes: 50 additions & 0 deletions packages/pipeline-crew-mcp/src/crew/contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The crew composition of the discoverable channel contract (#3622): each role's sanctioned seams
* (read off the catalog, so a future non-flat topology stays in sync) joined with the generic
* kind→shape map, and the whole thing resolved as the startup invariant.
*/
import {assert, describe, it} from "@effect/vitest";
import {Effect} from "effect";
import {crewMessageKinds} from "../protocol/index.ts";
import {ALL_SEAMS} from "./catalog.ts";
import {resolveChannelContract, roleContract, roleContracts} from "./contract.ts";
import {CREW_ROLES} from "./roles.ts";

describe("crew/contract — the discoverable role↔kind surface (#3622)", () => {
it("resolves each role's seams to the wire kinds they ride, off the catalog", () => {
const contract = roleContract("engineering-manager");
assert.strictEqual(contract.role, "engineering-manager");
const byName = new Map(contract.seams.map((s) => [s.seam, s.kind]));
// the seam→kind mapping a peer resolves: which wire kind each named seam actually carries.
assert.strictEqual(byName.get("intakePing"), "IntakePing");
assert.strictEqual(byName.get("claimCollisionCheck"), "Claim");
assert.strictEqual(byName.get("engineNudge"), "EngineNudge");
});

it("gives every role the full seam set under the flat topology (sourced from the catalog)", () => {
for (const role of CREW_ROLES) {
const seams = roleContract(role).seams.map((s) => s.seam);
assert.deepStrictEqual([...seams], [...ALL_SEAMS]);
}
assert.strictEqual(roleContracts().length, CREW_ROLES.length);
});

it.effect("resolveChannelContract yields the full kind set + every role, or fails at boot", () =>
Effect.gen(function* () {
const contract = yield* resolveChannelContract();
assert.deepStrictEqual(
contract.kinds.map((k) => k.kind).sort(),
[...crewMessageKinds].sort(),
);
assert.deepStrictEqual(
contract.roles.map((r) => r.role),
[...CREW_ROLES],
);
// the footgun fix is visible in the resolved contract: IntakePing.issue is an integer shape.
const ping = contract.kinds.find((k) => k.kind === "IntakePing");
const issue = (ping?.payload.schema as {properties?: {issue?: {type?: string}}}).properties
?.issue;
assert.strictEqual(issue?.type, "integer");
}),
);
});
56 changes: 56 additions & 0 deletions packages/pipeline-crew-mcp/src/crew/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* crew/contract — the crew composition of the discoverable channel contract (#3622): the generic
* kind→shape map (`../protocol/describe`) joined with each role's sanctioned seams (`./catalog`),
* so a peer can resolve BOTH "what does kind K look like" and "which kinds may role R send/receive"
* from one surface, ahead of any send.
*
* The role half is read straight off `seamsFor` / `CrewSeams`, so a future non-flat topology (a role
* mapped to a seam subset instead of `ALL_SEAMS`) flows through automatically — never a hand-kept
* second list. The kind half is `resolveKindContracts`, which fails loud if the shared kind set is
* not fully resolvable (the startup invariant); this module threads that failure up so a session
* refuses to serve an unresolvable contract.
*/
import {Effect} from "effect";
import {
type ChannelContractError,
type KindContract,
resolveKindContracts,
} from "../protocol/index.ts";
import {type CrewSeamName, CrewSeams, seamsFor} from "./catalog.ts";
import {CREW_ROLES, type CrewRole} from "./roles.ts";

/** One seam a role is a party to, resolved to the wire kind it rides. */
export interface SeamContract {
readonly seam: CrewSeamName;
readonly kind: string;
}

/** A role's sanctioned send/receive seams, sourced from the catalog (so a non-flat topology stays in sync). */
export interface RoleContract {
readonly role: CrewRole;
readonly seams: ReadonlyArray<SeamContract>;
}

/** The full discoverable channel contract: the shared kind→shape map + each role's sanctioned seams. */
export interface ChannelContract {
readonly kinds: ReadonlyArray<KindContract>;
readonly roles: ReadonlyArray<RoleContract>;
}

/** The seams `role` is a party to, each resolved to the wire kind it rides (via `CrewSeams`). */
export const roleContract = (role: CrewRole): RoleContract => ({
role,
seams: seamsFor(role).map((seam) => ({seam, kind: CrewSeams[seam]._tag})),
});

/** Every standing role's seam contract — the role half of the discoverable surface, straight off the catalog. */
export const roleContracts = (): ReadonlyArray<RoleContract> => CREW_ROLES.map(roleContract);

/**
* Resolve the full discoverable channel contract, or fail loud — the startup invariant (#3622). The
* kind→shape map must resolve for EVERY shared kind before a session serves it; each role's seams are
* read off the catalog. A session wires this on its boot critical path, so an unresolvable kind set
* aborts the build rather than surfacing as a gap at first send.
*/
export const resolveChannelContract = (): Effect.Effect<ChannelContract, ChannelContractError> =>
resolveKindContracts().pipe(Effect.map((kinds) => ({kinds, roles: roleContracts()})));
8 changes: 8 additions & 0 deletions packages/pipeline-crew-mcp/src/crew/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export {
inboxSocketPathFor,
makeCrewChannel,
} from "./channel-server.ts";
export {
type ChannelContract,
type RoleContract,
resolveChannelContract,
roleContract,
roleContracts,
type SeamContract,
} from "./contract.ts";
export {RoleUniquenessError} from "./errors.ts";
export {
type CardinalityOf,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("crew/session — forked-stdio live-wake round-trip (session.ts:34 seam
messageId: "m-stdio-1",
from: "inbox://engineering-manager/1",
kind: "IntakePing",
body: {issue: "3489", from: "engineering-manager", at: new Date().toISOString()},
body: {issue: 3489, from: "engineering-manager", at: new Date().toISOString()},
at: new Date().toISOString(),
})
.pipe(Effect.timeout("6 seconds"));
Expand Down
2 changes: 1 addition & 1 deletion packages/pipeline-crew-mcp/src/crew/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe("crew/session — cutover: the ChannelSend-from-peer binding round-trip
// The sender's real ChannelSend-from-peer binding (the cutover) — the port channel_send uses.
const ack = yield* Effect.gen(function* () {
const sender = yield* ChannelSend;
return yield* sender.send(receiverRole, "IntakePing", {issue: "3062", from: senderRole});
return yield* sender.send(receiverRole, "IntakePing", {issue: 3062, from: senderRole});
}).pipe(
Effect.provide(
channelSendFromPeer(senderRole, senderAddress).pipe(
Expand Down
61 changes: 37 additions & 24 deletions packages/pipeline-crew-mcp/src/crew/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ import {Effect, type FileSystem, Layer} from "effect";
import {type McpSchema, McpServer} from "effect/unstable/ai";
import {
ChannelClaim,
ChannelDescribe,
ChannelSend,
ChannelSink,
ChannelToolkit,
ClaimToolkit,
channelExperimentalCapability,
channelToolHandlers,
claimToolHandlers,
KindsToolkit,
kindsToolHandlers,
} from "../edge/index.ts";
import {type Dialer, Inbox, type Tracker} from "../peer/index.ts";
import {socketPathFor} from "../tracker/index.ts";
Expand All @@ -56,6 +59,7 @@ import {
inboxSocketPathFor,
makeCrewChannel,
} from "./channel-server.ts";
import {resolveChannelContract} from "./contract.ts";
import type {RoleUniquenessError} from "./errors.ts";
import {crewHeartbeatLayer} from "./heartbeat.ts";
import {type CrewRole, kindOf} from "./roles.ts";
Expand Down Expand Up @@ -198,35 +202,44 @@ export const assembleCrewSession = <RIn, RSub = never>(
// Claim FIRST (Race 2): the slow tracker-claim/presence handshake runs in the unwrap's effect,
// BEFORE the run-loop-forking transport is built, so ChannelSend below is a zero-async binding.
Layer.unwrap(
makeCrewChannel({role: config.role, address}).pipe(
Effect.map((channel) => {
// outbound: the channel_send toolkit, its ChannelSend an INSTANT bind to the already-resolved
// peer — the toolkit registers with no socket await in its build path (Race 2).
const outbound = McpServer.toolkit(ChannelToolkit).pipe(
Layer.provide(channelToolHandlers),
Layer.provide(Layer.succeed(ChannelSend, {send: channel.peer.send})),
);
// deconfliction: the channel_claim toolkit (#3509), its ChannelClaim an INSTANT bind to the
// already-resolved channel's tracker claim — same zero-async binding as `outbound` (Race 2).
const claim = McpServer.toolkit(ClaimToolkit).pipe(
Layer.provide(claimToolHandlers),
Layer.provide(Layer.succeed(ChannelClaim, {claim: channel.claim})),
);
// inbound: the peer-inbox socket server, its deliveries waking THIS served server.
const inbound = inboxServerSocketLayer(address).pipe(
Layer.provide(ChannelSink.layerFromMcpServer),
);
// Provide the transport ONCE to the MERGED registrations — the single-instance memo (Race 1).
return Layer.mergeAll(outbound, claim, inbound).pipe(Layer.provide(transport));
}),
),
Effect.gen(function* () {
const channel = yield* makeCrewChannel({role: config.role, address});
// Startup invariant (#3622): resolve the full discoverable channel contract BEFORE serving.
// A shared kind set that can't be fully resolved to a shape fails the build HERE (on the boot
// critical path, like the claim), so a peer never discovers a gap at first send.
const contract = yield* resolveChannelContract();
// outbound: the channel_send toolkit, its ChannelSend an INSTANT bind to the already-resolved
// peer — the toolkit registers with no socket await in its build path (Race 2).
const outbound = McpServer.toolkit(ChannelToolkit).pipe(
Layer.provide(channelToolHandlers),
Layer.provide(Layer.succeed(ChannelSend, {send: channel.peer.send})),
);
// deconfliction: the channel_claim toolkit (#3509), its ChannelClaim an INSTANT bind to the
// already-resolved channel's tracker claim — same zero-async binding as `outbound` (Race 2).
const claim = McpServer.toolkit(ClaimToolkit).pipe(
Layer.provide(claimToolHandlers),
Layer.provide(Layer.succeed(ChannelClaim, {claim: channel.claim})),
);
// discovery: the channel_kinds toolkit (#3622), its ChannelDescribe an INSTANT bind to the
// contract resolved just above — a static value, so it never re-derives the catalog nor awaits.
const kinds = McpServer.toolkit(KindsToolkit).pipe(
Layer.provide(kindsToolHandlers),
Layer.provide(Layer.succeed(ChannelDescribe, {view: contract})),
);
// inbound: the peer-inbox socket server, its deliveries waking THIS served server.
const inbound = inboxServerSocketLayer(address).pipe(
Layer.provide(ChannelSink.layerFromMcpServer),
);
// Provide the transport ONCE to the MERGED registrations — the single-instance memo (Race 1).
return Layer.mergeAll(outbound, claim, kinds, inbound).pipe(Layer.provide(transport));
}),
).pipe(Layer.provide(substrate));

/**
* The full runnable session layer: launch it (`Layer.launch`) to run one live crew session. The one
* stdio `McpServer` is provided to the merged registrations exactly once (`assembleCrewSession`), so
* the served server advertises the `channel_send` + `channel_claim` tools + the `claude/channel`
* capability; the heartbeat rides the same `substrate` the outbound binding announces on.
* the served server advertises the `channel_send` + `channel_claim` + `channel_kinds` tools + the
* `claude/channel` capability; the heartbeat rides the same `substrate` the outbound binding announces on.
*/
export const crewSessionLayer = (config: CrewSessionConfig) => {
// One per-session instance id, resolved once here and threaded to every address derivation, so an
Expand Down
4 changes: 2 additions & 2 deletions packages/pipeline-crew-mcp/src/edge/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const envelope = (over?: Partial<InboxEnvelope>): InboxEnvelope => ({
messageId: "msg-1",
from: "peer-a",
kind: "IntakePing",
body: {issue: "3057"},
body: {issue: 3057},
at: "2026-07-16T10:00:00Z",
...over,
});
Expand Down Expand Up @@ -47,6 +47,6 @@ describe("edge/bridge — inbound peer-inbox message wakes the session (AC2)", (
it("formatChannelTag renders sender + kind as attributes and the body as content", () => {
const tag = formatChannelTag(envelope());
assert.match(tag, /^<channel from="peer-a" kind="IntakePing">/);
assert.include(tag, '{"issue":"3057"}');
assert.include(tag, '{"issue":3057}');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ const makeCapturingClient = Effect.gen(function* () {

describe("defect(a): channel_send normalizes body to a struct at the boundary (#3491)", () => {
it("a STRUCT body renders single-encoded through formatChannelTag (baseline)", () => {
const tag = formatChannelTag(baseEnvelope({issue: "3486", from: "engineering-manager"}));
const tag = formatChannelTag(baseEnvelope({issue: 3486, from: "engineering-manager"}));
const inner = tag.slice(tag.indexOf(">") + 1, tag.lastIndexOf("<"));
assert.strictEqual(inner, '{"issue":"3486","from":"engineering-manager"}');
assert.strictEqual(inner, '{"issue":3486,"from":"engineering-manager"}');
});

// The fix: even when the wire delivers `body` as a JSON STRING, the handler forwards the decoded
Expand All @@ -105,7 +105,7 @@ describe("defect(a): channel_send normalizes body to a struct at the boundary (#
targetRole: "reviewer",
kind: "IntakePing",
body: JSON.stringify({
issue: "3486",
issue: 3486,
from: "engineering-manager",
at: "2026-07-18T00:00:00Z",
}),
Expand All @@ -119,7 +119,7 @@ describe("defect(a): channel_send normalizes body to a struct at the boundary (#
"object",
"handler must forward the DECODED struct, not the raw JSON string",
);
assert.deepInclude(forwarded as object, {issue: "3486", from: "engineering-manager"});
assert.deepInclude(forwarded as object, {issue: 3486, from: "engineering-manager"});

const inner = ((tag) => tag.slice(tag.indexOf(">") + 1, tag.lastIndexOf("<")))(
formatChannelTag(baseEnvelope(forwarded)),
Expand All @@ -131,7 +131,7 @@ describe("defect(a): channel_send normalizes body to a struct at the boundary (#
);
assert.strictEqual(
inner,
'{"issue":"3486","from":"engineering-manager","at":"2026-07-18T00:00:00Z"}',
'{"issue":3486,"from":"engineering-manager","at":"2026-07-18T00:00:00Z"}',
);
}),
);
Expand Down
7 changes: 7 additions & 0 deletions packages/pipeline-crew-mcp/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export {
ClaimToolkit,
claimToolHandlers,
} from "./claim-tool.ts";
export {
ChannelContractView,
ChannelDescribe,
DescribeChannelKinds,
KindsToolkit,
kindsToolHandlers,
} from "./kinds-tool.ts";
export {
CHANNEL_CAPABILITY,
CHANNEL_NOTIFICATION_METHOD,
Expand Down
Loading
Loading