Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
2ec5654
feat(a2a): spawn_agent and crew seats choose their workspace
bryantderosier Oct 2, 2026
6aedda3
feat(web): crew roster card shows and edits each seat's workspace
bryantderosier Oct 2, 2026
e691d1f
fix(a2a): crew seats take the spawn start permit; prove the binding
bryantderosier Oct 2, 2026
f09e367
test(a2a): prove worktree spawns prepare and fail through ThreadLaunch
bryantderosier Oct 2, 2026
d8d83ce
docs(a2a): say when a worktree default falls back to the shared checkout
bryantderosier Oct 2, 2026
b72a7e1
test(a2a): prove spawn_agent and crews share one workspace service
bryantderosier Oct 2, 2026
11d1fd8
test(a2a): prove a contending spawn start waits at the permit
bryantderosier Oct 2, 2026
9570521
fix(a2a): refuse worktree refs git would read as options
bryantderosier Oct 2, 2026
75ac638
Merge origin/j5/main into j5/spawn-agent-workspace
bryantderosier Oct 3, 2026
c80a080
refactor(a2a): refuse a second spawn start instead of queueing it
bryantderosier Oct 3, 2026
5daa73c
fix(a2a): a peer without its worktree takes no turns, and its spawner…
bryantderosier Oct 3, 2026
65f15d3
docs(a2a): say a spawn's setup script may still run when it starts
bryantderosier Oct 3, 2026
7150bb7
docs(a2a): describe the refused second spawn start
bryantderosier Oct 3, 2026
5b34504
fix(a2a): a peer's worktree still being prepared takes its messages
bryantderosier Oct 3, 2026
bdeedd8
Merge origin/j5/main into j5/spawn-agent-workspace
bryantderosier Oct 5, 2026
8f23ce0
feat(a2a): every spawn and seat names its workspace, including an exi…
bryantderosier Oct 5, 2026
866c8c2
feat(web): the roster card edits all three seat workspaces
bryantderosier Oct 5, 2026
4f6ced6
docs(a2a): every spawn and seat names its workspace
bryantderosier Oct 5, 2026
a828403
fix(a2a): read the repository fresh before placing a seat or spawn in…
bryantderosier Oct 5, 2026
5609f58
fix(a2a): the roster card's lists may be cached, but binding a worksp…
bryantderosier Oct 5, 2026
69e619b
fix(web): a Crew preview from an older server still shows its seats
bryantderosier Oct 5, 2026
50835d5
fix(a2a): bind a workspace from git itself, past the ref cache's refr…
bryantderosier Oct 5, 2026
f047884
refactor(a2a): bind an existing worktree's branch from the checkout's…
bryantderosier Oct 5, 2026
d7563a6
fix(a2a): refresh the worktree list the ruling reads membership from
bryantderosier Oct 5, 2026
c5568c4
revert(a2a): bind an existing worktree from git's worktree list again
bryantderosier Oct 5, 2026
5dfc651
fix(a2a): refresh the roster card's branch and worktree lists
bryantderosier Oct 5, 2026
2089218
revert(a2a): restore the live-status worktree binding accepted in review
bryantderosier Oct 5, 2026
8cc4ae3
test(a2a): prove the live worktree binding drops the status cache first
bryantderosier Oct 5, 2026
e91428c
fix(a2a): a peer is not reachable until its worktree is ready
bryantderosier Oct 6, 2026
d67ac13
feat(web): search and page base branches on the crew roster card
bryantderosier Oct 6, 2026
1d29dd9
fix(a2a): ThreadLaunch must not register a worktree spawn's home befo…
bryantderosier Oct 6, 2026
09b2aac
Merge remote-tracking branch 'origin/j5/main' into j5/spawn-agent-wor…
bryantderosier Oct 6, 2026
d39c916
fix(a2a): a Crew seat is not reachable until its worktree is ready
bryantderosier Oct 6, 2026
99c13ed
Revert "fix(a2a): a Crew seat is not reachable until its worktree is …
bryantderosier Oct 6, 2026
cce607d
Revert "fix(a2a): ThreadLaunch must not register a worktree spawn's h…
bryantderosier Oct 6, 2026
692107d
fix(a2a): stop guarding a failed worktree preparation
bryantderosier Oct 6, 2026
a5ebe97
fix(a2a): one unreadable stored seat workspace no longer fails every …
bryantderosier Oct 6, 2026
fdb640a
fix(contracts): a seat workspace a client can't read costs that seat,…
bryantderosier Oct 6, 2026
81565eb
chore(a2a): drop wiring and comments left from the removed spawner no…
bryantderosier Oct 6, 2026
bda6970
docs(a2a): drop the superseded failed-preparation sentence from the 1…
bryantderosier Oct 7, 2026
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
43 changes: 42 additions & 1 deletion apps/server/src/j5/a2a/AgentCrewProposalService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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 SqlClient from "effect/unstable/sql/SqlClient";

