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
176 changes: 176 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewProposalService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { assert, it } from "@effect/vitest";
import { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import { AgentCrewInstanceService, layer as crewLayer } from "./AgentCrewInstanceService.ts";
import {
AgentCrewProposalService,
layer as proposalLayer,
type CreateCrewProposalInput,
} from "./AgentCrewProposalService.ts";
import { crewSeatThreadId } from "./crewSeatIds.ts";
import { participantIdForThread } from "./HomeRegistrar.ts";
import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts";
import { runJ5A2AMigrations } from "./Migrations.ts";
import { ParticipantId, SquadronId } from "./contracts.ts";

const database = NodeSqliteClient.layerMemory();
const testLayer = Layer.mergeAll(
database,
ledgerLayer.pipe(Layer.provide(database)),
crewLayer.pipe(Layer.provide(database)),
proposalLayer.pipe(Layer.provide(database)),
);
const createdAt = "2026-09-22T09:00:00.000Z";
const squadronId = SquadronId.make("squadron:crew-proposals");
const captain = ParticipantId.make("agent:j5:a2a:captain-proposals");
const captainThreadId = ThreadId.make("thread:captain-proposals");

const seat = (name: string) => ({ seat: name, agentId: "scout", reason: "Holds a seat" });

/** A roster row minted by a launch: its identity is its own, not derived from any proposal. */
const rosterMember = (crewId: string, name: string) => ({
seatName: name,
agentId: "scout",
participantId: ParticipantId.make(`agent:j5:a2a:thread:${crewId}:${name}`),
threadId: ThreadId.make(`thread:${crewId}:${name}`),
reason: null,
});

/** The row an addition reserves for one of its seats, under the ids the launcher derives. */
const reservedMember = (proposalId: string, name: string) => {
const threadId = crewSeatThreadId(proposalId, name);
return {
seatName: name,
agentId: "scout",
participantId: participantIdForThread(threadId),
threadId,
reason: null,
};
};

const addition = (
id: string,
crewInstanceId: string,
seats: ReadonlyArray<string>,
): CreateCrewProposalInput => ({
id,
squadronId,
captainParticipantId: captain,
captainThreadId,
crewInstanceId,
kind: "addition",
brief: "Join in.",
displayName: "Counted",
requestedSeats: seats.map(seat),
createdAt,
});

it.effect(
"admits against rows and open requests, and files one of two racing for the last seat",
() =>
Effect.gen(function* () {
yield* runJ5A2AMigrations();
yield* (yield* A2ALedger).createSquadron({
squadron: { id: squadronId, name: "Proposal Squadron", createdAt },
});
const crews = yield* AgentCrewInstanceService;
const proposals = yield* AgentCrewProposalService;
const crewId = "crew:counted";
yield* crews.record({
id: crewId,
squadronId,
captainParticipantId: captain,
captainThreadId,
displayName: "Counted",
brief: "Join in.",
createdAt,
members: Array.from({ length: 9 }, (_, index) => rosterMember(crewId, `s${index}`)),
});

// Nine rows and P's two open seats leave room for one more; a resolved request holds nothing.
const p = yield* proposals.admit(addition("proposal:p", crewId, ["p", "p-two"]), {
maxSeats: 12,
});
assert.equal(p.status, "created");
const declined = yield* proposals.admit(addition("proposal:d", crewId, ["d"]), {
maxSeats: 12,
});
assert.equal(declined.status, "created");
const claimed = yield* proposals.claim({
id: "proposal:d",
decision: "decline",
approvedSeats: null,
});
assert.isNotNull(claimed);
assert.isNotNull(
yield* proposals.complete({
id: "proposal:d",
decision: "decline",
crewInstanceId: crewId,
resolvedAt: createdAt,
}),
);

// Two requests racing for that last seat: one is filed, one refused and never written.
const a = addition("proposal:a", crewId, ["a"]);
const b = addition("proposal:b", crewId, ["b"]);
const [left, right] = yield* Effect.all(
[proposals.admit(a, { maxSeats: 12 }), proposals.admit(b, { maxSeats: 12 })],
{ concurrency: "unbounded" },
);
assert.sameMembers([left.status, right.status], ["created", "cap-exceeded"]);
const [winner, loser] = left.status === "created" ? [a, b] : [b, a];
assert.deepStrictEqual(left.status === "created" ? right : left, {
status: "cap-exceeded",
held: 12,
adding: 1,
});
assert.isNull(yield* proposals.read(loser.id));

// The winner's replay finds its row before anything is counted, though the Crew is full.
const replay = yield* proposals.admit(winner, { maxSeats: 12 });
assert.equal(replay.status, "existing");
if (replay.status === "existing") assert.equal(replay.proposal.id, winner.id);
assert.equal((yield* proposals.admit(loser, { maxSeats: 12 })).status, "cap-exceeded");
}).pipe(Effect.provide(testLayer)),
);

it.effect("an open addition whose failed launch left its reserved row is counted once", () =>
Effect.gen(function* () {
yield* runJ5A2AMigrations();
yield* (yield* A2ALedger).createSquadron({
squadron: { id: squadronId, name: "Proposal Squadron", createdAt },
});
const crews = yield* AgentCrewInstanceService;
const proposals = yield* AgentCrewProposalService;
const crewId = "crew:reserved";
yield* crews.record({
id: crewId,
squadronId,
captainParticipantId: captain,
captainThreadId,
displayName: "Reserved",
brief: "Join in.",
createdAt,
members: Array.from({ length: 10 }, (_, index) => rosterMember(crewId, `s${index}`)),
});
// P is open again after its launch failed, with its one reserved row still on the Crew.
const p = yield* proposals.admit(addition("proposal:p", crewId, ["p"]), { maxSeats: 12 });
assert.equal(p.status, "created");
assert.equal(
(yield* crews.addMembers(crewId, [reservedMember("proposal:p", "p")])).status,
"added",
);

// Eleven held, not twelve: Q takes the last seat and R is refused.
const q = yield* proposals.admit(addition("proposal:q", crewId, ["q"]), { maxSeats: 12 });
assert.equal(q.status, "created");
assert.deepStrictEqual(
yield* proposals.admit(addition("proposal:r", crewId, ["r"]), { maxSeats: 12 }),
{ status: "cap-exceeded", held: 12, adding: 1 },
);
}).pipe(Effect.provide(testLayer)),
);
64 changes: 64 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewProposalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
import type { SqlError } from "effect/unstable/sql/SqlError";

import { CREW_NAME_MAX_CHARS, CREW_REASON_MAX_CHARS, CREW_TEXT_MAX_CHARS } from "./crewLimits.ts";
import { crewSeatReservedBy } from "./crewSeatIds.ts";
import { ParticipantId, SquadronId } from "./contracts.ts";

// The same bounds as the MCP verbs: the human's card edits arrive over HTTP and must not be the
Expand Down Expand Up @@ -82,9 +83,26 @@ export interface CreateCrewProposalInput {
readonly createdAt: string;
}

export type AdmitCrewProposalOutcome =
| { readonly status: "created"; readonly proposal: CrewProposal }
/** A request with this id already exists; returned before any counting. */
| { readonly status: "existing"; readonly proposal: CrewProposal }
/** Wrote nothing: the seats would pass the cap counting rows and open requests. */
| { readonly status: "cap-exceeded"; readonly held: number; readonly adding: number };

export interface AgentCrewProposalServiceShape {
/** Idempotent by proposal id; a retried tool call finds its earlier proposal. */
readonly create: (input: CreateCrewProposalInput) => Effect.Effect<CrewProposal, SqlError>;
/**
* Files an addition only if the Crew can seat it: its member rows plus the unreserved seats of
* every open request, plus this one, must fit under `maxSeats`. The existence check (a replay finds its row
* before anything is counted), the count, and the insert run in one transaction, so two
* requests racing for the last seat cannot both be filed.
*/
readonly admit: (
input: CreateCrewProposalInput,
options: { readonly maxSeats: number },
) => Effect.Effect<AdmitCrewProposalOutcome, SqlError>;
readonly read: (id: string) => Effect.Effect<CrewProposal | null, SqlError>;
readonly listOpen: () => Effect.Effect<ReadonlyArray<CrewProposal>, SqlError>;
readonly listForCaptain: (
Expand Down Expand Up @@ -195,6 +213,51 @@ export const layer: Layer.Layer<AgentCrewProposalService, never, SqlClient.SqlCl
return (yield* read(input.id))!;
});

// A failed addition launch reopens its proposal with the member rows it reserved still in
// place; those rows are counted as members, not again as the proposal's pending seats.
// Delete the subtraction when the launch-once change removes reopen.
const countHeldSeats = Effect.fn("j5.a2a.agentCrewProposals.countHeldSeats")(function* (
crewInstanceId: string,
) {
const members = yield* sql<{ readonly participant_id: string; readonly seat_name: string }>`
SELECT participant_id, seat_name FROM j5_agent_crew_member
WHERE crew_instance_id = ${crewInstanceId}
`;
const open = (yield* sql<Row>`
SELECT * FROM j5_agent_crew_proposal
WHERE crew_instance_id = ${crewInstanceId} AND status = 'open'
`).map(fromRow);
return open.reduce((held, proposal) => {
const reserved = members.filter((row) =>
crewSeatReservedBy(proposal.id)({
participantId: row.participant_id,
seatName: row.seat_name,
}),
).length;
const seats = (proposal.approvedSeats ?? proposal.requestedSeats).length;
return held + Math.max(0, seats - reserved);
}, members.length);
});

const admit = Effect.fn("j5.a2a.agentCrewProposals.admit")(function* (
input: CreateCrewProposalInput,
options: { readonly maxSeats: number },
) {
return yield* sql.withTransaction(
Effect.gen(function* () {
const existing = yield* read(input.id);
if (existing !== null) return { status: "existing", proposal: existing } as const;
if (input.crewInstanceId !== null) {
const held = yield* countHeldSeats(input.crewInstanceId);
const adding = input.requestedSeats.length;
if (held + adding > options.maxSeats)
return { status: "cap-exceeded", held, adding } as const;
}
return { status: "created", proposal: yield* create(input) } as const;
}),
);
});

const listOpen = Effect.fn("j5.a2a.agentCrewProposals.listOpen")(function* () {
const rows = yield* sql<Row>`
SELECT * FROM j5_agent_crew_proposal WHERE status = 'open' ORDER BY created_at, id
Expand Down Expand Up @@ -299,6 +362,7 @@ export const layer: Layer.Layer<AgentCrewProposalService, never, SqlClient.SqlCl

return AgentCrewProposalService.of({
create,
admit,
read,
listOpen,
listForCaptain,
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/j5/a2a/CrewLaunchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ export interface CrewLaunchInput {
* Runs once the Crew is recorded and before any seat spawns, so the caller can bind its own
* record (the proposal) to the instance; a spawn or brief that fails afterwards then hands the
* gate back to a proposal that already names its Crew, and a decline can retire what exists.
* A failure here aborts the launch with no seat spawned.
*/
readonly onRecorded?: (instance: AgentCrewInstance) => Effect.Effect<void>;
readonly onRecorded?: (
instance: AgentCrewInstance,
) => Effect.Effect<void, CrewLaunchOperationError>;
}

export interface CrewAddSeatsInput {
Expand Down
Loading
Loading