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
4 changes: 2 additions & 2 deletions FORK.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion apps/server/src/j5/a2a/AgentCrewProposalService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ThreadId } from "@t3tools/contracts";
import { ModelSelection, RuntimeMode, ThreadId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -20,6 +20,8 @@ export const CrewProposalSeat = Schema.Struct({
agentId: Schema.NullOr(bounded(CREW_NAME_MAX_CHARS)),
reason: bounded(CREW_REASON_MAX_CHARS),
instructions: Schema.optional(bounded(CREW_TEXT_MAX_CHARS)),
modelSelection: Schema.optional(ModelSelection),
runtimeMode: Schema.optional(RuntimeMode),
});
export type CrewProposalSeat = typeof CrewProposalSeat.Type;

Expand Down
751 changes: 734 additions & 17 deletions apps/server/src/j5/a2a/CrewLaunchService.test.ts

Large diffs are not rendered by default.

328 changes: 243 additions & 85 deletions apps/server/src/j5/a2a/CrewLaunchService.ts

Large diffs are not rendered by default.

259 changes: 249 additions & 10 deletions apps/server/src/j5/a2a/CrewProposalService.test.ts

Large diffs are not rendered by default.

144 changes: 111 additions & 33 deletions apps/server/src/j5/a2a/CrewProposalService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type {
CrewProposalPreviewRequest,
CrewProposalPreviewResponse,
} from "@t3tools/contracts/j5";
import { crewApprovalToken } from "./crewRuntimePreview.ts";
import { MessageId, type ThreadId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Data from "effect/Data";
Expand All @@ -19,6 +24,7 @@ import {
CrewLaunchService,
type CrewCaptain,
type CrewLaunchError,
type ResolvedCrewLaunchSeat,
} from "./CrewLaunchService.ts";
import { crewDeclinedNoticeText } from "./crewGateNotice.ts";
import { CrewLaunchReporter } from "./CrewLaunchReporter.ts";
Expand Down Expand Up @@ -95,6 +101,7 @@ export interface RequestCrewMemberInput {
export interface ResolveCrewProposalInput {
readonly proposalId: string;
readonly decision: "approve" | "decline";
readonly approvalToken?: string | undefined;
/** The human's final roster on approval; the requested seats when omitted. */
readonly seats?: ReadonlyArray<CrewProposalSeat> | undefined;
}
Expand All @@ -105,6 +112,9 @@ export interface CrewProposalOutcome {
}

export interface CrewProposalServiceShape {
readonly preview: (
input: CrewProposalPreviewRequest,
) => Effect.Effect<CrewProposalPreviewResponse, CrewProposalError>;
readonly propose: (
input: ProposeCrewInput,
) => Effect.Effect<CrewProposalOutcome, CrewProposalError>;
Expand Down Expand Up @@ -151,6 +161,7 @@ export const layer = Layer.effect(
const validateSeats = Effect.fn("j5.a2a.crewProposal.validateSeats")(function* (
seats: ReadonlyArray<CrewProposalSeat>,
existing: { readonly seatNames: ReadonlyArray<string>; readonly pendingSeats: number },
humanReview = false,
) {
const existingSeatCount = existing.seatNames.length + existing.pendingSeats;
if (seats.length === 0)
Expand All @@ -161,6 +172,16 @@ export const layer = Layer.effect(
// The MCP schema already bounds a Captain's seats; the human's card submits the same shape
// through HTTP, so every door meets the rule here rather than surfacing a storage error.
for (const seat of seats) {
if (
!humanReview &&
seat.agentId !== null &&
(seat.modelSelection !== undefined || seat.runtimeMode !== undefined)
)
return yield* new CrewProposalRequestError({
detail: `Seat ${seat.seat} is a saved persona; only the human can override its runtime.`,
nextStep:
"Propose the persona with its defaults; the human may edit its runtime before approval.",
});
const problem = crewSeatShapeProblem(seat);
if (problem !== null)
return yield* new CrewProposalRequestError({
Expand Down Expand Up @@ -252,12 +273,15 @@ export const layer = Layer.effect(
proposal: CrewProposal,
captain: CrewCaptain,
seats: ReadonlyArray<CrewProposalSeat>,
resolvedSeats: ReadonlyArray<ResolvedCrewLaunchSeat>,
) {
const launchSeats = seats.map((seat) => ({
name: seat.seat,
agentId: seat.agentId,
reason: seat.reason,
instructions: seat.instructions,
modelSelection: seat.modelSelection,
runtimeMode: seat.runtimeMode,
}));
if (proposal.kind === "roster") {
return yield* launcher.launch({
Expand All @@ -266,6 +290,7 @@ export const layer = Layer.effect(
captain,
displayName: proposal.displayName,
seats: launchSeats,
resolvedSeats,
brief: proposal.brief,
onRecorded: (instance) =>
proposals.attachInstance(proposal.id, instance.id).pipe(Effect.ignore),
Expand All @@ -288,6 +313,7 @@ export const layer = Layer.effect(
captain,
instance,
seats: launchSeats,
resolvedSeats,
brief: proposal.brief,
});
});
Expand Down Expand Up @@ -345,8 +371,9 @@ export const layer = Layer.effect(
claimed: CrewProposal,
captain: CrewCaptain,
seats: ReadonlyArray<CrewProposalSeat>,
resolvedSeats: ReadonlyArray<ResolvedCrewLaunchSeat>,
) {
const instance = yield* fulfil(claimed, captain, seats).pipe(
const instance = yield* fulfil(claimed, captain, seats, resolvedSeats).pipe(
// A failed spawn hands the gate back to the human rather than recording a phantom crew;
// onError also fires on a defect, so an unexpected failure cannot leave it claimed.
Effect.onError(() => proposals.reopen(claimed.id).pipe(Effect.ignore)),
Expand Down Expand Up @@ -550,6 +577,80 @@ export const layer = Layer.effect(
return { proposal: declined, instance: null } satisfies CrewProposalOutcome;
});

const resolveRuntime = (captain: CrewCaptain, seats: ReadonlyArray<CrewProposalSeat>) =>
launcher.resolveSeats(
captain,
seats.map((seat) => ({
name: seat.seat,
agentId: seat.agentId,
reason: seat.reason,
instructions: seat.instructions,
modelSelection: seat.modelSelection,
runtimeMode: seat.runtimeMode,
})),
);

/** Preview and approval consult the same live roster, excluding this proposal's retry reservations. */
const validateProposalSeats = Effect.fn("j5.a2a.crewProposal.validateProposalSeats")(function* (
proposal: CrewProposal,
seats: ReadonlyArray<CrewProposalSeat>,
) {
// The human may have renamed or added seats on the card; check them against the live
// roster, or an approved duplicate would spawn a seat the snapshot cannot record.
const existingCrew =
proposal.crewInstanceId === null
? null
: yield* crews
.read(proposal.crewInstanceId)
.pipe(Effect.mapError(operationError("reading the crew")));
// A request left open past archive_crew must not seat agents into a retired Crew.
if (proposal.kind === "addition" && existingCrew !== null && existingCrew.archivedAt !== null)
return yield* new CrewProposalRequestError({
detail: `Proposal ${proposal.id} adds to crew ${existingCrew.id}, which has been retired.`,
nextStep: "Decline this proposal.",
});
const existingMembers = existingCrew?.members ?? [];
// An addition reserves its member rows before the seats spawn. When that spawn failed
// and the gate was handed back, the rows are still there under this proposal's own
// deterministic ids; they are the reservation the retry converges on, not a clash
// (Critic Q2, 2026-09-14).
const reservedHere = reservedByProposal(proposal.id);
yield* validateSeats(
seats,
{
seatNames: existingMembers
.filter((member) => !reservedHere(member))
.map(({ seatName }) => seatName),
pendingSeats: 0,
},
true,
);
});

const preview: CrewProposalServiceShape["preview"] = Effect.fn("j5.a2a.crewProposal.preview")(
function* (input) {
const proposal = yield* proposals
.read(input.proposalId)
.pipe(Effect.mapError(operationError("reading the proposal")));
if (proposal === null)
return yield* new CrewProposalNotFoundError({ proposalId: input.proposalId });
if (proposal.status !== "open")
return yield* new CrewProposalNotOpenError({
proposalId: proposal.id,
status: proposal.status,
});
const seats = input.seats ?? proposal.approvedSeats ?? proposal.requestedSeats;
yield* validateProposalSeats(proposal, seats);
const captain = yield* captainFor(proposal);
const resolved = yield* resolveRuntime(captain, seats);
return {
proposalId: proposal.id,
approvalToken: crewApprovalToken(proposal, captain, resolved),
seats: resolved.map((entry) => entry.runtime),
};
},
);

const resolve: CrewProposalServiceShape["resolve"] = (input) =>
Effect.gen(function* () {
const proposal = yield* proposals
Expand All @@ -574,40 +675,17 @@ export const layer = Layer.effect(
const declined = yield* complete(claimed, "decline", null);
return { proposal: declined, instance: null } satisfies CrewProposalOutcome;
}
const seats = input.seats ?? proposal.requestedSeats;
// The human may have renamed or added seats on the card; check them against the live
// roster, or an approved duplicate would spawn a seat the snapshot cannot record.
const existingCrew =
proposal.crewInstanceId === null
? null
: yield* crews
.read(proposal.crewInstanceId)
.pipe(Effect.mapError(operationError("reading the crew")));
// A request left open past archive_crew must not seat agents into a retired Crew.
if (
proposal.kind === "addition" &&
existingCrew !== null &&
existingCrew.archivedAt !== null
)
const seats = input.seats ?? proposal.approvedSeats ?? proposal.requestedSeats;
yield* validateProposalSeats(proposal, seats);
const captain = yield* captainFor(proposal);
const resolved = yield* resolveRuntime(captain, seats);
if (input.approvalToken !== crewApprovalToken(proposal, captain, resolved))
return yield* new CrewProposalRequestError({
detail: `Proposal ${proposal.id} adds to crew ${existingCrew.id}, which has been retired.`,
nextStep: "Decline this proposal.",
detail: "The crew runtime preview is missing or has changed since it was shown.",
nextStep: "Refresh the preview and review the current settings before approving.",
});
const existingMembers = existingCrew?.members ?? [];
// An addition reserves its member rows before the seats spawn. When that spawn failed
// and the gate was handed back, the rows are still there under this proposal's own
// deterministic ids; they are the reservation the retry converges on, not a clash
// (Critic Q2, 2026-09-14).
const reservedHere = reservedByProposal(proposal.id);
yield* validateSeats(seats, {
seatNames: existingMembers
.filter((member) => !reservedHere(member))
.map(({ seatName }) => seatName),
pendingSeats: 0,
});
const captain = yield* captainFor(proposal);
const claimed = yield* claim(proposal, "approve", seats);
return yield* settle(claimed, captain, seats);
return yield* settle(claimed, captain, seats, resolved);
});

const reconcile: CrewProposalServiceShape["reconcile"] = Effect.gen(function* () {
Expand Down Expand Up @@ -645,7 +723,7 @@ export const layer = Layer.effect(
Effect.logError("J5 crew proposal sweep failed", { cause }).pipe(Effect.as([])),
),
);
return CrewProposalService.of({ propose, requestMember, resolve, reconcile });
return CrewProposalService.of({ propose, requestMember, preview, resolve, reconcile });
}),
);

Expand Down
86 changes: 79 additions & 7 deletions apps/server/src/j5/a2a/CrewProposalsHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
AuthSessionId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
Expand Down Expand Up @@ -32,7 +33,24 @@ const proposal: CrewProposal = {
reportedAt: null,
};

const paths = { list: "/raw/crews/proposals", resolve: "/raw/crews/proposals/resolve" } as const;
const customSeat = {
seat: "critic",
agentId: null,
reason: "Human added review",
instructions: "Review the proposed fix.",
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-fable-5-1",
options: [{ id: "effort", value: "high" }],
},
runtimeMode: "approval-required" as const,
};

const paths = {
list: "/raw/crews/proposals",
resolve: "/raw/crews/proposals/resolve",
preview: "/raw/crews/proposals/preview",
} as const;

const authWith = (scopes: ReadonlyArray<string>) =>
Layer.mock(EnvironmentAuth.EnvironmentAuth)({
Expand All @@ -46,8 +64,33 @@ const authWith = (scopes: ReadonlyArray<string>) =>
});

it("lists open proposals for readers and resolves them only for operators", async () => {
const resolved: Array<{ proposalId: string; decision: string; seats?: unknown }> = [];
const resolved: Array<{
proposalId: string;
decision: string;
seats?: unknown;
approvalToken?: string | undefined;
}> = [];
const previewed: unknown[] = [];
const gate = Layer.mock(CrewProposalService)({
preview: (input) => {
previewed.push(input);
return Effect.succeed({
proposalId: input.proposalId,
approvalToken: "runtime-token",
seats: [
{
seat: "critic",
provider: "Anthropic",
harness: "Claude Code",
model: "Claude Fable 5.1",
reasoning: "High",
access: "Approval required",
modelSelection: customSeat.modelSelection,
runtimeMode: customSeat.runtimeMode,
},
],
});
},
resolve: (input) => {
resolved.push(input);
return input.proposalId === proposal.id
Expand Down Expand Up @@ -93,26 +136,55 @@ it("lists open proposals for readers and resolves them only for operators", asyn
assert.equal(body.proposals[0]?.id, proposal.id);
assert.lengthOf(body.proposals[0]?.requestedSeats ?? [], 1);

const preview = await reader.handler(
new Request(`http://environment.test${paths.preview}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ proposalId: proposal.id, seats: [customSeat] }),
}),
);
assert.equal(preview.status, 200);
assert.deepStrictEqual(previewed, [{ proposalId: proposal.id, seats: [customSeat] }]);
assert.deepStrictEqual(await preview.json(), {
proposalId: proposal.id,
approvalToken: "runtime-token",
seats: [
{
seat: "critic",
provider: "Anthropic",
harness: "Claude Code",
model: "Claude Fable 5.1",
reasoning: "High",
access: "Approval required",
modelSelection: customSeat.modelSelection,
runtimeMode: customSeat.runtimeMode,
},
],
});

const approved = await operator.handler(
new Request(`http://environment.test${paths.resolve}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
proposalId: proposal.id,
decision: "approve",
seats: [
...proposal.requestedSeats,
{ seat: "critic", agentId: "critic", reason: "Human added review" },
],
approvalToken: "runtime-token",
seats: [...proposal.requestedSeats, customSeat],
}),
}),
);
assert.equal(approved.status, 200);
assert.equal(resolved[0]?.approvalToken, "runtime-token");
assert.deepStrictEqual(resolved[0]?.seats, [...proposal.requestedSeats, customSeat]);
const approvedBody = (await approved.json()) as {
proposal: { status: string; approvedSeats: unknown[] };
};
assert.equal(approvedBody.proposal.status, "approved");
assert.lengthOf(approvedBody.proposal.approvedSeats, 2);
assert.deepStrictEqual(approvedBody.proposal.approvedSeats, [
...proposal.requestedSeats,
customSeat,
]);

const stale = await operator.handler(
new Request(`http://environment.test${paths.resolve}`, {
Expand Down
Loading
Loading