import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import { AgentCrewInstanceService, layer as crewLayer } from "./AgentCrewInstanceService.ts";
Expand All @@ -26,10 +27,16 @@ 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" });
const seat = (name: string) => ({
workspace: { type: "shared" as const },
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) => ({
workspace: { type: "shared" as const },
seatName: name,
agentId: "scout",
participantId: ParticipantId.make(`agent:j5:a2a:thread:${crewId}:${name}`),
Expand Down Expand Up @@ -174,3 +181,37 @@ it.effect("a proposal resolves once: the second resolution finds it closed", ()
);
}).pipe(Effect.provide(testLayer)),
);

it.effect("a stored seat workspace this version can't read costs that seat its workspace", () =>
Effect.gen(function* () {
yield* runJ5A2AMigrations();
yield* (yield* A2ALedger).createSquadron({
squadron: { id: squadronId, name: "Proposal Squadron", createdAt },
});
const proposals = yield* AgentCrewProposalService;
yield* proposals.create({
...addition("proposal:stored", "crew:stored", ["a", "b"]),
kind: "roster",
crewInstanceId: null,
});
// Written by another version: a new worktree with no base, and a type this one doesn't know.
const sql = yield* SqlClient.SqlClient;
const stored = `[
{"seat":"a","agentId":"scout","reason":"Holds a seat","workspace":{"type":"worktree"}},
{"seat":"b","agentId":"scout","reason":"Holds a seat","workspace":{"type":"sandbox"}},
{"seat":"c","agentId":"scout","reason":"Holds a seat","workspace":{"type":"shared"}}
]`;
yield* sql`UPDATE j5_agent_crew_proposal SET requested_seats = ${stored} WHERE id = 'proposal:stored'`;
const open = yield* proposals.listOpen();
assert.deepStrictEqual(
open.map((proposal) => proposal.requestedSeats.map((seat) => [seat.seat, seat.workspace])),
[
[
["a", undefined],
["b", undefined],
["c", { type: "shared" }],
],
],
);
}).pipe(Effect.provide(testLayer)),
);
10 changes: 10 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewProposalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 { ParticipantId, SquadronId } from "./contracts.ts";
import { SpawnWorkspaceChoice } from "./spawnWorkspace.ts";

// The same bounds as the MCP verbs: the human's card edits arrive over HTTP and must not be the
// one way to store an unbounded or empty seat (Sentry S-3, 2026-09-14).
Expand All @@ -32,6 +33,15 @@ export const CrewProposalSeat = Schema.Struct({
Schema.isMaxLength(PLAYBOOK_MAX_STEPS),
),
),
/**
* Where the seat works, as the Captain or the person chose it. Required at every door that
* files or approves a seat. A stored one this version can't read, such as one written by another
* version, reads as none: the seat stays listed and is refused at preview until one is chosen,
* rather than one seat failing every read of the table.
*/
workspace: Schema.optionalKey(
SpawnWorkspaceChoice.pipe(Schema.catchDecoding(() => Effect.succeedNone)),
),
/** Recomputed by the server whenever the seats are validated; never taken from a client. */
personaSwaps: Schema.optionalKey(Schema.Array(CrewPersonaSwap)),
});
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/j5/a2a/CrewLaunchReporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,17 @@ it.effect("posts one report once every seat has started or failed, with the run'
userMessageId: brief("prosecutor"),
});
assert.isNull(yield* reporter.handleStoredEvent(queued));
// Nor is a first turn held as `preparing` while its worktree is being made, even though
// ThreadLaunch's own preparation item reads as activity.
yield* setSeat(id, "prosecutor", [
{ status: "preparing", activity: true, userMessageId: brief("prosecutor") },
]);
const preparing = runEvent(crewSeatThreadId(id, "prosecutor"), {
status: "preparing",
userMessageId: brief("prosecutor"),
});
assert.isNull(yield* reporter.handleStoredEvent(preparing));
assert.deepStrictEqual(yield* reports(), []);
const punchlineRun = {
id: RunId.make(`run:${crewSeatThreadId(id, "punchline")}:0`),
threadId: crewSeatThreadId(id, "punchline"),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/j5/a2a/CrewLaunchReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const verdictFor = (
switch (run.status) {
case "completed":
return { kind: "started" };
// A new worktree's first turn waits here while ThreadLaunch prepares it, and that
// preparation's own items are not the seat's agent at work.
case "preparing":
return { kind: "pending" };
case "failed":
case "interrupted":
case "cancelled":
Expand Down
Loading
Loading