From 5d2ab0ba6fcd6c2093f8dfc5f06743edc6d98e22 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 16 Sep 2026 07:31:48 -0400 Subject: [PATCH 1/7] feat(agents): a Crew stops and archives as a unit stop_crew interrupts every seat with a running turn and leaves idle seats alone; nothing settles or retires, and the person has the same act as Stop crew on the Fleet header and the Captain's sidebar expander. archive_crew retires a Crew as a unit through its Captain: it lists every seat's open Exchanges and running turn, takes one signed confirmation over those facts, and retires the seats through the ordinary single-agent archive. archive_agent refuses a Crew member and a Captain of a live Crew, naming the archive_crew call. A person's archive or deletion of a Captain thread, from any door, retires its live Crews as units through a lifecycle cascade on the crew event stream; the web dialog lists the Crews seat by seat first and refuses to archive a seat alone. The Fleet header gains Archive crew, and a Squadron's retired Crews stay readable there with brief and roster snapshot. A read-only Captain runs these verbs like any other; identity and the person's confirmation are the gates, not the sandbox. Co-Authored-By: Claude Fable 5.1 --- FORK.md | 4 +- apps/server/src/j5/a2a/ArchiveAgentService.ts | 33 +- .../src/j5/a2a/ArchiveCrewService.test.ts | 338 +++++++++++++ apps/server/src/j5/a2a/ArchiveCrewService.ts | 454 ++++++++++++++++++ .../server/src/j5/a2a/ClientReadsHttp.test.ts | 4 + .../server/src/j5/a2a/CrewArchiveHttp.test.ts | 129 +++++ apps/server/src/j5/a2a/CrewArchiveHttp.ts | 117 +++++ .../j5/a2a/CrewCaptainArchiveCascade.test.ts | 87 ++++ .../src/j5/a2a/CrewCaptainArchiveCascade.ts | 114 +++++ .../src/j5/a2a/CrewSeatFinishNotifier.test.ts | 3 + .../src/j5/a2a/CrewSeatFinishNotifier.ts | 5 + apps/server/src/j5/a2a/CrewStopHttp.test.ts | 117 +++++ apps/server/src/j5/a2a/CrewStopHttp.ts | 103 ++++ .../server/src/j5/a2a/CrewStopService.test.ts | 174 +++++++ apps/server/src/j5/a2a/CrewStopService.ts | 164 +++++++ apps/server/src/j5/a2a/FleetReadsHttp.ts | 11 +- .../src/j5/a2a/J5AuthenticatedRoutes.ts | 4 + .../src/j5/a2a/PreArchiveFactsHttp.test.ts | 51 ++ apps/server/src/j5/a2a/PreArchiveFactsHttp.ts | 65 ++- .../server/src/j5/a2a/ThreadHomesHttp.test.ts | 4 + .../src/j5/a2a/mcp/claudeAllowedTools.ts | 2 + .../src/j5/a2a/mcp/codexToolApproval.ts | 2 + apps/server/src/j5/a2a/mcp/handlers.test.ts | 294 ++++++++++++ apps/server/src/j5/a2a/mcp/handlers.ts | 167 ++++++- .../src/j5/a2a/mcp/registration.test.ts | 6 + apps/server/src/j5/a2a/mcp/tools.test.ts | 7 + apps/server/src/j5/a2a/mcp/tools.ts | 111 +++++ apps/server/src/j5/a2a/runtimeLayer.test.ts | 2 +- apps/server/src/j5/a2a/runtimeLayer.ts | 16 + .../toolkits/worktree/registration.test.ts | 2 + .../Adapters/ClaudeAdapterV2.test.ts | 2 +- apps/web/src/j5/a2a/ArchiveWarningContent.tsx | 78 +++ apps/web/src/j5/a2a/archiveFlow.test.ts | 73 +++ apps/web/src/j5/a2a/archiveFlow.ts | 53 +- apps/web/src/j5/a2a/archiveFlowClient.ts | 11 + apps/web/src/j5/crew/crewArchiveClient.ts | 18 + apps/web/src/j5/crew/crewStopClient.ts | 15 + apps/web/src/j5/fleet/FleetPage.tsx | 212 +++++++- apps/web/src/j5/fleet/fleet.logic.test.ts | 31 +- apps/web/src/j5/fleet/fleet.logic.ts | 11 +- apps/web/src/j5/fleet/fleetClient.ts | 16 +- apps/web/src/j5/squadron/SpawnedChildren.tsx | 27 +- .../j5/squadron/spawnedChildren.logic.test.ts | 28 ++ .../src/j5/squadron/spawnedChildren.logic.ts | 19 +- apps/web/src/j5/state.ts | 8 + docs/j5/product/a2a/agent-tools.md | 70 ++- docs/j5/product/features/archive-flow.md | 4 +- docs/j5/product/features/fleet-page.md | 5 + docs/user/agents.md | 4 + docs/user/artifacts.md | 4 +- packages/client-runtime/src/j5/http.ts | 48 +- packages/client-runtime/src/j5/state.ts | 22 +- packages/contracts/src/j5.ts | 61 +++ 53 files changed, 3363 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/j5/a2a/ArchiveCrewService.test.ts create mode 100644 apps/server/src/j5/a2a/ArchiveCrewService.ts create mode 100644 apps/server/src/j5/a2a/CrewArchiveHttp.test.ts create mode 100644 apps/server/src/j5/a2a/CrewArchiveHttp.ts create mode 100644 apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts create mode 100644 apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts create mode 100644 apps/server/src/j5/a2a/CrewStopHttp.test.ts create mode 100644 apps/server/src/j5/a2a/CrewStopHttp.ts create mode 100644 apps/server/src/j5/a2a/CrewStopService.test.ts create mode 100644 apps/server/src/j5/a2a/CrewStopService.ts create mode 100644 apps/web/src/j5/crew/crewArchiveClient.ts create mode 100644 apps/web/src/j5/crew/crewStopClient.ts diff --git a/FORK.md b/FORK.md index cee2279b72c7c..e22353c85a55e 100644 --- a/FORK.md +++ b/FORK.md @@ -75,7 +75,7 @@ Landing-order collision: two cases landed as `15.`; resolved by the `b` suffix o 20. E7's revised Squadron-picker row shape: `apps/web/src/components/CommandPalette.tsx:990-1004` delegates its row fields to J5-owned `apps/web/src/j5/squadron/SquadronPicker.logic.ts:18-23`. The visible row is the Squadron name alone and has no folder-description second line; folder title/path remain search terms, availability state, and launch substrate only. Amended by case 34 (Jackson's ruling, 2026-09-12): once the directory spans more than one environment, the row's description is the owning environment's label, because two environments can each hold a Squadron of the same name; a single-environment directory keeps the name-only row. The row helper's focused tests prove both the retained non-visual mechanics, the absence of a `description` field for one environment, and the label for two. On every rebase, preserve this presentation without changing the Registrar-derived identity or the missing-folder failure behavior. -21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. +21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone. The Captain rule itself is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the crew settler and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. 22. B4's typed confirmation presentation seam: `apps/web/src/confirmDialog.ts:4-31,92-158` extends only the in-memory `requestConfirmDialog(message, options, presentation?)` coordinator state with optional `ReactNode` content and `confirmLabel`; `apps/web/src/components/ConfirmDialogHost.tsx:57-67,79-116` renders the typed node and label when present and otherwise keeps its existing string title/description plus `Confirm` path. The host has no J5 import, payload decoding, or private prefix branch. J5 owns the archive row content in `apps/web/src/j5/a2a/ArchiveWarningContent.tsx:6-130`; `apps/web/src/j5/a2a/archiveFlow.ts:13-17,40-94` builds and passes it for consequential archive facts, with `Archive anyway` only when one or more open asks are measured and `Archive` otherwise. `apps/web/src/components/Sidebar.tsx:3169-3181` plus `apps/web/src/components/LegacySidebar.tsx:1824-1836,2012-2018` use the existing confirmation coordinator to carry the presentation. Existing string callers omit the third argument and remain byte-for-byte on the prior path. Jackson's Variant B title `Archive ?` supersedes the earlier Designer condition that put “anyway” in the question; the explicit button label now disambiguates the action. On every rebase, verify the host stays J5-agnostic, the absent-presentation string branch remains intact, and the three archive doors continue to pass J5-owned presentation only for fact-backed warnings. @@ -211,7 +211,7 @@ If a required change cannot fit this discipline, stop and review the exception b Bryant requested a shared agent picker for Codex and Claude, invoking a saved agent as a subagent within the current conversation. This is a narrow exception to the earlier native-only Subagent steering: the child uses the existing delegated-child engine with `lineage.relationshipToParent = subagent`. Its child thread is backing storage, not a top-level Peer Agent. Ordinary native subagent requests and `spawn_agent` retain their existing meanings; `spawn_agent`'s optional `agent` input (2026-09-09) resolves a saved agent into a Peer Agent's immutable assignment entirely inside `apps/server/src/j5/agents/agentPersonaSpawn.ts` and the J5 toolkit handlers, with no further upstream hook. Proposal-driven Crews (2026-09-09; the earlier definition-file, Crews-tab, and `@crew` slice was reverted before shipping): `apps/web/src/components/ChatView.tsx` gains one appended case in the send path, in three hunks (the import, the parse-and-refuse before any in-flight state, and the substitution of the wrapped brief for the raw command before the composer's contexts are appended) — it calls J5-owned `parseCrewCommand`/`crewCommandRefusal`/`crewLaunchPrompt` from `apps/web/src/j5/crew/crewCommand.ts` on the raw prompt and sends the brief wrapped in the crew-composition guidance instead of the raw command, with attached terminal, element, and review contexts still appended after it, on a fresh draft or an existing thread alike; the Claude effort prefix is applied after the wrapper as for any turn, and the card parser skips it; the thread keeps whatever agent and model it has, so no saved agent is attached and no `crew-captain` persona exists (decoupled 2026-09-16 at Bryant's request; the bundled example was removed with it). `apps/web/src/components/chat/ChatComposer.tsx` spreads J5-owned `j5CrewSlashCommandItems(selectedProvider)` into the slash-menu item list (one appended spread) so `/crew` is discoverable in every composer; the item reuses the provider-slash-command shape, so selection inserts `/crew ` with no new item variant. The `/crew` turn and the platform's `` decisions render as cards through case 24's existing `renderThreadA2ADelivery` delegate: J5-owned `apps/web/src/j5/crew/CrewNoticeRenderer.tsx` and `crewNotices.logic.ts` recognize the tagged blocks strictly (anything unparsed stays raw), and the one upstream call site in `MessagesTimeline.tsx` gains two fields on the same call, `threadRef` and `markdownCwd`, so a seat opens its thread and the brief renders as markdown (2026-09-16). Mobile has no delivery seam and shows these notices as text, as it does A2A deliveries. The same composer mounts J5-owned `CrewRosterGate` (`apps/web/src/j5/crew/CrewRosterGate.tsx`) beside the existing `SteerUnavailableNotice` mount: the initial roster proposal is answered inline above the Captain's composer like a planning question, while mid-run seat additions stay on the inbox bell (`crewProposals.logic.ts` decides which is which). The human gate (open proposals, approve with edited seats, decline) lives entirely in J5 files: `apps/web/src/j5/crew/`, the J5 inbox page and bell, and server routes under `apps/server/src/j5/a2a/CrewProposalsHttp.ts`. One adapter append makes the gate reachable from Claude: `claudeMcpQueryOverrides` in `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` spreads J5-owned `J5_CLAUDE_MCP_ALLOWED_TOOLS` (`apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts`) into the read-only pre-approval list, because Claude runs read-only personas in `dontAsk` mode and would otherwise deny every J5 coordination tool; the upstream `CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS` constant and its toolkit cross-check stay untouched, and the two exact-allowlist assertions in `ClaudeAdapterV2.test.ts` include the J5 spread. The Codex counterpart is one spread in `codexThreadRuntimeParams` (`apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts`): the injected `mcp_servers."t3-code"` entry gains J5-owned `J5_CODEX_T3_MCP_SERVER_CONFIG` (`apps/server/src/j5/a2a/mcp/codexToolApproval.ts`; since 2026-09-14 a per-tool `tools..approval_mode = "approve"` map naming the J5 verbs plus `write_artifact` (a saved agent's declared handoff lives in application storage, never the workspace) and, since 2026-09-15, the provider-native Subagent verbs `delegate_task`, `task_status`, and `task_cancel` (a Crew member refused `spawn_agent` is sent there), never a server-wide default, so worktree handoff, browser preview, and scheduling keep Codex's own verdict for read-only personas), because Codex 0.153+ rejects every non-read-only MCP tool under approval policy `never` with "MCP tool call requires approval, but approval policy is never"; that policy is what full-access mode and every saved-agent runtime policy send, so without the spread `send_message`, `spawn_agent`, and `propose_crew` all fail on Codex. The spread applies only when the thread's resolved approval policy is `never` (`codexApprovalPolicyIsNever`), so interactive modes keep Codex's own prompting. The platform's own tools carry their gates server-side. The `codexThreadRuntimeParams` assertion in `CodexAdapterV2.test.ts` includes the field. -The proposal flow's server modules are J5-owned and need no upstream registration: `AgentCrewInstanceService`, `AgentCrewProposalService`, `CrewLaunchService`, `CrewProposalService`, `CrewLaunchReporter`, `crewGateNotice`, `crewFailureAlert`, `CrewSeatFinishNotifier`, and `CrewProposalsHttp` under `apps/server/src/j5/a2a/`, the `list_agents`, `propose_crew`, and `request_crew_member` verbs in the J5 toolkit, and J5 migrations 14 (`j5_agent_crew_instance`, `j5_agent_crew_member`, `j5_agent_crew_proposal`) and 16 (proposal claims and launch-report receipts). Names are one line each and seat names are lowercase-hyphen words (`crewLimits.ts`), enforced by the MCP schemas and again in `CrewProposalService.validateSeats` for the human's edited card, because the platform writes them into the gate and seat notices ahead of the fields the Captain's card parser trusts. A roster launch records its Crew (every planned seat under deterministic ids) before any seat thread exists, so a spawn that fails partway leaves nothing a Crew record does not know: the gate is handed back with the human's approved seats kept, a retry converges on the same seats (a renamed seat's stale reservation is dropped), and a decline retires the record and archives whatever seat threads the failed launch created (2026-09-17, after Jackson's review of PR #148). Crew handoffs (2026-09-10, consolidated 2026-09-14) add no artifact path of their own: a seat whose definition declares an `outputArtifact` writes the same shared handoff file every saved agent writes (`handoffs//-.md`, PR #142), the handoff gate in `apps/server/src/j5/agents/agentHandoffObserver.ts` checks for it and reminds the seat once, and J5-owned `CrewSeatFinishNotifier.ts` tells the Captain in its thread when the seat finishes and whether the file exists; it settles no thread (2026-09-17, Jackson's review: upstream's settle rules apply to seats). That notice is keyed by the seat's participant (one Captain's several Crews reuse seat names), carries the handoff's size and digest so a rewritten body too long to inline still reads as changed, escapes the opening seat tag as well as the closing body tag in quoted handoffs, and a failed notice is left for the next pass rather than swallowed (2026-09-17, after Jackson's review of PR #150). The earlier `deliver_artifact` verb, `j5_agent_artifact` table, and crew-only read verbs were removed before shipping. Two leaf allowlists carry `write_artifact` for read-only personas so a seat can write its handoff into application storage: `J5_CLAUDE_MCP_ALLOWED_TOOLS` (spread into Claude's read-only pre-approval list) and `J5_CODEX_PREAPPROVED_TOOLS` (per-tool `approval_mode` entries on the `t3-code` server for Codex under approval policy `never`). The crew gate rides the J5 client-runtime surface (`packages/client-runtime/src/j5/http.ts`, `state.ts`; contracts in `packages/contracts/src/j5.ts`), so proposals are a per-environment read source like the inbox. Crew launch remains out of scope for `delegate_task`. +The proposal flow's server modules are J5-owned and need no upstream registration: `AgentCrewInstanceService`, `AgentCrewProposalService`, `CrewLaunchService`, `CrewProposalService`, `CrewLaunchReporter`, `crewGateNotice`, `crewFailureAlert`, `CrewSeatFinishNotifier`, and `CrewProposalsHttp` under `apps/server/src/j5/a2a/`, the `list_agents`, `propose_crew`, `request_crew_member`, and `archive_crew` verbs in the J5 toolkit, and J5 migrations 14 (`j5_agent_crew_instance`, `j5_agent_crew_member`, `j5_agent_crew_proposal`) and 16 (proposal claims and launch-report receipts). `archive_crew` composes the J5 `ArchiveAgentService` (which gained a side-effect-free `readFacts` and a `confirmationSatisfied` input for unit archives), and `archive_agent` refuses Crew members. Names are one line each and seat names are lowercase-hyphen words (`crewLimits.ts`), enforced by the MCP schemas and again in `CrewProposalService.validateSeats` for the human's edited card, because the platform writes them into the gate and seat notices ahead of the fields the Captain's card parser trusts. A roster launch records its Crew (every planned seat under deterministic ids) before any seat thread exists, so a spawn that fails partway leaves nothing a Crew record does not know: the gate is handed back with the human's approved seats kept, a retry converges on the same seats (a renamed seat's stale reservation is dropped), and a decline retires the record and archives whatever seat threads the failed launch created (2026-09-17, after Jackson's review of PR #148). Crew handoffs (2026-09-10, consolidated 2026-09-14) add no artifact path of their own: a seat whose definition declares an `outputArtifact` writes the same shared handoff file every saved agent writes (`handoffs//-.md`, PR #142), the handoff gate in `apps/server/src/j5/agents/agentHandoffObserver.ts` checks for it and reminds the seat once, and J5-owned `CrewSeatFinishNotifier.ts` tells the Captain in its thread when the seat finishes and whether the file exists; it settles no thread (2026-09-17, Jackson's review: upstream's settle rules apply to seats). That notice is keyed by the seat's participant (one Captain's several Crews reuse seat names), carries the handoff's size and digest so a rewritten body too long to inline still reads as changed, escapes the opening seat tag as well as the closing body tag in quoted handoffs, and a failed notice is left for the next pass rather than swallowed (2026-09-17, after Jackson's review of PR #150). The earlier `deliver_artifact` verb, `j5_agent_artifact` table, and crew-only read verbs were removed before shipping. Two leaf allowlists carry `write_artifact` for read-only personas so a seat can write its handoff into application storage: `J5_CLAUDE_MCP_ALLOWED_TOOLS` (spread into Claude's read-only pre-approval list) and `J5_CODEX_PREAPPROVED_TOOLS` (per-tool `approval_mode` entries on the `t3-code` server for Codex under approval policy `never`). The crew gate rides the J5 client-runtime surface (`packages/client-runtime/src/j5/http.ts`, `state.ts`; contracts in `packages/contracts/src/j5.ts`), so proposals are a per-environment read source like the inbox. Crew launch remains out of scope for `delegate_task`. Following Jackson's 2026-09-14 review, the J5-only `invoke_agent` tool is gone. J5 registers its own `delegate_task` (`apps/server/src/j5/agents/agentDelegation.ts`): upstream's input plus an optional `agent` saved-agent id, with a J5-written description that leads with `agent` and presents the plain child as the fallback for cross-provider or T3-tracked work (upstream's text tells the model to delegate for any subagent request, which would contradict the native-subagent steering). With `agent` it resolves the saved definition, route, and permissions in the J5 invocation module and passes the pinned assignment to `OrchestratorMcpService.delegateTask`; without it, it is upstream's plain child delegation. `task_status` and `task_cancel` are consumed from upstream unchanged so a parent can follow the child. Raw thread creation (`create_threads`, `t3_thread_start`, `t3_thread_send`, `t3_thread_interrupt`) stays excluded. diff --git a/apps/server/src/j5/a2a/ArchiveAgentService.ts b/apps/server/src/j5/a2a/ArchiveAgentService.ts index 2c1d2b7e12703..8a643975148f2 100644 --- a/apps/server/src/j5/a2a/ArchiveAgentService.ts +++ b/apps/server/src/j5/a2a/ArchiveAgentService.ts @@ -75,6 +75,11 @@ export interface ArchiveAgentInput { readonly target: ArchiveAgentTarget; readonly clientRequestKey: string; readonly confirmationToken?: string; + /** + * The Crew archive path has already shown the unit's facts and holds a valid crew token, so + * per-member confirmation is satisfied; never set from the single-agent tool. + */ + readonly confirmationSatisfied?: boolean; readonly archivedAt: string; readonly interruptCommandId: CommandId; readonly archiveCommandId: CommandId; @@ -306,10 +311,20 @@ const isForwardOnlyRecovery = (confirmed: TokenPayload, current: TokenPayload): return current.thread_archived || current.retired; }; +export interface ArchiveAgentTargetFacts { + readonly facts: ArchiveAgentConsequenceFacts; + readonly threadArchived: boolean; + readonly retired: boolean; +} + export interface ArchiveAgentServiceShape { readonly archive: ( input: ArchiveAgentInput, ) => Effect.Effect; + /** Consequence facts without side effects, so a unit archive can warn about every member at once. */ + readonly readFacts: ( + target: ArchiveAgentTarget, + ) => Effect.Effect; } export class ArchiveAgentService extends Context.Service< @@ -542,13 +557,18 @@ export const layer = Layer.effect( target: input.target, state: before, }); - if (decodedToken === undefined && stateHasConsequences(before)) { + if ( + input.confirmationSatisfied !== true && + decodedToken === undefined && + stateHasConsequences(before) + ) { return yield* new ArchiveAgentConfirmationRequiredError({ facts: before.facts, confirmationToken: yield* issueToken(payload), }); } if ( + input.confirmationSatisfied !== true && decodedToken !== undefined && canonicalPayload(decodedToken) !== canonicalPayload(payload) && !isForwardOnlyRecovery(decodedToken, payload) @@ -612,6 +632,15 @@ export const layer = Layer.effect( return "archived" as const; }); - return ArchiveAgentService.of({ archive }); + const readFacts: ArchiveAgentServiceShape["readFacts"] = (target) => + readState(target).pipe( + Effect.map((state) => ({ + facts: state.facts, + threadArchived: state.projection.thread.archivedAt !== null, + retired: state.retired, + })), + ); + + return ArchiveAgentService.of({ archive, readFacts }); }), ); diff --git a/apps/server/src/j5/a2a/ArchiveCrewService.test.ts b/apps/server/src/j5/a2a/ArchiveCrewService.test.ts new file mode 100644 index 0000000000000..b86cd329116da --- /dev/null +++ b/apps/server/src/j5/a2a/ArchiveCrewService.test.ts @@ -0,0 +1,338 @@ +import { assert, it } from "@effect/vitest"; +import { CommandId, RunId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; +import { + ArchiveAgentOperationError, + ArchiveAgentService, + type ArchiveAgentInput, + type ArchiveAgentTargetFacts, +} from "./ArchiveAgentService.ts"; +import { + ArchiveCrewConfirmationRequiredError, + ArchiveCrewConfirmationStaleError, + ArchiveCrewNotCaptainError, + ArchiveCrewPartialFailureError, + ArchiveCrewService, + layer as archiveCrewLayer, + type ArchiveCrewInput, +} from "./ArchiveCrewService.ts"; +import { ExchangeId, ParticipantId, SquadronId } from "./contracts.ts"; + +const squadronId = SquadronId.make("squadron:j5:archive-crew"); +const captain = ParticipantId.make("agent:j5:a2a:captain"); +const builder = ParticipantId.make("agent:j5:a2a:thread:builder"); +const critic = ParticipantId.make("agent:j5:a2a:thread:critic"); +const instance: AgentCrewInstance = { + id: "crew:j5:test", + squadronId, + captainParticipantId: captain, + captainThreadId: ThreadId.make("t:captain"), + brief: "Implement and review the login fix.", + version: 1, + displayName: "Review Pair", + createdAt: "2026-09-09T16:00:00.000Z", + archivedAt: null, + members: [ + { + seatName: "builder", + agentId: "builder", + participantId: builder, + threadId: ThreadId.make("t:b"), + addedVersion: 1, + reason: null, + }, + { + seatName: "critic", + agentId: "critic", + participantId: critic, + threadId: ThreadId.make("t:c"), + addedVersion: 1, + reason: null, + }, + ], +}; +const openExchange = { + exchangeId: ExchangeId.make("exchange:j5:archive-crew"), + direction: "inbound" as const, + replyObligation: "participant-owes-reply" as const, + counterpartyId: captain, + intent: "Review the fix", + urgency: null, + openedAt: "2026-09-09T16:05:00.000Z", +}; + +const fixture = Effect.gen(function* () { + const facts = yield* Ref.make>({ + [builder]: { + facts: { openExchanges: [openExchange], runningTurn: null }, + threadArchived: false, + retired: false, + }, + [critic]: { + facts: { openExchanges: [], runningTurn: { runId: RunId.make("run:c"), status: "running" } }, + threadArchived: false, + retired: false, + }, + }); + const archived = yield* Ref.make>([]); + const failSeat = yield* Ref.make(null); + const marked = yield* Ref.make>([]); + const crewArchivedAt = yield* Ref.make(null); + const layer = archiveCrewLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ArchiveAgentService)({ + readFacts: (target) => + Ref.get(facts).pipe(Effect.map((current) => current[target.participantId]!)), + archive: (input) => + Effect.gen(function* () { + // Mirrors the real service: a retired member replays as already archived. + if ((yield* Ref.get(facts))[input.target.participantId]?.retired) + return "already_archived" as const; + if ((yield* Ref.get(failSeat)) === input.target.participantId) + return yield* new ArchiveAgentOperationError({ + phase: "committing the target thread archive", + cause: new Error("injected"), + }); + yield* Ref.update(archived, (items) => [...items, input]); + yield* Ref.update(facts, (current) => ({ + ...current, + [input.target.participantId]: { + facts: { openExchanges: [], runningTurn: null }, + threadArchived: true, + retired: true, + }, + })); + return "archived" as const; + }), + }), + Layer.mock(AgentCrewInstanceService)({ + read: (id) => + Ref.get(crewArchivedAt).pipe( + Effect.map((archivedAt) => (id === instance.id ? { ...instance, archivedAt } : null)), + ), + markArchived: (id, archivedAt) => + Effect.all( + [Ref.update(marked, (items) => [...items, id]), Ref.set(crewArchivedAt, archivedAt)], + { discard: true }, + ), + listForCaptain: (query) => + Ref.get(crewArchivedAt).pipe( + Effect.map((archivedAt) => + query.captainParticipantId === captain ? [{ ...instance, archivedAt }] : [], + ), + ), + }), + Layer.mock(ServerSecretStore)({ + getOrCreateRandom: () => Effect.succeed(new Uint8Array(32).fill(7)), + }), + ), + ), + ); + const input = (overrides: Partial = {}): ArchiveCrewInput => ({ + providerSessionId: "session", + callerParticipantId: captain, + squadronId, + crewInstanceId: instance.id, + clientRequestKey: "archive-1", + archivedAt: "2026-09-09T17:00:00.000Z", + commandIds: (seat) => ({ + interruptCommandId: CommandId.make(`interrupt:${seat}`), + archiveCommandId: CommandId.make(`archive:${seat}`), + }), + ...overrides, + }); + return { layer, input, archived, failSeat, marked, facts }; +}); + +it.effect("refuses until the captain confirms every member's facts, then retires the unit", () => + Effect.gen(function* () { + const { layer, input, archived, marked } = yield* fixture; + yield* Effect.gen(function* () { + const service = yield* ArchiveCrewService; + const refusal = yield* service.archive(input()).pipe(Effect.flip); + assert.instanceOf(refusal, ArchiveCrewConfirmationRequiredError); + if (!(refusal instanceof ArchiveCrewConfirmationRequiredError)) return; + assert.deepStrictEqual( + refusal.facts.members.map((member) => [ + member.seatName, + member.facts.openExchanges.length, + member.facts.runningTurn?.runId ?? null, + ]), + [ + ["builder", 1, null], + ["critic", 0, "run:c"], + ], + ); + assert.include(refusal.message, "1 open exchange(s) and 1 running turn(s)"); + assert.include(refusal.message, "check with the human"); + assert.lengthOf(yield* Ref.get(archived), 0); + + const notCaptain = yield* service + .archive(input({ callerParticipantId: builder })) + .pipe(Effect.flip); + assert.instanceOf(notCaptain, ArchiveCrewNotCaptainError); + + const garbage = yield* service + .archive(input({ confirmationToken: "nope" })) + .pipe(Effect.flip); + assert.equal(garbage._tag, "ArchiveCrewConfirmationTokenError"); + + const outcome = yield* service.archive( + input({ confirmationToken: refusal.confirmationToken }), + ); + assert.deepStrictEqual(outcome, { + status: "archived", + members: [ + { seatName: "builder", participantId: builder, result: "archived" }, + { seatName: "critic", participantId: critic, result: "archived" }, + ], + }); + const calls = yield* Ref.get(archived); + assert.deepStrictEqual( + calls.map((call) => [ + call.target.participantId, + call.confirmationSatisfied, + call.archiveCommandId, + ]), + [ + [builder, true, CommandId.make("archive:builder")], + [critic, true, CommandId.make("archive:critic")], + ], + ); + assert.deepStrictEqual(yield* Ref.get(marked), [instance.id]); + + const replay = yield* service.archive( + input({ confirmationToken: refusal.confirmationToken }), + ); + assert.equal(replay.status, "already_archived"); + assert.lengthOf(yield* Ref.get(archived), 2); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("reports a partial failure by seat and finishes on retry with the same token", () => + Effect.gen(function* () { + const { layer, input, archived, failSeat, marked } = yield* fixture; + yield* Effect.gen(function* () { + const service = yield* ArchiveCrewService; + const refusal = yield* service.archive(input()).pipe(Effect.flip); + if (!(refusal instanceof ArchiveCrewConfirmationRequiredError)) return assert.fail(); + yield* Ref.set(failSeat, critic); + const partial = yield* service + .archive(input({ confirmationToken: refusal.confirmationToken })) + .pipe(Effect.flip); + assert.instanceOf(partial, ArchiveCrewPartialFailureError); + if (partial instanceof ArchiveCrewPartialFailureError) { + assert.deepStrictEqual(partial.archivedSeats, ["builder"]); + assert.equal(partial.failedSeat, "critic"); + assert.include(partial.message, "same client_request_id and confirmation_token"); + } + assert.deepStrictEqual(yield* Ref.get(marked), []); + + // Builder is retired now, so the current facts are a strict subset of the confirmed ones. + yield* Ref.set(failSeat, null); + const finished = yield* service.archive( + input({ confirmationToken: refusal.confirmationToken }), + ); + assert.equal(finished.status, "archived"); + assert.deepStrictEqual( + (yield* Ref.get(archived)).map((call) => call.target.participantId), + [builder, critic], + ); + assert.deepStrictEqual(yield* Ref.get(marked), [instance.id]); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("treats a token as stale when a member gains new work after confirmation", () => + Effect.gen(function* () { + const { layer, input, facts, archived } = yield* fixture; + yield* Effect.gen(function* () { + const service = yield* ArchiveCrewService; + const refusal = yield* service.archive(input()).pipe(Effect.flip); + if (!(refusal instanceof ArchiveCrewConfirmationRequiredError)) return assert.fail(); + yield* Ref.update(facts, (current) => ({ + ...current, + [critic]: { + ...current[critic]!, + facts: { + openExchanges: [{ ...openExchange, exchangeId: ExchangeId.make("exchange:new") }], + runningTurn: null, + }, + }, + })); + const stale = yield* service + .archive(input({ confirmationToken: refusal.confirmationToken })) + .pipe(Effect.flip); + assert.instanceOf(stale, ArchiveCrewConfirmationStaleError); + if (stale instanceof ArchiveCrewConfirmationStaleError) { + assert.isNotNull(stale.confirmationToken); + assert.notEqual(stale.confirmationToken, refusal.confirmationToken); + } + assert.lengthOf(yield* Ref.get(archived), 0); + }).pipe(Effect.provide(layer)); + }), +); + +it.effect("retires the unit for a person whose dialog already listed every seat's facts", () => + Effect.gen(function* () { + const { layer, input, archived, marked } = yield* fixture; + yield* Effect.gen(function* () { + const service = yield* ArchiveCrewService; + // The person's archive dialog reads the same seat facts the Captain's refusal would carry. + const before = yield* service.readCaptainFacts({ squadronId, captainParticipantId: captain }); + assert.deepStrictEqual( + before.map((entry) => [ + entry.instance.id, + entry.facts.members.map((member) => [ + member.seatName, + member.facts.openExchanges.length, + member.facts.runningTurn !== null, + ]), + ]), + [ + [ + instance.id, + [ + ["builder", 1, false], + ["critic", 0, true], + ], + ], + ], + ); + assert.deepStrictEqual( + yield* service.readCaptainFacts({ squadronId, captainParticipantId: builder }), + [], + ); + + // No token is minted or checked: the person is not a participant and confirmed already. + const outcome = yield* service.archive( + input({ callerParticipantId: null, squadronId: null, confirmationSatisfied: true }), + ); + assert.equal(outcome.status, "archived"); + assert.deepStrictEqual( + (yield* Ref.get(archived)).map((call) => [ + call.target.participantId, + call.callerParticipantId, + call.confirmationSatisfied, + ]), + [ + [builder, captain, true], + [critic, captain, true], + ], + ); + assert.deepStrictEqual(yield* Ref.get(marked), [instance.id]); + // Once retired, the Captain commands no live Crew and may be archived on its own. + assert.deepStrictEqual( + yield* service.readCaptainFacts({ squadronId, captainParticipantId: captain }), + [], + ); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/j5/a2a/ArchiveCrewService.ts b/apps/server/src/j5/a2a/ArchiveCrewService.ts new file mode 100644 index 0000000000000..0dfaa8b6535d7 --- /dev/null +++ b/apps/server/src/j5/a2a/ArchiveCrewService.ts @@ -0,0 +1,454 @@ +import { CommandId, type ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import { + base64UrlDecodeUtf8, + base64UrlEncode, + signPayload, + timingSafeEqualBase64Url, +} from "../../auth/utils.ts"; +import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; +import { + ArchiveAgentService, + type ArchiveAgentConsequenceFacts, + type ArchiveAgentError, + type ArchiveAgentResult, +} from "./ArchiveAgentService.ts"; +import { ParticipantId, SquadronId } from "./contracts.ts"; + +const TOKEN_VERSION = "j5-crew-archive-confirmation/v1" as const; +const TOKEN_SECRET_NAME = "j5-a2a-archive-confirmation-v1"; +const TOKEN_SECRET_BYTES = 32; + +/** One member's consequences, keyed by seat so the Captain can see who is waiting on whom. */ +export interface ArchiveCrewMemberFacts { + readonly seatName: string; + readonly participantId: ParticipantId; + readonly threadId: ThreadId; + readonly alreadyArchived: boolean; + readonly facts: ArchiveAgentConsequenceFacts; +} + +export interface ArchiveCrewConsequenceFacts { + readonly members: ReadonlyArray; +} + +export interface ArchiveCrewInput { + readonly providerSessionId: string; + /** The Captain calling over MCP, or null when a person archives the Crew from the app. */ + readonly callerParticipantId: ParticipantId | null; + /** The Squadron the caller named; null when the person's route only knows the Crew. */ + readonly squadronId: SquadronId | null; + readonly crewInstanceId: string; + readonly clientRequestKey: string; + readonly confirmationToken?: string; + /** + * The person already confirmed a dialog that listed every seat's consequences, so no token is + * issued or checked. Never set on the Captain's path, where the signed token is the confirmation. + */ + readonly confirmationSatisfied?: boolean; + readonly archivedAt: string; + /** Per-seat command ids derive from the caller's request key so retries replay each member. */ + readonly commandIds: (seatName: string) => { + readonly interruptCommandId: CommandId; + readonly archiveCommandId: CommandId; + }; +} + +export interface ArchiveCrewMemberResult { + readonly seatName: string; + readonly participantId: ParticipantId; + readonly result: ArchiveAgentResult; +} + +export interface ArchiveCrewOutcome { + readonly status: ArchiveAgentResult; + readonly members: ReadonlyArray; +} + +export class ArchiveCrewNotFoundError extends Data.TaggedError("ArchiveCrewNotFoundError")<{ + readonly crewInstanceId: string; +}> { + override get message(): string { + return `Crew ${this.crewInstanceId} is not recorded in this environment. Call list_participants to inspect the roster; only Crews launched from an approved roster can be archived as a unit.`; + } +} + +export class ArchiveCrewNotCaptainError extends Data.TaggedError("ArchiveCrewNotCaptainError")<{ + readonly crewInstanceId: string; + readonly captainParticipantId: ParticipantId; + readonly callerParticipantId: ParticipantId; +}> { + override get message(): string { + return `Crew ${this.crewInstanceId} is commanded by ${this.captainParticipantId}, not by ${this.callerParticipantId}. Only the Captain archives a Crew (R19); send the Captain an ask instead.`; + } +} + +export class ArchiveCrewSquadronMismatchError extends Data.TaggedError( + "ArchiveCrewSquadronMismatchError", +)<{ + readonly crewInstanceId: string; + readonly squadronId: SquadronId; + readonly expected: SquadronId; +}> { + override get message(): string { + return `Crew ${this.crewInstanceId} lives in Squadron ${this.expected}, but archive_crew named ${this.squadronId}. Retry with squadron_id=${this.expected}.`; + } +} + +export class ArchiveCrewConfirmationRequiredError extends Data.TaggedError( + "ArchiveCrewConfirmationRequiredError", +)<{ readonly facts: ArchiveCrewConsequenceFacts; readonly confirmationToken: string }> { + override get message(): string { + const waiting = this.facts.members.reduce( + (count, member) => count + member.facts.openExchanges.length, + 0, + ); + const running = this.facts.members.filter((member) => member.facts.runningTurn !== null).length; + return `Archiving this Crew would end active work: ${waiting} open exchange(s) and ${running} running turn(s) across its seats. Review every listed member fact, check with the human before retiring a Crew that others are waiting on, then retry archive_crew with the confirmation_token.`; + } +} + +export class ArchiveCrewConfirmationStaleError extends Data.TaggedError( + "ArchiveCrewConfirmationStaleError", +)<{ readonly facts: ArchiveCrewConsequenceFacts; readonly confirmationToken: string | null }> { + override get message(): string { + return this.confirmationToken === null + ? "The confirmation_token is stale because the Crew no longer has consequential work. Retry archive_crew without a token." + : "The confirmation_token is stale because the Crew's facts changed. Review the current member facts and retry archive_crew with the new confirmation_token."; + } +} + +export class ArchiveCrewConfirmationTokenError extends Data.TaggedError( + "ArchiveCrewConfirmationTokenError", +)<{ readonly reason: "malformed" | "unsupported-version" }> { + override get message(): string { + return this.reason === "unsupported-version" + ? "The confirmation_token uses an unsupported version. Call archive_crew without a token to receive a current refusal." + : "The confirmation_token is malformed or has an invalid signature. Call archive_crew without a token to receive a current refusal."; + } +} + +export class ArchiveCrewPartialFailureError extends Data.TaggedError( + "ArchiveCrewPartialFailureError", +)<{ + readonly crewInstanceId: string; + readonly archivedSeats: ReadonlyArray; + readonly failedSeat: string; + readonly cause: ArchiveAgentError | unknown; +}> { + override get message(): string { + const cause = this.cause instanceof Error ? this.cause.message : String(this.cause); + return `archive_crew retired seat(s) ${this.archivedSeats.join(", ") || "none"} of Crew ${this.crewInstanceId} but stopped at seat ${this.failedSeat}: ${cause} Retry archive_crew with the same client_request_id and confirmation_token; retired seats replay as already archived and the rest continue.`; + } +} + +export type ArchiveCrewError = + | ArchiveCrewNotFoundError + | ArchiveCrewNotCaptainError + | ArchiveCrewSquadronMismatchError + | ArchiveCrewConfirmationRequiredError + | ArchiveCrewConfirmationStaleError + | ArchiveCrewConfirmationTokenError + | ArchiveCrewPartialFailureError; + +const TokenMember = Schema.Struct({ + seat: Schema.String, + participant_id: ParticipantId, + open_exchange_ids: Schema.Array(Schema.String), + running_turn_id: Schema.NullOr(Schema.String), +}); +const TokenPayload = Schema.Struct({ + version: Schema.Literal(TOKEN_VERSION), + provider_session_id: Schema.String, + caller_participant_id: Schema.NullOr(ParticipantId), + squadron_id: Schema.NullOr(SquadronId), + crew_instance_id: Schema.String, + members: Schema.Array(TokenMember), +}); +type TokenPayload = typeof TokenPayload.Type; +const TokenPayloadJson = Schema.fromJsonString(TokenPayload); +const decodeTokenPayloadJson = Schema.decodeUnknownEffect(TokenPayloadJson); +const decodeUnknownJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); +const encodeTokenPayloadJson = Schema.encodeSync(TokenPayloadJson); + +const payloadFor = (input: ArchiveCrewInput, facts: ArchiveCrewConsequenceFacts): TokenPayload => ({ + version: TOKEN_VERSION, + provider_session_id: input.providerSessionId, + caller_participant_id: input.callerParticipantId, + squadron_id: input.squadronId, + crew_instance_id: input.crewInstanceId, + members: facts.members.map((member) => ({ + seat: member.seatName, + participant_id: member.participantId, + open_exchange_ids: member.facts.openExchanges + .map((exchange) => String(exchange.exchangeId)) + .toSorted(), + running_turn_id: + member.facts.runningTurn === null ? null : String(member.facts.runningTurn.runId), + })), +}); + +const hasConsequences = (facts: ArchiveCrewConsequenceFacts) => + facts.members.some( + (member) => member.facts.openExchanges.length > 0 || member.facts.runningTurn !== null, + ); + +/** Retrying after a partial archive sees fewer consequences, never new ones; that still counts as confirmed. */ +const isForwardOnlyRecovery = (confirmed: TokenPayload, current: TokenPayload): boolean => { + if ( + confirmed.provider_session_id !== current.provider_session_id || + confirmed.caller_participant_id !== current.caller_participant_id || + confirmed.squadron_id !== current.squadron_id || + confirmed.crew_instance_id !== current.crew_instance_id + ) { + return false; + } + return current.members.every((member) => { + const before = confirmed.members.find((candidate) => candidate.seat === member.seat); + if (before === undefined) return false; + const confirmedExchanges = new Set(before.open_exchange_ids); + return ( + member.open_exchange_ids.every((id) => confirmedExchanges.has(id)) && + (member.running_turn_id === null || member.running_turn_id === before.running_turn_id) + ); + }); +}; + +/** One live Crew a Captain commands, with every seat's consequences, for the person's archive dialog. */ +export interface ArchiveCrewCaptainFacts { + readonly instance: AgentCrewInstance; + readonly facts: ArchiveCrewConsequenceFacts; +} + +export interface ArchiveCrewServiceShape { + readonly archive: ( + input: ArchiveCrewInput, + ) => Effect.Effect; + /** + * The live Crews directly under a Captain and their seats' open Exchanges and running turns, + * read without mutation. A Captain is never archived alone (Crews AC17), so the person's + * archive dialog shows these before the Crews retire ahead of their Captain. + */ + readonly readCaptainFacts: (input: { + readonly squadronId: SquadronId; + readonly captainParticipantId: ParticipantId; + }) => Effect.Effect, ArchiveCrewPartialFailureError>; +} + +export class ArchiveCrewService extends Context.Service< + ArchiveCrewService, + ArchiveCrewServiceShape +>()("t3/j5/a2a/ArchiveCrewService") {} + +/** + * Crews archive only as a unit (R14) and only by their Captain (R19). The Captain sees every + * member's open Exchanges and running turn in one refusal, confirms once with a signed token over + * those facts, and then each member retires through the ordinary single-agent archive with its own + * confirmation already satisfied. The platform composes nothing for a successor; it only records. + */ +export const layer = Layer.effect( + ArchiveCrewService, + Effect.gen(function* () { + const archiveAgent = yield* ArchiveAgentService; + const crews = yield* AgentCrewInstanceService; + const secrets = yield* ServerSecretStore; + const secret = secrets + .getOrCreateRandom(TOKEN_SECRET_NAME, TOKEN_SECRET_BYTES) + .pipe(Effect.orDie); + + const issueToken = Effect.fn("j5.a2a.archiveCrew.issueToken")(function* ( + payload: TokenPayload, + ) { + const encoded = base64UrlEncode(encodeTokenPayloadJson(payload)); + return `${encoded}.${signPayload(encoded, yield* secret)}`; + }); + + const parseToken = Effect.fn("j5.a2a.archiveCrew.parseToken")(function* (token: string) { + const parts = token.split("."); + if (parts.length !== 2 || parts[0] === "" || parts[1] === "") + return yield* new ArchiveCrewConfirmationTokenError({ reason: "malformed" }); + const [encoded, signature] = parts as [string, string]; + if (!timingSafeEqualBase64Url(signature, signPayload(encoded, yield* secret))) + return yield* new ArchiveCrewConfirmationTokenError({ reason: "malformed" }); + const text = yield* Effect.result( + Effect.try({ + try: () => base64UrlDecodeUtf8(encoded), + catch: () => new ArchiveCrewConfirmationTokenError({ reason: "malformed" }), + }), + ); + if (Result.isFailure(text)) + return yield* new ArchiveCrewConfirmationTokenError({ reason: "malformed" }); + const unknown = yield* Effect.result(decodeUnknownJson(text.success)); + if (Result.isFailure(unknown)) + return yield* new ArchiveCrewConfirmationTokenError({ reason: "malformed" }); + const value = unknown.success; + if ( + typeof value === "object" && + value !== null && + "version" in value && + value.version !== TOKEN_VERSION + ) + return yield* new ArchiveCrewConfirmationTokenError({ reason: "unsupported-version" }); + const payload = yield* Effect.result(decodeTokenPayloadJson(text.success)); + if (Result.isFailure(payload)) + return yield* new ArchiveCrewConfirmationTokenError({ reason: "malformed" }); + return payload.success; + }); + + const readFacts = Effect.fn("j5.a2a.archiveCrew.readFacts")(function* ( + instance: AgentCrewInstance, + ) { + const members: Array = []; + for (const member of instance.members) { + const state = yield* archiveAgent + .readFacts({ + squadronId: instance.squadronId, + participantId: member.participantId, + threadId: member.threadId, + }) + .pipe( + Effect.mapError( + (cause) => + new ArchiveCrewPartialFailureError({ + crewInstanceId: instance.id, + archivedSeats: [], + failedSeat: member.seatName, + cause, + }), + ), + ); + members.push({ + seatName: member.seatName, + participantId: member.participantId, + threadId: member.threadId, + alreadyArchived: state.threadArchived && state.retired, + facts: state.facts, + }); + } + return { members } satisfies ArchiveCrewConsequenceFacts; + }); + + const archive: ArchiveCrewServiceShape["archive"] = (input) => + Effect.gen(function* () { + const decodedToken = + input.confirmationToken === undefined + ? undefined + : yield* parseToken(input.confirmationToken); + const instance = yield* crews.read(input.crewInstanceId).pipe(Effect.orDie); + if (instance === null) + return yield* new ArchiveCrewNotFoundError({ crewInstanceId: input.crewInstanceId }); + if (input.squadronId !== null && instance.squadronId !== input.squadronId) + return yield* new ArchiveCrewSquadronMismatchError({ + crewInstanceId: instance.id, + squadronId: input.squadronId, + expected: instance.squadronId, + }); + // A person is not a participant and holds the authority the gate gave them; only an + // agent caller has to be the Captain (R19). + if ( + input.callerParticipantId !== null && + instance.captainParticipantId !== input.callerParticipantId + ) + return yield* new ArchiveCrewNotCaptainError({ + crewInstanceId: instance.id, + captainParticipantId: instance.captainParticipantId, + callerParticipantId: input.callerParticipantId, + }); + + const facts = yield* readFacts(instance); + if ( + instance.archivedAt !== null && + facts.members.every((member) => member.alreadyArchived) + ) { + return { + status: "already_archived" as const, + members: facts.members.map((member) => ({ + seatName: member.seatName, + participantId: member.participantId, + result: "already_archived" as const, + })), + }; + } + + const payload = payloadFor(input, facts); + if ( + input.confirmationSatisfied !== true && + decodedToken === undefined && + hasConsequences(facts) + ) { + return yield* new ArchiveCrewConfirmationRequiredError({ + facts, + confirmationToken: yield* issueToken(payload), + }); + } + if ( + input.confirmationSatisfied !== true && + decodedToken !== undefined && + encodeTokenPayloadJson(decodedToken) !== encodeTokenPayloadJson(payload) && + !isForwardOnlyRecovery(decodedToken, payload) + ) { + return yield* new ArchiveCrewConfirmationStaleError({ + facts, + confirmationToken: hasConsequences(facts) ? yield* issueToken(payload) : null, + }); + } + + const results: Array = []; + for (const member of facts.members) { + const ids = input.commandIds(member.seatName); + const result = yield* archiveAgent + .archive({ + providerSessionId: input.providerSessionId, + // The single-agent archive only folds the caller into a token it never issues + // here (confirmation is already satisfied); a person's archive runs under the + // Captain's identity because the unit retires as the Captain's Crew. + callerParticipantId: input.callerParticipantId ?? instance.captainParticipantId, + target: { + squadronId: instance.squadronId, + participantId: member.participantId, + threadId: member.threadId, + }, + clientRequestKey: input.clientRequestKey, + confirmationSatisfied: true, + archivedAt: input.archivedAt, + interruptCommandId: ids.interruptCommandId, + archiveCommandId: ids.archiveCommandId, + }) + .pipe( + Effect.mapError( + (cause) => + new ArchiveCrewPartialFailureError({ + crewInstanceId: instance.id, + archivedSeats: results.map(({ seatName }) => seatName), + failedSeat: member.seatName, + cause, + }), + ), + ); + results.push({ seatName: member.seatName, participantId: member.participantId, result }); + } + yield* crews.markArchived(instance.id, input.archivedAt).pipe(Effect.orDie); + return { status: "archived" as const, members: results }; + }); + + const readCaptainFacts: ArchiveCrewServiceShape["readCaptainFacts"] = (input) => + Effect.gen(function* () { + const commanded = yield* crews.listForCaptain(input).pipe(Effect.orDie); + const live = commanded.filter((instance) => instance.archivedAt === null); + return yield* Effect.forEach( + live, + (instance) => readFacts(instance).pipe(Effect.map((facts) => ({ instance, facts }))), + { concurrency: 1 }, + ); + }); + + return ArchiveCrewService.of({ archive, readCaptainFacts }); + }), +); diff --git a/apps/server/src/j5/a2a/ClientReadsHttp.test.ts b/apps/server/src/j5/a2a/ClientReadsHttp.test.ts index 39cb652275050..d6f8f97f72de9 100644 --- a/apps/server/src/j5/a2a/ClientReadsHttp.test.ts +++ b/apps/server/src/j5/a2a/ClientReadsHttp.test.ts @@ -16,6 +16,8 @@ import { ClientReadsService } from "./ClientReadsService.ts"; import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; import { AgentCrewProposalService } from "./AgentCrewProposalService.ts"; import { CrewProposalService } from "./CrewProposalService.ts"; +import { ArchiveCrewService } from "./ArchiveCrewService.ts"; +import { CrewStopService } from "./CrewStopService.ts"; import { ParticipantPlacementService } from "./PlacementService.ts"; import { A2AArchiveFacts } from "./ArchiveFactsService.ts"; import { @@ -349,6 +351,8 @@ it("registers B6 client reads through the authenticated aggregate", async () => Layer.mock(AgentCrewInstanceService)({}), Layer.mock(AgentCrewProposalService)({}), Layer.mock(CrewProposalService)({}), + Layer.mock(CrewStopService)({}), + Layer.mock(ArchiveCrewService)({}), Layer.mock(ParticipantPlacementService)({}), ), ), diff --git a/apps/server/src/j5/a2a/CrewArchiveHttp.test.ts b/apps/server/src/j5/a2a/CrewArchiveHttp.test.ts new file mode 100644 index 0000000000000..c52c199f1de65 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewArchiveHttp.test.ts @@ -0,0 +1,129 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSessionId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import { makeCrewArchiveHttpRouteLayer } from "./CrewArchiveHttp.ts"; +import { ArchiveCrewNotFoundError, ArchiveCrewService } from "./ArchiveCrewService.ts"; +import { ParticipantId } from "./contracts.ts"; + +const path = "/raw/crews/archive"; + +const authWith = (scopes: ReadonlyArray) => + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: () => + Effect.succeed({ + sessionId: AuthSessionId.make("auth-session:crew-archive"), + subject: "crew-archive-test", + method: "bearer-access-token", + scopes: scopes as never, + }), + }); + +it("archives a crew for operators only, with the person's confirmation already satisfied", async () => { + const calls: Array<{ + callerParticipantId: unknown; + squadronId: unknown; + confirmationSatisfied: boolean | undefined; + ids: string[]; + }> = []; + const archives = Layer.mock(ArchiveCrewService)({ + archive: (input) => { + calls.push({ + callerParticipantId: input.callerParticipantId, + squadronId: input.squadronId, + confirmationSatisfied: input.confirmationSatisfied, + ids: ["builder", "critic"].map((seat) => input.commandIds(seat).archiveCommandId), + }); + return input.crewInstanceId === "crew:1" + ? Effect.succeed({ + status: "archived" as const, + members: [ + { + seatName: "builder", + participantId: ParticipantId.make("agent:j5:a2a:thread:builder"), + result: "archived" as const, + }, + { + seatName: "critic", + participantId: ParticipantId.make("agent:j5:a2a:thread:critic"), + result: "already_archived" as const, + }, + ], + }) + : Effect.fail(new ArchiveCrewNotFoundError({ crewInstanceId: input.crewInstanceId })); + }, + }); + const routes = (scopes: ReadonlyArray) => + makeCrewArchiveHttpRouteLayer(path).pipe( + Layer.provide(archives), + Layer.provideMerge(authWith(scopes)), + Layer.provide(NodeServices.layer), + Layer.provide(HttpServer.layerServices), + ); + const operator = HttpRouter.toWebHandler( + routes([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + { disableLogger: true }, + ); + const reader = HttpRouter.toWebHandler(routes([AuthOrchestrationReadScope]), { + disableLogger: true, + }); + try { + const archived = await operator.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:1" }), + }), + ); + assert.equal(archived.status, 200); + const body = (await archived.json()) as { + status: string; + members: Array<{ seat: string; result: string }>; + }; + assert.equal(body.status, "archived"); + assert.deepStrictEqual( + body.members.map((member) => [member.seat, member.result]), + [ + ["builder", "archived"], + ["critic", "already_archived"], + ], + ); + // A person is not a participant and confirmed the dialog: no Captain check, no token dance. + assert.isNull(calls[0]?.callerParticipantId); + assert.isNull(calls[0]?.squadronId); + assert.isTrue(calls[0]?.confirmationSatisfied); + assert.notEqual(calls[0]?.ids[0], calls[0]?.ids[1]); + + const missing = await operator.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:nope" }), + }), + ); + assert.equal(missing.status, 404); + + const malformed = await operator.handler( + new Request(`http://environment.test${path}`, { method: "POST", body: "{}" }), + ); + assert.equal(malformed.status, 400); + + const forbidden = await reader.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:1" }), + }), + ); + assert.equal(forbidden.status, 403); + assert.lengthOf(calls, 2); + } finally { + await operator.dispose(); + await reader.dispose(); + } +}); diff --git a/apps/server/src/j5/a2a/CrewArchiveHttp.ts b/apps/server/src/j5/a2a/CrewArchiveHttp.ts new file mode 100644 index 0000000000000..5c8898619568a --- /dev/null +++ b/apps/server/src/j5/a2a/CrewArchiveHttp.ts @@ -0,0 +1,117 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { CrewArchiveRequest, CrewArchiveResponse } from "@t3tools/contracts/j5"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"; + +import { annotateEnvironmentRequest } from "../../auth/http.ts"; +import { ArchiveCrewService } from "./ArchiveCrewService.ts"; +import { authenticateOperate, invalidRequest, jsonBody } from "./ClientReadsHttp.ts"; +import { crewSeatRequestKey, lifecycleCommandId } from "./spawnIds.ts"; + +export const CREW_ARCHIVE_PATH = "/api/j5/a2a/crews/archive"; +const HUMAN_ARCHIVE_SESSION = "j5-crew-archive-human"; + +const decodeArchive = Schema.decodeUnknownEffect(CrewArchiveRequest); +const encodeArchive = Schema.encodeEffect(CrewArchiveResponse); + +/** + * The person's Archive crew: the Fleet crew header, and the cascade when a Captain is archived + * from the sidebar. The person is not a participant, so there is no Captain check, and the dialog + * they confirmed already listed every seat's open asks and running turns, so the service runs with + * its confirmation satisfied rather than minting a token nobody would read. Each click is one + * archive keyed by a fresh request id; seats that already retired replay as already archived. + */ +export const makeCrewArchiveHttpRouteLayer = (path: HttpRouter.PathInput) => + Layer.unwrap( + Effect.gen(function* () { + const archives = yield* ArchiveCrewService; + const crypto = yield* Crypto.Crypto; + return HttpRouter.add( + "POST", + path, + Effect.gen(function* () { + yield* annotateEnvironmentRequest("j5.a2a.crews.archive"); + yield* authenticateOperate; + const body = yield* jsonBody; + if (Result.isFailure(body)) return invalidRequest("The request body must be JSON."); + const decoded = yield* Effect.result(decodeArchive(body.success)); + if (Result.isFailure(decoded)) return invalidRequest("crewInstanceId is required."); + const requestKey = yield* crypto.randomUUIDv4; + const archivedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const crewInstanceId = decoded.success.crewInstanceId; + const outcome = yield* Effect.result( + archives + .archive({ + providerSessionId: HUMAN_ARCHIVE_SESSION, + callerParticipantId: null, + squadronId: null, + crewInstanceId, + clientRequestKey: requestKey, + confirmationSatisfied: true, + archivedAt, + commandIds: (seatName) => ({ + interruptCommandId: lifecycleCommandId({ + providerSessionId: HUMAN_ARCHIVE_SESSION, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-interrupt", + }), + archiveCommandId: lifecycleCommandId({ + providerSessionId: HUMAN_ARCHIVE_SESSION, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-thread", + }), + }), + }) + .pipe( + Effect.flatMap((result) => + encodeArchive({ + crewInstanceId, + status: result.status, + members: result.members.map((member) => ({ + seat: member.seatName, + participantId: member.participantId, + result: member.result, + })), + }), + ), + ), + ); + if (Result.isSuccess(outcome)) return HttpServerResponse.jsonUnsafe(outcome.success); + const failure = outcome.failure; + const status = + failure._tag === "ArchiveCrewNotFoundError" + ? 404 + : failure._tag === "ArchiveCrewPartialFailureError" + ? 500 + : 409; + if (status === 500) yield* Effect.logError("J5 crew archive failed", { cause: failure }); + return HttpServerResponse.jsonUnsafe( + { + error: failure._tag, + message: + status === 500 + ? "Archiving the crew stopped partway; the seats already retired stay retired. Try again." + : failure.message, + }, + { status }, + ); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), + ); + }), + ); + +// The authenticated aggregate carries no Crypto of its own; the route brings the Node one along. +export const crewArchiveHttpRouteLayer = makeCrewArchiveHttpRouteLayer(CREW_ARCHIVE_PATH).pipe( + Layer.provide(NodeCrypto.layer), +); diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts new file mode 100644 index 0000000000000..09aa0c42a7030 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts @@ -0,0 +1,87 @@ +import { ThreadId, type OrchestrationV2StoredEvent } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; +import { ArchiveCrewService, type ArchiveCrewInput } from "./ArchiveCrewService.ts"; +import { CrewCaptainArchiveCascade, layer as cascadeLayer } from "./CrewCaptainArchiveCascade.ts"; +import { participantIdForThread } from "./HomeRegistrar.ts"; +import { ParticipantId, SquadronId } from "./contracts.ts"; + +const squadronId = SquadronId.make("squadron:j5:cascade"); +const captainThread = ThreadId.make("thread:captain"); +const seatThread = ThreadId.make("thread:seat"); +const crew = (id: string, archivedAt: string | null): AgentCrewInstance => ({ + id, + squadronId, + captainParticipantId: participantIdForThread(captainThread), + captainThreadId: captainThread, + displayName: id, + brief: "Land it.", + version: 1, + createdAt: "2026-09-15T10:00:00.000Z", + archivedAt, + members: [ + { + seatName: "builder", + agentId: "builder", + participantId: ParticipantId.make("agent:j5:a2a:thread:seat"), + threadId: seatThread, + addedVersion: 1, + reason: null, + }, + ], +}); +const archivedEvent = (threadId: ThreadId, type = "thread.archived"): OrchestrationV2StoredEvent => + ({ sequence: 1, commandId: null, event: { type, threadId } }) as never; + +it.effect("retires the live Crews a Captain commanded when the person archives its thread", () => + Effect.gen(function* () { + const calls = yield* Ref.make>([]); + const layer = cascadeLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(AgentCrewInstanceService)({ + listInvolving: (input) => + Effect.succeed( + input.threadIds.includes(captainThread) + ? [crew("crew:live", null), crew("crew:old", "2026-09-14T00:00:00.000Z")] + : [], + ), + }), + Layer.mock(ArchiveCrewService)({ + archive: (input) => + Ref.update(calls, (items) => [...items, input]).pipe( + Effect.as({ status: "archived" as const, members: [] }), + ), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const cascade = yield* CrewCaptainArchiveCascade; + // A seat's own archive commands nothing; unrelated events are ignored. + assert.isNull(yield* cascade.handleStoredEvent(archivedEvent(seatThread))); + assert.isNull(yield* cascade.handleStoredEvent(archivedEvent(captainThread, "run.updated"))); + assert.deepStrictEqual(yield* cascade.handleStoredEvent(archivedEvent(captainThread)), [ + "crew:live", + ]); + const [call] = yield* Ref.get(calls); + assert.isNull(call?.callerParticipantId); + assert.isTrue(call?.confirmationSatisfied); + assert.equal(call?.crewInstanceId, "crew:live"); + // Same Captain, same Crew, same command ids: a replayed event converges. + const again = yield* cascade.handleStoredEvent( + archivedEvent(captainThread, "thread.deleted"), + ); + assert.deepStrictEqual(again, ["crew:live"]); + const [first, second] = yield* Ref.get(calls); + assert.equal( + first?.commandIds("builder").archiveCommandId, + second?.commandIds("builder").archiveCommandId, + ); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts new file mode 100644 index 0000000000000..b58778db93522 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts @@ -0,0 +1,114 @@ +import type { OrchestrationV2StoredEvent, ThreadId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; +import { ArchiveCrewService } from "./ArchiveCrewService.ts"; +import { participantIdForThread } from "./HomeRegistrar.ts"; +import { crewSeatRequestKey, lifecycleCommandId } from "./spawnIds.ts"; + +const CASCADE_SESSION = "j5-captain-archive-cascade"; + +export interface CrewCaptainArchiveCascadeShape { + /** + * When a committed archive or deletion lands on a thread that commands live Crews, retire + * those Crews as units. Returns the ids retired, or null when the event was not a Captain's. + */ + readonly handleStoredEvent: ( + stored: OrchestrationV2StoredEvent, + ) => Effect.Effect | null>; +} + +export class CrewCaptainArchiveCascade extends Context.Service< + CrewCaptainArchiveCascade, + CrewCaptainArchiveCascadeShape +>()("t3/j5/a2a/CrewCaptainArchiveCascade") {} + +/** + * A Captain is never archived alone (Crews AC17). Agents are refused up front: `archive_agent` + * names the Crew to retire first. A person's archive cannot be refused after the fact, because + * upstream's `thread.archive` has already committed by the time J5 sees it, from the web sidebar, + * a mobile swipe, or any future door. So the one place that sees every archive compensates: the + * live Crews the thread commanded retire as units through the same service the Captain and the + * Fleet page use, with the person's act as the confirmation. Command ids derive from the Captain + * and the Crew, so a replayed event converges on the same commands and an already retired Crew + * replays as already archived. + */ +export const layer = Layer.effect( + CrewCaptainArchiveCascade, + Effect.gen(function* () { + const crews = yield* AgentCrewInstanceService; + const archives = yield* ArchiveCrewService; + + const retire = Effect.fn("j5.a2a.captainArchiveCascade.retire")(function* ( + captainThreadId: ThreadId, + instance: AgentCrewInstance, + ) { + const requestKey = `${captainThreadId}:${instance.id}`; + const archivedAt = DateTime.formatIso(yield* DateTime.now); + const outcome = yield* archives.archive({ + providerSessionId: CASCADE_SESSION, + callerParticipantId: null, + squadronId: instance.squadronId, + crewInstanceId: instance.id, + clientRequestKey: requestKey, + confirmationSatisfied: true, + archivedAt, + commandIds: (seatName) => ({ + interruptCommandId: lifecycleCommandId({ + providerSessionId: CASCADE_SESSION, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-interrupt", + }), + archiveCommandId: lifecycleCommandId({ + providerSessionId: CASCADE_SESSION, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-thread", + }), + }), + }); + yield* Effect.logInfo("J5 Captain archived; its Crew retired as a unit", { + captainThreadId, + crewInstanceId: instance.id, + status: outcome.status, + }); + }); + + const handleStoredEvent: CrewCaptainArchiveCascadeShape["handleStoredEvent"] = (stored) => + Effect.gen(function* () { + const event = stored.event; + if (event.type !== "thread.archived" && event.type !== "thread.deleted") return null; + const threadId = event.threadId; + // Placement and membership rows may already be gone for a deleted thread; the Crew + // record names its Captain's thread directly, so the lookup needs no home. + const involving = yield* crews.listInvolving({ + threadIds: [threadId], + participantIds: [participantIdForThread(threadId)], + }); + const commanded = involving.filter( + (instance) => instance.captainThreadId === threadId && instance.archivedAt === null, + ); + if (commanded.length === 0) return null; + for (const instance of commanded) { + yield* retire(threadId, instance).pipe( + Effect.catchCause((cause) => + Effect.logError("J5 Captain archive cascade failed; the Crew stays live", { + captainThreadId: threadId, + crewInstanceId: instance.id, + cause, + }), + ), + ); + } + return commanded.map((instance) => instance.id); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("J5 Captain archive cascade skipped", { cause }).pipe(Effect.as(null)), + ), + ); + + return CrewCaptainArchiveCascade.of({ handleStoredEvent }); + }), +); diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts index 365c084715c67..7e8a86b240ba6 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts @@ -35,6 +35,7 @@ import { seatFinishedNoticeText, } from "./CrewSeatFinishNotifier.ts"; import { CrewLaunchReporter } from "./CrewLaunchReporter.ts"; +import { CrewCaptainArchiveCascade } from "./CrewCaptainArchiveCascade.ts"; import { participantIdForThread } from "./HomeRegistrar.ts"; import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; import { runJ5A2AMigrations } from "./Migrations.ts"; @@ -239,6 +240,7 @@ it.effect("tells the Captain how each finished seat ended, and settles nothing", ), }), ), + Layer.provideMerge(Layer.mock(CrewCaptainArchiveCascade)({})), Layer.provideMerge(artifactWorkspaceLayer), Layer.provideMerge(Layer.succeedContext(context)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "j5-crew-finish-" })), @@ -457,6 +459,7 @@ it.effect( ), }), ), + Layer.provideMerge(Layer.mock(CrewCaptainArchiveCascade)({})), Layer.provideMerge(artifactWorkspaceLayer), Layer.provideMerge(Layer.succeedContext(context)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "j5-crew-fold-" })), diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts index e28815d0b3f0e..e19c4c0e9209c 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts @@ -26,6 +26,7 @@ import { makeAgentPersonaLibrary } from "../agents/agentPersonaLibrary.ts"; import { ArtifactWorkspace } from "../artifacts/ArtifactWorkspace.ts"; import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; import { CrewLaunchReporter } from "./CrewLaunchReporter.ts"; +import { CrewCaptainArchiveCascade } from "./CrewCaptainArchiveCascade.ts"; import { participantIdForThread } from "./HomeRegistrar.ts"; import { formatRunFailureField, runFailureDetail } from "./runFailures.ts"; import { lifecycleCommandId, lifecycleId } from "./spawnIds.ts"; @@ -196,6 +197,7 @@ const makeLayer = (daemon: boolean) => const alertHumanOfCrewFailure = yield* makeCrewFailureAlert; const crews = yield* AgentCrewInstanceService; const reporter = yield* CrewLaunchReporter; + const cascade = yield* CrewCaptainArchiveCascade; const workspace = yield* ArtifactWorkspace; const agents = yield* makeAgentPersonaLibrary; const sql = yield* SqlClient.SqlClient; @@ -366,9 +368,12 @@ const makeLayer = (daemon: boolean) => // sequence rather than from the daemon's start. return yield* Effect.forever( Stream.suspend(() => threads.streamStoredEventsFrom({ afterSequence })).pipe( + // One stream serves both Crew reactions: seats settling, and a Captain's archive + // retiring its Crews (CrewCaptainArchiveCascade). Stream.runForEach((event) => reporter.handleStoredEvent(event).pipe( Effect.andThen(handleStoredEvent(event)), + Effect.andThen(cascade.handleStoredEvent(event)), Effect.tap(() => Effect.sync(() => (afterSequence = event.sequence))), ), ), diff --git a/apps/server/src/j5/a2a/CrewStopHttp.test.ts b/apps/server/src/j5/a2a/CrewStopHttp.test.ts new file mode 100644 index 0000000000000..13144728bc6c7 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewStopHttp.test.ts @@ -0,0 +1,117 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSessionId, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; + +import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import { makeCrewStopHttpRouteLayer } from "./CrewStopHttp.ts"; +import { CrewStopNotFoundError, CrewStopService } from "./CrewStopService.ts"; +import { ParticipantId } from "./contracts.ts"; + +const path = "/raw/crews/stop"; + +const authWith = (scopes: ReadonlyArray) => + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: () => + Effect.succeed({ + sessionId: AuthSessionId.make("auth-session:crew-stop"), + subject: "crew-stop-test", + method: "bearer-access-token", + scopes: scopes as never, + }), + }); + +it("stops a crew for operators only and reports each seat", async () => { + const calls: Array<{ callerParticipantId: unknown; crewInstanceId: string; ids: string[] }> = []; + const stops = Layer.mock(CrewStopService)({ + stop: (input) => { + calls.push({ + callerParticipantId: input.callerParticipantId, + crewInstanceId: input.crewInstanceId, + ids: ["builder", "critic"].map((seat) => input.commandIds(seat).interruptCommandId), + }); + return input.crewInstanceId === "crew:1" + ? Effect.succeed({ + crewInstanceId: "crew:1", + members: [ + { + seatName: "builder", + participantId: ParticipantId.make("agent:j5:a2a:thread:builder"), + result: "interrupt_requested" as const, + }, + { + seatName: "critic", + participantId: ParticipantId.make("agent:j5:a2a:thread:critic"), + result: "already_idle" as const, + }, + ], + }) + : Effect.fail(new CrewStopNotFoundError({ crewInstanceId: input.crewInstanceId })); + }, + }); + const routes = (scopes: ReadonlyArray) => + makeCrewStopHttpRouteLayer(path).pipe( + Layer.provide(stops), + Layer.provideMerge(authWith(scopes)), + Layer.provide(NodeServices.layer), + Layer.provide(HttpServer.layerServices), + ); + const operator = HttpRouter.toWebHandler( + routes([AuthOrchestrationReadScope, AuthOrchestrationOperateScope]), + { disableLogger: true }, + ); + const reader = HttpRouter.toWebHandler(routes([AuthOrchestrationReadScope]), { + disableLogger: true, + }); + try { + const stopped = await operator.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:1" }), + }), + ); + assert.equal(stopped.status, 200); + const body = (await stopped.json()) as { members: Array<{ seat: string; result: string }> }; + assert.deepStrictEqual( + body.members.map((member) => [member.seat, member.result]), + [ + ["builder", "interrupt_requested"], + ["critic", "already_idle"], + ], + ); + // A person is not a participant: no Captain check, and per-seat ids differ per seat. + assert.isNull(calls[0]?.callerParticipantId); + assert.notEqual(calls[0]?.ids[0], calls[0]?.ids[1]); + + const missing = await operator.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:nope" }), + }), + ); + assert.equal(missing.status, 404); + + const malformed = await operator.handler( + new Request(`http://environment.test${path}`, { method: "POST", body: "{}" }), + ); + assert.equal(malformed.status, 400); + + const forbidden = await reader.handler( + new Request(`http://environment.test${path}`, { + method: "POST", + body: JSON.stringify({ crewInstanceId: "crew:1" }), + }), + ); + assert.equal(forbidden.status, 403); + assert.lengthOf(calls, 2); + } finally { + await operator.dispose(); + await reader.dispose(); + } +}); diff --git a/apps/server/src/j5/a2a/CrewStopHttp.ts b/apps/server/src/j5/a2a/CrewStopHttp.ts new file mode 100644 index 0000000000000..7a606377cbe2c --- /dev/null +++ b/apps/server/src/j5/a2a/CrewStopHttp.ts @@ -0,0 +1,103 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import { CommandId } from "@t3tools/contracts"; +import { CrewStopRequest, CrewStopResponse } from "@t3tools/contracts/j5"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import { HttpRouter, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"; + +import { annotateEnvironmentRequest } from "../../auth/http.ts"; +import { authenticateOperate, invalidRequest, jsonBody } from "./ClientReadsHttp.ts"; +import { CrewStopService } from "./CrewStopService.ts"; +import { crewSeatRequestKey, lifecycleCommandId } from "./spawnIds.ts"; + +export const CREW_STOP_PATH = "/api/j5/a2a/crews/stop"; +const HUMAN_STOP_SESSION = "j5-crew-stop-human"; + +const decodeStop = Schema.decodeUnknownEffect(CrewStopRequest); +const encodeStop = Schema.encodeEffect(CrewStopResponse); + +/** + * The person's Stop crew control (Fleet crew header, Captain's sidebar expander). Operate scope, + * like answering an ask: the person is not a Crew participant, so there is no Captain check; the + * service interrupts running seats and touches nothing else. Each click is one stop, keyed by a + * fresh request id, so two clicks cannot share command ids. + */ +export const makeCrewStopHttpRouteLayer = (path: HttpRouter.PathInput) => + Layer.unwrap( + Effect.gen(function* () { + const stops = yield* CrewStopService; + const crypto = yield* Crypto.Crypto; + return HttpRouter.add( + "POST", + path, + Effect.gen(function* () { + yield* annotateEnvironmentRequest("j5.a2a.crews.stop"); + yield* authenticateOperate; + const body = yield* jsonBody; + if (Result.isFailure(body)) return invalidRequest("The request body must be JSON."); + const decoded = yield* Effect.result(decodeStop(body.success)); + if (Result.isFailure(decoded)) return invalidRequest("crewInstanceId is required."); + const requestKey = yield* crypto.randomUUIDv4; + const outcome = yield* Effect.result( + stops + .stop({ + callerParticipantId: null, + squadronId: null, + crewInstanceId: decoded.success.crewInstanceId, + commandIds: (seatName) => ({ + interruptCommandId: CommandId.make( + lifecycleCommandId({ + providerSessionId: HUMAN_STOP_SESSION, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "stop-crew-interrupt", + }), + ), + }), + }) + .pipe( + Effect.flatMap((result) => + encodeStop({ + crewInstanceId: result.crewInstanceId, + members: result.members.map((member) => ({ + seat: member.seatName, + participantId: member.participantId, + result: member.result, + })), + }), + ), + ), + ); + if (Result.isSuccess(outcome)) return HttpServerResponse.jsonUnsafe(outcome.success); + const failure = outcome.failure; + const status = + failure._tag === "CrewStopNotFoundError" + ? 404 + : failure._tag === "CrewStopRequestError" + ? 409 + : 500; + if (status === 500) yield* Effect.logError("J5 crew stop failed", { cause: failure }); + return HttpServerResponse.jsonUnsafe( + { + error: failure._tag, + message: status === 500 ? "Stopping the crew failed." : failure.message, + }, + { status }, + ); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), + ); + }), + ); + +// The authenticated aggregate carries no Crypto of its own; the route brings the Node one along. +export const crewStopHttpRouteLayer = makeCrewStopHttpRouteLayer(CREW_STOP_PATH).pipe( + Layer.provide(NodeCrypto.layer), +); diff --git a/apps/server/src/j5/a2a/CrewStopService.test.ts b/apps/server/src/j5/a2a/CrewStopService.test.ts new file mode 100644 index 0000000000000..effb4dd7d5873 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewStopService.test.ts @@ -0,0 +1,174 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + ProjectId, + ThreadId, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { + AgentCrewInstanceService, + layer as crewInstanceLayer, +} from "./AgentCrewInstanceService.ts"; +import { CrewStopService, layer as crewStopLayer } from "./CrewStopService.ts"; +import { participantIdForThread } from "./HomeRegistrar.ts"; +import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; +import { runJ5A2AMigrations } from "./Migrations.ts"; +import { SquadronId } from "./contracts.ts"; + +const squadronId = SquadronId.make("squadron:crew-stop"); +const captainThread = ThreadId.make("thread:captain"); +const builderThread = ThreadId.make("thread:builder"); +const criticThread = ThreadId.make("thread:critic"); +const retiredThread = ThreadId.make("thread:retired"); +const createdAt = DateTime.makeUnsafe("2026-09-14T20:00:00.000Z"); + +const projection = (threadId: ThreadId): OrchestrationV2ThreadProjection => + ({ + thread: { + id: threadId, + projectId: ProjectId.make("project:crew-stop"), + archivedAt: threadId === retiredThread ? "2026-09-14T19:00:00.000Z" : null, + }, + runs: [], + }) as unknown as OrchestrationV2ThreadProjection; + +it.effect("interrupts only running seats, for the Captain or a person, and nobody else", () => + Effect.gen(function* () { + const context = yield* Layer.build( + Layer.mergeAll(ledgerLayer, crewInstanceLayer).pipe( + Layer.provideMerge(NodeSqliteClient.layerMemory()), + ), + ); + yield* runJ5A2AMigrations().pipe(Effect.provide(context)); + yield* Context.get(context, A2ALedger).createSquadron({ + squadron: { id: squadronId, name: "Stop", createdAt: DateTime.formatIso(createdAt) }, + }); + const captain = participantIdForThread(captainThread); + yield* Context.get(context, AgentCrewInstanceService).record({ + id: "crew:stop", + squadronId, + captainParticipantId: captain, + captainThreadId: captainThread, + displayName: "Stop Crew", + brief: "Work.", + createdAt: DateTime.formatIso(createdAt), + members: [ + { + seatName: "builder", + agentId: "builder", + participantId: participantIdForThread(builderThread), + threadId: builderThread, + reason: null, + }, + { + seatName: "critic", + agentId: "critic", + participantId: participantIdForThread(criticThread), + threadId: criticThread, + reason: null, + }, + { + seatName: "retired", + agentId: "scout", + participantId: participantIdForThread(retiredThread), + threadId: retiredThread, + reason: null, + }, + ], + }); + const interrupts = yield* Ref.make>([]); + const layer = crewStopLayer.pipe( + Layer.provideMerge( + Layer.mock(ThreadManagementService)({ + getThreadProjection: (threadId) => Effect.succeed(projection(threadId)), + // Only the builder has a turn to interrupt. + interruptThread: (input) => + Ref.update(interrupts, (items) => [ + ...items, + `${input.threadId}:${input.commandId}`, + ]).pipe( + Effect.as( + input.threadId === builderThread + ? ({ type: "interrupt_requested" } as never) + : ({ type: "already_idle" } as never), + ), + ), + }), + ), + Layer.provideMerge(Layer.succeedContext(context)), + ); + yield* Effect.gen(function* () { + const service = yield* CrewStopService; + const commandIds = (seatName: string) => ({ + interruptCommandId: CommandId.make(`stop:${seatName}`), + }); + + const stopped = yield* service.stop({ + callerParticipantId: captain, + squadronId, + crewInstanceId: "crew:stop", + commandIds, + }); + assert.deepStrictEqual( + stopped.members.map(({ seatName, result }) => [seatName, result]), + [ + ["builder", "interrupt_requested"], + ["critic", "already_idle"], + ["retired", "archived"], + ], + ); + // The archived seat is never interrupted; live seats use the caller's deterministic ids. + assert.deepStrictEqual(yield* Ref.get(interrupts), [ + `${builderThread}:stop:builder`, + `${criticThread}:stop:critic`, + ]); + + // A person stops the same Crew without a participant id. + const byHuman = yield* service.stop({ + callerParticipantId: null, + squadronId: null, + crewInstanceId: "crew:stop", + commandIds, + }); + assert.lengthOf(byHuman.members, 3); + + // A member, or any other agent, is refused and pointed at the Captain. + const refused = yield* service + .stop({ + callerParticipantId: participantIdForThread(builderThread), + squadronId, + crewInstanceId: "crew:stop", + commandIds, + }) + .pipe(Effect.flip); + assert.equal(refused._tag, "CrewStopRequestError"); + assert.include(refused.message, "only its Captain or the human"); + const wrongSquadron = yield* service + .stop({ + callerParticipantId: captain, + squadronId: SquadronId.make("squadron:other"), + crewInstanceId: "crew:stop", + commandIds, + }) + .pipe(Effect.flip); + assert.include(wrongSquadron.message, "lives in Squadron"); + const missing = yield* service + .stop({ + callerParticipantId: null, + squadronId: null, + crewInstanceId: "crew:nope", + commandIds, + }) + .pipe(Effect.flip); + assert.equal(missing._tag, "CrewStopNotFoundError"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/j5/a2a/CrewStopService.ts b/apps/server/src/j5/a2a/CrewStopService.ts new file mode 100644 index 0000000000000..b22e8256f18b3 --- /dev/null +++ b/apps/server/src/j5/a2a/CrewStopService.ts @@ -0,0 +1,164 @@ +import type { CommandId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; +import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; +import type { ParticipantId, SquadronId } from "./contracts.ts"; + +export type CrewSeatStopResult = "interrupt_requested" | "already_idle" | "archived"; + +export interface StopCrewInput { + /** The Captain calling over MCP, or null when a person stops the Crew from the app. */ + readonly callerParticipantId: ParticipantId | null; + /** The caller's Squadron when an agent calls; the Crew must live there. */ + readonly squadronId: SquadronId | null; + readonly crewInstanceId: string; + /** Deterministic per seat so a retried stop cannot interrupt twice. */ + readonly commandIds: (seatName: string) => { readonly interruptCommandId: CommandId }; +} + +export interface StopCrewOutcome { + readonly crewInstanceId: string; + readonly members: ReadonlyArray<{ + readonly seatName: string; + readonly participantId: ParticipantId; + readonly result: CrewSeatStopResult; + }>; +} + +export class CrewStopNotFoundError extends Data.TaggedError("CrewStopNotFoundError")<{ + readonly crewInstanceId: string; +}> { + override get message() { + return `Crew ${this.crewInstanceId} does not exist.`; + } +} + +export class CrewStopRequestError extends Data.TaggedError("CrewStopRequestError")<{ + readonly detail: string; + readonly nextStep: string; +}> { + override get message() { + return this.detail; + } +} + +export class CrewStopOperationError extends Data.TaggedError("CrewStopOperationError")<{ + readonly phase: string; + readonly seatName: string | null; + readonly cause: unknown; +}> { + override get message() { + return `Stopping the crew failed while ${this.phase}${this.seatName === null ? "" : ` (seat ${this.seatName})`}: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`; + } +} + +export type CrewStopError = CrewStopNotFoundError | CrewStopRequestError | CrewStopOperationError; + +export interface CrewStopServiceShape { + /** + * Interrupt every seat with a running turn; idle seats are untouched, nothing settles or + * archives, and the Crew stays live so any seat can be messaged again. Only the Captain or a + * person may stop a Crew: stopping is a controller's act, and a member owns nothing about its + * crewmates' turns (Bryant, 2026-09-14). + */ + readonly stop: (input: StopCrewInput) => Effect.Effect; +} + +export class CrewStopService extends Context.Service()( + "t3/j5/a2a/CrewStopService", +) {} + +export const layer = Layer.effect( + CrewStopService, + Effect.gen(function* () { + const crews = yield* AgentCrewInstanceService; + const threads = yield* ThreadManagementService; + + const stop: CrewStopServiceShape["stop"] = (input) => + Effect.gen(function* () { + const instance = yield* crews + .read(input.crewInstanceId) + .pipe( + Effect.mapError( + (cause) => + new CrewStopOperationError({ phase: "reading the crew", seatName: null, cause }), + ), + ); + if (instance === null) + return yield* new CrewStopNotFoundError({ crewInstanceId: input.crewInstanceId }); + if (input.squadronId !== null && instance.squadronId !== input.squadronId) + return yield* new CrewStopRequestError({ + detail: `Crew ${instance.id} lives in Squadron ${instance.squadronId}, not ${input.squadronId}.`, + nextStep: `Retry with squadron_id=${instance.squadronId}.`, + }); + if ( + input.callerParticipantId !== null && + instance.captainParticipantId !== input.callerParticipantId + ) + return yield* new CrewStopRequestError({ + detail: `Crew ${instance.id} is commanded by ${instance.captainParticipantId}; only its Captain or the human may stop it.`, + nextStep: + "Ask the Captain with send_message, or stop one agent you command with stop_agent.", + }); + if (instance.archivedAt !== null) + return yield* new CrewStopRequestError({ + detail: `Crew ${instance.id} was archived at ${instance.archivedAt}; there is nothing running to stop.`, + nextStep: "Nothing to do.", + }); + const members = yield* Effect.forEach( + instance.members, + (member) => + Effect.gen(function* () { + const projection = yield* threads.getThreadProjection(member.threadId).pipe( + Effect.mapError( + (cause) => + new CrewStopOperationError({ + phase: "reading a seat", + seatName: member.seatName, + cause, + }), + ), + ); + if (projection.thread.archivedAt !== null) + return { + seatName: member.seatName, + participantId: member.participantId, + result: "archived" as const, + }; + const result = yield* threads + .interruptThread({ + projectId: projection.thread.projectId, + commandId: input.commandIds(member.seatName).interruptCommandId, + threadId: member.threadId, + }) + .pipe( + Effect.mapError( + (cause) => + new CrewStopOperationError({ + phase: "interrupting a seat", + seatName: member.seatName, + cause, + }), + ), + ); + return { + seatName: member.seatName, + participantId: member.participantId, + result: + result.type === "interrupt_requested" + ? ("interrupt_requested" as const) + : ("already_idle" as const), + }; + }), + { concurrency: 1 }, + ); + return { crewInstanceId: instance.id, members }; + }); + + return CrewStopService.of({ stop }); + }), +); diff --git a/apps/server/src/j5/a2a/FleetReadsHttp.ts b/apps/server/src/j5/a2a/FleetReadsHttp.ts index a41ee9ef9f9f3..32fd3eee53834 100644 --- a/apps/server/src/j5/a2a/FleetReadsHttp.ts +++ b/apps/server/src/j5/a2a/FleetReadsHttp.ts @@ -8,7 +8,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { annotateEnvironmentRequest } from "../../auth/http.ts"; import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; -import { authenticateClientRead } from "./ClientReadsHttp.ts"; +import { authenticateClientRead, jsonBody } from "./ClientReadsHttp.ts"; import { A2ALedger } from "./LedgerService.ts"; import { ParticipantPlacementService } from "./PlacementService.ts"; import { SquadronId } from "./contracts.ts"; @@ -25,6 +25,7 @@ export type FleetAgent = J5Contracts.FleetAgent; export type FleetResponse = J5Contracts.FleetResponse; const encodeResponse = Schema.encodeEffect(FleetResponse); +const decodeRequest = Schema.decodeUnknownEffect(J5Contracts.FleetReadRequest); /** Pure projection from placement rows, live Crews, and open-ask counts to one Squadron. */ export const projectFleetSquadron = (input: { @@ -159,7 +160,13 @@ export const makeFleetReadsHttpRouteLayer = (path: HttpRouter.PathInput) => Effect.gen(function* () { yield* annotateEnvironmentRequest("j5.a2a.clientReads.fleet"); yield* authenticateClientRead; - const read = yield* Effect.result(readFleet(false).pipe(Effect.flatMap(encodeResponse))); + const body = yield* jsonBody; + const decoded = Result.isFailure(body) + ? undefined + : Result.getOrUndefined(yield* Effect.result(decodeRequest(body.success))); + const read = yield* Effect.result( + readFleet(decoded?.includeRetired === true).pipe(Effect.flatMap(encodeResponse)), + ); if (Result.isFailure(read)) { yield* Effect.logError("J5 fleet read failed", { cause: read.failure }); return HttpServerResponse.jsonUnsafe( diff --git a/apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts b/apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts index ae39be6f91ee1..fb5da6759fc93 100644 --- a/apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts +++ b/apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts @@ -1,7 +1,9 @@ import * as Layer from "effect/Layer"; import { agentCrewReadsHttpRouteLayer } from "./AgentCrewReadsHttp.ts"; +import { crewArchiveHttpRouteLayer } from "./CrewArchiveHttp.ts"; import { crewProposalsHttpRouteLayer } from "./CrewProposalsHttp.ts"; +import { crewStopHttpRouteLayer } from "./CrewStopHttp.ts"; import { fleetReadsHttpRouteLayer } from "./FleetReadsHttp.ts"; import { spawnedChildrenHttpRouteLayer } from "./SpawnedChildrenHttp.ts"; import { @@ -26,7 +28,9 @@ import { layer as artifactWorkspaceLayer } from "../artifacts/ArtifactWorkspace. */ export const j5AuthenticatedRoutesLayer = Layer.mergeAll( agentCrewReadsHttpRouteLayer, + crewArchiveHttpRouteLayer, crewProposalsHttpRouteLayer, + crewStopHttpRouteLayer, fleetReadsHttpRouteLayer, spawnedChildrenHttpRouteLayer, artifactHttpRouteLayer, diff --git a/apps/server/src/j5/a2a/PreArchiveFactsHttp.test.ts b/apps/server/src/j5/a2a/PreArchiveFactsHttp.test.ts index 871b5c28818bc..69a96589cfcb7 100644 --- a/apps/server/src/j5/a2a/PreArchiveFactsHttp.test.ts +++ b/apps/server/src/j5/a2a/PreArchiveFactsHttp.test.ts @@ -5,6 +5,8 @@ import * as Layer from "effect/Layer"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; +import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; +import { ArchiveCrewService } from "./ArchiveCrewService.ts"; import { A2AArchiveFacts, A2AArchiveFactsError } from "./ArchiveFactsService.ts"; import { PRE_ARCHIVE_FACTS_PATH, preArchiveFactsHttpRouteLayer } from "./PreArchiveFactsHttp.ts"; @@ -43,8 +45,46 @@ it("returns the pre-archive facts without turning a failed read into a clean arc scopes: [AuthOrchestrationReadScope], }), }); + const archiveCrews = Layer.mock(ArchiveCrewService)({ + readCaptainFacts: (input) => + Effect.succeed([ + { + instance: { + id: "crew:pre-archive-http", + squadronId: input.squadronId, + captainParticipantId: input.captainParticipantId, + captainThreadId: threadId, + displayName: "Review Pair", + brief: "Review the release.", + version: 1, + createdAt: "2026-09-14T10:00:00.000Z", + archivedAt: null, + members: [], + }, + facts: { + members: [ + { + seatName: "critic", + participantId: "agent:critic" as never, + threadId: ThreadId.make("thread:critic"), + alreadyArchived: false, + facts: { + openExchanges: [], + runningTurn: { runId: "run:critic" as never, status: "running" }, + }, + }, + ], + }, + }, + ]), + }); + const crewInstances = Layer.mock(AgentCrewInstanceService)({ + findMembership: () => Effect.succeed(null), + }); const routes = preArchiveFactsHttpRouteLayer.pipe( Layer.provide(archiveFacts), + Layer.provide(archiveCrews), + Layer.provide(crewInstances), Layer.provideMerge(auth), Layer.provide(HttpServer.layerServices), ); @@ -70,6 +110,17 @@ it("returns the pre-archive facts without turning a failed read into a clean arc archived: false, openExchanges: [], placementSubtree: { state: "unknown", reason: "placement-query-failed" }, + // A Captain's live Crews ride along seat by seat: the Captain is never archived alone. + liveCrews: [ + { + crewInstanceId: "crew:pre-archive-http", + crewName: "Review Pair", + seats: [ + { seat: "critic", participantId: "agent:critic", runningTurn: true, openAsks: 0 }, + ], + }, + ], + crewSeat: null, }); failRead = true; diff --git a/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts b/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts index 751fed8f897e2..fcfd02102239d 100644 --- a/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts +++ b/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts @@ -17,7 +17,12 @@ import { failEnvironmentInternal, failEnvironmentScopeRequired, } from "../../auth/http.ts"; -import { A2AArchiveFacts } from "./ArchiveFactsService.ts"; +import type { PreArchiveCrewSeat, PreArchiveLiveCrew } from "@t3tools/contracts/j5"; + +import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; +import { ArchiveCrewService, type ArchiveCrewCaptainFacts } from "./ArchiveCrewService.ts"; +import { A2AArchiveFacts, type ThreadPreArchiveFacts } from "./ArchiveFactsService.ts"; +import type { ParticipantId } from "./contracts.ts"; export const PRE_ARCHIVE_FACTS_PATH = "/api/j5/a2a/pre-archive-facts"; @@ -59,10 +64,62 @@ const operationFailure = (cause: unknown) => ), ); -/** AR2's human-facing, authenticated read; it performs no archive mutation. */ +/** What the dialog shows for a Crew the archived agent commands: each seat's consequences. */ +const projectLiveCrew = (entry: ArchiveCrewCaptainFacts): PreArchiveLiveCrew => ({ + crewInstanceId: entry.instance.id, + crewName: entry.instance.displayName, + seats: entry.facts.members.map((member) => ({ + seat: member.seatName, + participantId: member.participantId, + runningTurn: member.facts.runningTurn !== null, + openAsks: member.facts.openExchanges.length, + })), +}); + +/** + * AR2's human-facing, authenticated read; it performs no archive mutation. A registered agent's + * facts carry its Crew relations, because a Captain is never archived alone and a seat is never + * archived one by one (Crews AC16, AC17): `liveCrews` are the Crews it commands, seat by seat, + * which the lifecycle cascade retires as units when the archive commits, and `crewSeat` is the + * seat it holds, which the web refuses to archive alone. A failed Crew read is `null`, shown as + * "couldn't check", never as no Crews. + */ export const preArchiveFactsHttpRouteLayer = Layer.unwrap( Effect.gen(function* () { const archiveFacts = yield* A2AArchiveFacts; + const archiveCrews = yield* ArchiveCrewService; + const crews = yield* AgentCrewInstanceService; + const readCrewSeat = (participantId: ParticipantId): Effect.Effect => + Effect.gen(function* () { + const membership = yield* crews.findMembership(participantId); + if (membership === null) return null; + const instance = yield* crews.read(membership.crewInstanceId); + if (instance === null || instance.archivedAt !== null) return null; + return { + crewInstanceId: instance.id, + crewName: instance.displayName, + seat: membership.seatName, + }; + }).pipe(Effect.orElseSucceed(() => null)); + const withLiveCrews = (facts: ThreadPreArchiveFacts): Effect.Effect => + facts.state !== "registered" + ? Effect.succeed(facts) + : Effect.all({ + liveCrews: archiveCrews + .readCaptainFacts({ + squadronId: facts.squadronId, + captainParticipantId: facts.participantId, + }) + .pipe( + Effect.map((entries) => entries.map(projectLiveCrew)), + Effect.catchCause((cause) => + Effect.logWarning("J5 A2A pre-archive crew read failed", { cause }).pipe( + Effect.as(null), + ), + ), + ), + crewSeat: readCrewSeat(facts.participantId), + }).pipe(Effect.map((crewFacts) => ({ ...facts, ...crewFacts }))); const route = HttpRouter.add( "POST", PRE_ARCHIVE_FACTS_PATH, @@ -74,7 +131,9 @@ export const preArchiveFactsHttpRouteLayer = Layer.unwrap( if (Result.isFailure(body)) return requestFailure("The request body must be JSON."); const decoded = yield* Effect.result(decodePreArchiveFactsRequest(body.success)); if (Result.isFailure(decoded)) return requestFailure("A valid threadId is required."); - const result = yield* Effect.result(archiveFacts.readForThread(decoded.success.threadId)); + const result = yield* Effect.result( + archiveFacts.readForThread(decoded.success.threadId).pipe(Effect.flatMap(withLiveCrews)), + ); if (Result.isSuccess(result)) return HttpServerResponse.jsonUnsafe(result.success); return yield* operationFailure(result.failure); }).pipe( diff --git a/apps/server/src/j5/a2a/ThreadHomesHttp.test.ts b/apps/server/src/j5/a2a/ThreadHomesHttp.test.ts index 17c8695b133c2..5baa9d3bafbfe 100644 --- a/apps/server/src/j5/a2a/ThreadHomesHttp.test.ts +++ b/apps/server/src/j5/a2a/ThreadHomesHttp.test.ts @@ -9,6 +9,8 @@ import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; import { AgentCrewProposalService } from "./AgentCrewProposalService.ts"; import { CrewProposalService } from "./CrewProposalService.ts"; +import { ArchiveCrewService } from "./ArchiveCrewService.ts"; +import { CrewStopService } from "./CrewStopService.ts"; import { ParticipantPlacementService } from "./PlacementService.ts"; import * as EnvironmentAuth from "../../auth/EnvironmentAuth.ts"; import * as ServerConfig from "../../config.ts"; @@ -116,6 +118,8 @@ it("wires the authenticated aggregate's thread-homes path without a parallel rou Layer.mock(AgentCrewInstanceService)({}), Layer.mock(AgentCrewProposalService)({}), Layer.mock(CrewProposalService)({}), + Layer.mock(CrewStopService)({}), + Layer.mock(ArchiveCrewService)({}), Layer.mock(ParticipantPlacementService)({}), ), ), diff --git a/apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts b/apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts index 371b9116e76db..bd66617ebb32f 100644 --- a/apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts +++ b/apps/server/src/j5/a2a/mcp/claudeAllowedTools.ts @@ -24,6 +24,8 @@ export const J5_CLAUDE_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__task_status", "mcp__t3-code__task_cancel", "mcp__t3-code__stop_agent", + "mcp__t3-code__stop_crew", "mcp__t3-code__archive_agent", + "mcp__t3-code__archive_crew", "mcp__t3-code__clear_own_ask", ]; diff --git a/apps/server/src/j5/a2a/mcp/codexToolApproval.ts b/apps/server/src/j5/a2a/mcp/codexToolApproval.ts index 184c8c24bd9e7..a2fa693060ed1 100644 --- a/apps/server/src/j5/a2a/mcp/codexToolApproval.ts +++ b/apps/server/src/j5/a2a/mcp/codexToolApproval.ts @@ -32,7 +32,9 @@ export const J5_CODEX_PREAPPROVED_TOOLS: ReadonlyArray = [ "task_status", "task_cancel", "stop_agent", + "stop_crew", "archive_agent", + "archive_crew", "clear_own_ask", ]; diff --git a/apps/server/src/j5/a2a/mcp/handlers.test.ts b/apps/server/src/j5/a2a/mcp/handlers.test.ts index e07f5cc936678..a5eb264ca0cb3 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.test.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.test.ts @@ -37,6 +37,12 @@ import { ArchiveAgentService, type ArchiveAgentInput, } from "../ArchiveAgentService.ts"; +import { + ArchiveCrewConfirmationRequiredError, + ArchiveCrewService, + type ArchiveCrewInput, +} from "../ArchiveCrewService.ts"; +import { CrewStopService } from "../CrewStopService.ts"; import { CrewProposalService, type CrewProposalOutcome } from "../CrewProposalService.ts"; import type { CrewProposal } from "../AgentCrewProposalService.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; @@ -65,6 +71,7 @@ import { J5ListParticipantsResult, J5Toolkit, type J5ArchiveAgentInput, + type J5ArchiveCrewInput, type J5SendMessageInput, type J5ProposeCrewInput, type J5RequestCrewMemberInput, @@ -133,6 +140,8 @@ const unusedLifecycleDependencies = Layer.mergeAll( findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({}), Layer.mock(SquadronJoinService)({}), @@ -183,6 +192,8 @@ it.effect("refuses self-target archive before target resolution or archive side findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({ archive: () => @@ -277,10 +288,13 @@ it.effect("projects a consequential archive refusal for exactly one active parti findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.succeed( ArchiveAgentService, ArchiveAgentService.of({ + readFacts: () => Effect.die("readFacts is outside this test"), archive: (input) => Ref.update(calls, (items) => [...items, input]).pipe( Effect.andThen( @@ -416,10 +430,13 @@ it.effect("uses consume-only history only after the active archive row is absent findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.succeed( ArchiveAgentService, ArchiveAgentService.of({ + readFacts: () => Effect.die("readFacts is outside this test"), archive: (input) => Ref.update(calls, (items) => [...items, input]).pipe( Effect.as("already_archived" as const), @@ -518,6 +535,8 @@ it.effect("refuses ambiguous historical archive identities without invoking the findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({ archive: () => Ref.update(archiveCalls, (count) => count + 1).pipe(Effect.as("archived")), @@ -560,6 +579,7 @@ it.effect("namespaces mutating-tool idempotency and sender identity from authent Effect.gen(function* () { assert.deepStrictEqual(Object.keys(J5Toolkit.tools).sort(), [ "archive_agent", + "archive_crew", "clear_own_ask", "join_squadron", "list_agents", @@ -570,6 +590,7 @@ it.effect("namespaces mutating-tool idempotency and sender identity from authent "send_message", "spawn_agent", "stop_agent", + "stop_crew", ]); const sends = yield* Ref.make>([]); const clears = yield* Ref.make>([]); @@ -1317,6 +1338,8 @@ it.effect("preflights home before creation and records facts before the one stab Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(AgentCrewInstanceService)({ findMembership: () => Effect.succeed(null) }), Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({}), Layer.mock(SquadronJoinService)({}), @@ -1466,6 +1489,8 @@ it.effect("refuses spawn before thread creation when the caller has no home", () Layer.mock(ProviderRegistry)({ getProviders: Effect.succeed([]) }), Layer.mock(AgentCrewInstanceService)({ findMembership: () => Effect.succeed(null) }), Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({}), Layer.mock(SquadronJoinService)({}), @@ -1640,6 +1665,8 @@ it.effect("spawns a saved agent as a Peer Agent only within its declared routes" Layer.mock(AgentCrewInstanceService)({ findMembership: () => Effect.succeed(null) }), Layer.mock(ParticipantPlacementService)({}), Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({}), ServerConfig.layerTest(process.cwd(), { prefix: "j5-mcp-spawn-persona-" }), @@ -1766,6 +1793,267 @@ it.effect("spawns a saved agent as a Peer Agent only within its declared routes" }), ); +it.effect("archives a crew only as a unit through its captain with one confirmation", () => + Effect.gen(function* () { + const squadronId = SquadronId.make("squadron:j5:mcp-archive-crew"); + const callerParticipantId = ParticipantId.make("agent:j5:mcp-archive-crew-captain"); + const memberParticipantId = ParticipantId.make("agent:j5:mcp-archive-crew-member"); + const otherCaptainId = ParticipantId.make("agent:j5:mcp-archive-crew-other-captain"); + const archiveCalls = yield* Ref.make>([]); + const singleArchiveCalls = yield* Ref.make(0); + const callerRow = { + squadronId, + participantId: callerParticipantId, + participant: { + kind: "agent" as const, + id: callerParticipantId, + threadId: invocation.threadId, + }, + archived: false, + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: false, + } satisfies ParticipantDirectoryRow; + const memberRow = { + squadronId, + participantId: memberParticipantId, + participant: { + kind: "agent" as const, + id: memberParticipantId, + threadId: ThreadId.make("thread:j5:mcp-archive-crew-member"), + }, + archived: false, + canReceiveMessage: true, + canOpenExchange: true, + acceptsUrgency: false, + } satisfies ParticipantDirectoryRow; + const facts = { + members: [ + { + seatName: "builder", + participantId: memberParticipantId, + threadId: memberRow.participant.threadId, + alreadyArchived: false, + facts: { + openExchanges: [ + { + exchangeId: ExchangeId.make("exchange:j5:mcp-archive-crew"), + direction: "inbound" as const, + replyObligation: "participant-owes-reply" as const, + counterpartyId: callerParticipantId, + intent: "Review the login fix", + urgency: null, + openedAt: DateTime.formatIso(createdAt), + }, + ], + runningTurn: null, + }, + }, + ], + }; + const dependencies = Layer.mergeAll( + Layer.mock(A2ASendService)({ + listParticipants: () => Effect.succeed([callerRow, memberRow]), + }), + Layer.mock(A2AHomeRegistrar)({}), + Layer.mock(A2ALedger)({}), + Layer.mock(SpawnCompositionService)({}), + Layer.mock(ThreadManagementService)({}), + Layer.mock(OrchestratorMcpService)({}), + Layer.mock(ProviderRegistry)({}), + Layer.mock(ParticipantPlacementService)({ + listParticipants: () => + Effect.succeed([ + { + squadronId, + participantId: memberParticipantId, + participant: memberRow.participant, + threadId: memberRow.participant.threadId, + provenance: { + kind: "spawned-by" as const, + spawnedByParticipantId: callerParticipantId, + source: "j5_spawn" as const, + }, + placementParentId: callerParticipantId, + }, + { + squadronId, + participantId: otherCaptainId, + participant: { + kind: "agent" as const, + id: otherCaptainId, + threadId: ThreadId.make("thread:j5:mcp-archive-crew-other-captain"), + }, + threadId: ThreadId.make("thread:j5:mcp-archive-crew-other-captain"), + provenance: { + kind: "spawned-by" as const, + spawnedByParticipantId: callerParticipantId, + source: "j5_spawn" as const, + }, + placementParentId: callerParticipantId, + }, + ]), + }), + Layer.mock(AgentCrewInstanceService)({ + findMembership: (participantId) => + Effect.succeed( + participantId === memberParticipantId + ? { crewInstanceId: "crew:j5:test", seatName: "builder" } + : null, + ), + listForCaptain: ({ captainParticipantId }) => + Effect.succeed( + captainParticipantId === otherCaptainId + ? [ + { + id: "crew:j5:other", + squadronId, + captainParticipantId: otherCaptainId, + captainThreadId: ThreadId.make("thread:j5:mcp-archive-crew-other-captain"), + displayName: "Other Crew", + brief: "b", + version: 1, + createdAt: DateTime.formatIso(createdAt), + archivedAt: null, + members: [], + }, + ] + : [], + ), + }), + Layer.mock(ArchiveAgentService)({ + archive: () => + Ref.update(singleArchiveCalls, (count) => count + 1).pipe(Effect.as("archived" as const)), + }), + Layer.mock(CrewProposalService)({}), + Layer.mock(ArchiveCrewService)({ + archive: (input) => + Ref.update(archiveCalls, (items) => [...items, input]).pipe( + Effect.andThen( + input.confirmationToken === undefined + ? Effect.fail( + new ArchiveCrewConfirmationRequiredError({ + facts, + confirmationToken: "crew-token", + }), + ) + : Effect.succeed({ + status: "archived" as const, + members: [ + { + seatName: "builder", + participantId: memberParticipantId, + result: "archived" as const, + }, + ], + }), + ), + ), + }), + Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), + Layer.mock(CrewStopService)({}), + NodeServices.layer, + ); + const layer = J5ToolkitHandlersLive.pipe(Layer.provideMerge(dependencies)); + + yield* Effect.gen(function* () { + const toolkit = yield* J5Toolkit; + const run = ( + tool: K, + args: K extends "archive_agent" ? J5ArchiveAgentInput : J5ArchiveCrewInput, + ) => + toolkit + .handle(tool, args as never) + .pipe( + Stream.unwrap, + Stream.run(Sink.last()), + Effect.flatMap(Effect.fromOption), + Effect.provideService(McpInvocationContext, invocation), + ); + const message = (response: { readonly result: unknown }) => + (response.result as { readonly message: string }).message; + + const memberArchive = yield* run("archive_agent", { + squadron_id: squadronId, + participant_id: memberParticipantId, + }); + assert.isTrue(memberArchive.isFailure); + assert.include(message(memberArchive), "crew members are never archived one by one"); + assert.include(message(memberArchive), "archive_crew with crew_instance_id=crew:j5:test"); + assert.equal(yield* Ref.get(singleArchiveCalls), 0); + + // A Captain with a live Crew is refused too: once archived, nobody could retire its seats. + const captainArchive = yield* run("archive_agent", { + squadron_id: squadronId, + participant_id: otherCaptainId, + }); + assert.isTrue(captainArchive.isFailure); + assert.include(message(captainArchive), "Captain of a live Crew"); + assert.include(message(captainArchive), "archive_crew with crew_instance_id=crew:j5:other"); + assert.equal(yield* Ref.get(singleArchiveCalls), 0); + + const refused = yield* run("archive_crew", { + squadron_id: squadronId, + crew_instance_id: "crew:j5:test", + client_request_id: "archive-crew-1", + }); + assert.isTrue(refused.isFailure); + const refusal = refused.result as { + readonly code: string; + readonly confirmation_token: string; + readonly members: ReadonlyArray<{ + readonly seat: string; + readonly participant_id: string; + readonly already_archived: boolean; + readonly open_exchanges: ReadonlyArray<{ readonly exchange_id: string }>; + readonly running_turn: unknown; + }>; + }; + assert.equal(refusal.code, "ArchiveCrewConfirmationRequiredError"); + assert.equal(refusal.confirmation_token, "crew-token"); + assert.deepStrictEqual( + refusal.members.map((member) => [ + member.seat, + member.participant_id, + member.already_archived, + member.open_exchanges.map(({ exchange_id }) => exchange_id), + member.running_turn, + ]), + [["builder", memberParticipantId, false, ["exchange:j5:mcp-archive-crew"], null]], + ); + assert.include(message(refused), "check with the human"); + + const confirmed = yield* run("archive_crew", { + squadron_id: squadronId, + crew_instance_id: "crew:j5:test", + client_request_id: "archive-crew-1", + confirmation_token: "crew-token", + }); + assert.isFalse(confirmed.isFailure, message(confirmed)); + assert.deepStrictEqual(confirmed.result, { + status: "archived", + crew_instance_id: "crew:j5:test", + members: [{ seat: "builder", participant_id: memberParticipantId, result: "archived" }], + }); + const calls = yield* Ref.get(archiveCalls); + assert.lengthOf(calls, 2); + assert.equal(calls[1]?.callerParticipantId, callerParticipantId); + assert.equal(calls[1]?.clientRequestKey, "archive-crew-1"); + const ids = calls[1]!.commandIds("builder"); + assert.include(ids.archiveCommandId, "archive-crew-thread"); + assert.include(ids.archiveCommandId, encodeURIComponent("archive-crew-1/seat/builder")); + assert.notEqual(ids.archiveCommandId, calls[1]!.commandIds("critic").archiveCommandId); + + const wrongSquadron = yield* run("archive_crew", { + squadron_id: SquadronId.make("squadron:j5:other"), + crew_instance_id: "crew:j5:test", + }); + assert.isTrue(wrongSquadron.isFailure); + assert.include(message(wrongSquadron), "archive_crew targeted squadron:j5:other"); + }).pipe(Effect.provide(layer)); + }), +); + it.effect("lists saved agents with purpose, policy, availability, and route", () => Effect.gen(function* () { const codex = personaProvider("codex", "codex", [ @@ -1784,6 +2072,8 @@ it.effect("lists saved agents with purpose, policy, availability, and route", () Layer.mock(ParticipantPlacementService)({}), Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), Layer.mock(ArchiveAgentService)({}), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), ServerConfig.layerTest(process.cwd(), { prefix: "j5-mcp-list-agents-" }), ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -1920,6 +2210,8 @@ it.effect("routes crew proposals through a captain that is not itself a crew mem Layer.mock(ParticipantPlacementService)({}), Layer.mock(A2ADeliveryWorker)({ notify: Effect.void }), Layer.mock(ArchiveAgentService)({}), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), NodeServices.layer, ); const layer = J5ToolkitHandlersLive.pipe(Layer.provideMerge(dependencies)); @@ -2097,6 +2389,8 @@ it.effect("stops exactly one placed agent without consulting or touching descend findMembership: () => Effect.succeed(null), listForCaptain: () => Effect.succeed([]), }), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), Layer.mock(ArchiveAgentService)({}), Layer.mock(SquadronJoinService)({}), diff --git a/apps/server/src/j5/a2a/mcp/handlers.ts b/apps/server/src/j5/a2a/mcp/handlers.ts index d45e48f00ee99..be964a9399ecf 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.ts @@ -26,7 +26,15 @@ import { type ArchiveAgentConsequenceFacts, type ArchiveAgentTarget, } from "../ArchiveAgentService.ts"; +import { + ArchiveCrewConfirmationRequiredError, + ArchiveCrewConfirmationStaleError, + ArchiveCrewPartialFailureError, + ArchiveCrewService, + type ArchiveCrewConsequenceFacts, +} from "../ArchiveCrewService.ts"; import { CrewProposalService, type CrewProposalOutcome } from "../CrewProposalService.ts"; +import { CrewStopService } from "../CrewStopService.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; import { A2AHomeRegistrar, participantIdForThread } from "../HomeRegistrar.ts"; import { A2ALedger } from "../LedgerService.ts"; @@ -36,6 +44,7 @@ import { SpawnCompositionService } from "../SpawnCompositionService.ts"; import { SquadronJoinService } from "../SquadronJoinService.ts"; import { SquadronProjectReferences } from "../SquadronProjectReferences.ts"; import { + crewSeatRequestKey, lifecycleCommandId, lifecycleId, spawnFirstTurnText, @@ -56,7 +65,12 @@ import { type StoredCommEvent, } from "../contracts.ts"; import type { ParticipantProvenanceView } from "../placementContracts.ts"; -import { J5Toolkit, type J5ArchiveAgentFailure, type J5McpFailure } from "./tools.ts"; +import { + J5Toolkit, + type J5ArchiveAgentFailure, + type J5ArchiveCrewFailure, + type J5McpFailure, +} from "./tools.ts"; class J5AgentToolStateError extends Data.TaggedError("J5AgentToolStateError")<{ readonly state: string; @@ -120,6 +134,37 @@ const archiveFailure = (error: unknown): J5ArchiveAgentFailure => { return base; }; +const projectCrewArchiveFacts = (facts: ArchiveCrewConsequenceFacts) => ({ + members: facts.members.map((member) => ({ + seat: member.seatName, + participant_id: member.participantId, + already_archived: member.alreadyArchived, + ...projectArchiveFacts(member.facts), + })), +}); + +const archiveCrewFailure = (error: unknown): J5ArchiveCrewFailure => { + const base = failure(error); + if ( + error instanceof ArchiveCrewConfirmationRequiredError || + error instanceof ArchiveCrewConfirmationStaleError + ) { + return { + ...base, + ...projectCrewArchiveFacts(error.facts), + confirmation_token: error.confirmationToken, + }; + } + if (error instanceof ArchiveCrewPartialFailureError) { + return { + ...base, + archived_seats: [...error.archivedSeats], + failed_seat: error.failedSeat, + }; + } + return base; +}; + const projectProvenance = (provenance: ParticipantProvenanceView) => { switch (provenance.kind) { case "spawned-by": @@ -240,7 +285,7 @@ const preflightSpawnCaller = Effect.fn("j5.a2a.mcp.preflightSpawnCaller")(functi const requireCallerSquadron = Effect.fn("j5.a2a.mcp.requireCallerSquadron")(function* ( scope: McpInvocationScope, squadronId: SquadronId, - command: "stop_agent" | "archive_agent", + command: "stop_agent" | "archive_agent" | "archive_crew" | "stop_crew", ) { const caller = yield* resolveCallerMembership(scope); if (caller.squadronId !== squadronId) { @@ -990,6 +1035,44 @@ const handlers = { ); } const target = yield* resolveArchiveTarget(input.squadron_id, input.participant_id); + const membership = yield* (yield* AgentCrewInstanceService) + .findMembership(target.participantId) + .pipe( + Effect.mapError((error) => + stateError( + `Crew membership for ${target.participantId} cannot be read: ${error.message}.`, + "Retry archive_agent once crew records are readable.", + ), + ), + ); + if (membership !== null) { + return yield* stateError( + `Participant ${target.participantId} is seat ${membership.seatName} of crew ${membership.crewInstanceId}; crew members are never archived one by one.`, + `Call archive_crew with crew_instance_id=${membership.crewInstanceId} to retire the whole Crew, or message the member instead.`, + ); + } + // Only the Captain may retire its Crew, so archiving the Captain first would leave live + // seats nobody can ever archive (Critic Q12, 2026-09-14). + const commanded = yield* (yield* AgentCrewInstanceService) + .listForCaptain({ + squadronId: input.squadron_id, + captainParticipantId: target.participantId, + }) + .pipe( + Effect.mapError((error) => + stateError( + `Crews commanded by ${target.participantId} cannot be read: ${error.message}.`, + "Retry archive_agent once crew records are readable.", + ), + ), + ); + const live = commanded.filter((crew) => crew.archivedAt === null); + if (live.length > 0) { + return yield* stateError( + `Participant ${target.participantId} is the Captain of ${live.length === 1 ? "a live Crew" : `${live.length} live Crews`} (${live.map(({ id }) => id).join(", ")}); a Captain is never archived while its Crew runs.`, + `Call archive_crew with crew_instance_id=${live[0]!.id} first, then archive_agent.`, + ); + } const requestKey = input.client_request_id ?? (yield* crypto.randomUUIDv4); const archivedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); return yield* (yield* ArchiveAgentService).archive({ @@ -1013,6 +1096,86 @@ const handlers = { }), }); }).pipe(Effect.mapError(archiveFailure)), + stop_crew: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const crypto = yield* Crypto.Crypto; + const caller = yield* requireCallerSquadron(scope, input.squadron_id, "stop_crew"); + const requestKey = input.client_request_id ?? (yield* crypto.randomUUIDv4); + const outcome = yield* (yield* CrewStopService) + .stop({ + callerParticipantId: caller.participantId, + squadronId: input.squadron_id, + crewInstanceId: input.crew_instance_id, + commandIds: (seatName) => ({ + interruptCommandId: lifecycleCommandId({ + providerSessionId: scope.providerSessionId, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "stop-crew-interrupt", + }), + }), + }) + .pipe( + Effect.mapError((error) => + stateError( + error.message, + error._tag === "CrewStopNotFoundError" + ? "Check crew_instance_id against the roster notice and retry." + : error._tag === "CrewStopRequestError" + ? error.nextStep + : "Retry stop_crew with the same client_request_id; seats already interrupted stay interrupted.", + ), + ), + ); + return { + crew_instance_id: outcome.crewInstanceId, + members: outcome.members.map((member) => ({ + seat: member.seatName, + participant_id: member.participantId, + result: member.result, + })), + }; + }).pipe(Effect.mapError(failure)), + archive_crew: (input) => + Effect.gen(function* () { + const scope = yield* McpInvocationContext; + const crypto = yield* Crypto.Crypto; + const caller = yield* requireCallerSquadron(scope, input.squadron_id, "archive_crew"); + const requestKey = input.client_request_id ?? (yield* crypto.randomUUIDv4); + const archivedAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const outcome = yield* (yield* ArchiveCrewService).archive({ + providerSessionId: scope.providerSessionId, + callerParticipantId: caller.participantId, + squadronId: input.squadron_id, + crewInstanceId: input.crew_instance_id, + clientRequestKey: requestKey, + ...(input.confirmation_token === undefined + ? {} + : { confirmationToken: input.confirmation_token }), + archivedAt, + commandIds: (seatName) => ({ + interruptCommandId: lifecycleCommandId({ + providerSessionId: scope.providerSessionId, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-interrupt", + }), + archiveCommandId: lifecycleCommandId({ + providerSessionId: scope.providerSessionId, + requestKey: crewSeatRequestKey(requestKey, seatName), + operation: "archive-crew-thread", + }), + }), + }); + return { + status: outcome.status, + crew_instance_id: input.crew_instance_id, + members: outcome.members.map((member) => ({ + seat: member.seatName, + participant_id: member.participantId, + result: member.result, + })), + }; + }).pipe(Effect.mapError(archiveCrewFailure)), } satisfies Parameters[0]; export const J5ToolkitHandlersLive = J5Toolkit.toLayer(handlers); diff --git a/apps/server/src/j5/a2a/mcp/registration.test.ts b/apps/server/src/j5/a2a/mcp/registration.test.ts index ac9651b15b4f7..81f00a378a7c2 100644 --- a/apps/server/src/j5/a2a/mcp/registration.test.ts +++ b/apps/server/src/j5/a2a/mcp/registration.test.ts @@ -10,6 +10,8 @@ import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import { AgentCrewInstanceService } from "../AgentCrewInstanceService.ts"; import { ArchiveAgentService } from "../ArchiveAgentService.ts"; +import { ArchiveCrewService } from "../ArchiveCrewService.ts"; +import { CrewStopService } from "../CrewStopService.ts"; import { CrewProposalService } from "../CrewProposalService.ts"; import { A2ADeliveryWorker } from "../DeliveryWorker.ts"; import { A2AHomeRegistrar } from "../HomeRegistrar.ts"; @@ -40,6 +42,8 @@ const Dependencies = Layer.mergeAll( Layer.mock(ArchiveAgentService)({}), Layer.mock(SquadronJoinService)({}), Layer.mock(SquadronProjectReferences)({}), + Layer.mock(ArchiveCrewService)({}), + Layer.mock(CrewStopService)({}), Layer.mock(CrewProposalService)({}), NodeServices.layer, ); @@ -54,6 +58,7 @@ it.effect("registers the exact composed production J5 orchestration surface", () const server = yield* McpServer.McpServer; assert.deepStrictEqual(server.tools.map(({ tool }) => tool.name).toSorted(), [ "archive_agent", + "archive_crew", "clear_own_ask", "delegate_task", "delete_scheduled_task", @@ -69,6 +74,7 @@ it.effect("registers the exact composed production J5 orchestration surface", () "send_message", "spawn_agent", "stop_agent", + "stop_crew", "t3_thread_list", "t3_thread_read", "task_cancel", diff --git a/apps/server/src/j5/a2a/mcp/tools.test.ts b/apps/server/src/j5/a2a/mcp/tools.test.ts index fcdc723098609..90dfd899fc755 100644 --- a/apps/server/src/j5/a2a/mcp/tools.test.ts +++ b/apps/server/src/j5/a2a/mcp/tools.test.ts @@ -7,6 +7,7 @@ import { J5OrchestratorSurface } from "./orchestratorSurface.ts"; import { J5_CLAUDE_MCP_ALLOWED_TOOLS } from "./claudeAllowedTools.ts"; import { J5ArchiveAgentTool, + J5ArchiveCrewTool, J5ProposeCrewTool, J5RequestCrewMemberTool, J5SendMessageTool, @@ -69,11 +70,17 @@ it("publishes the ratified single-target lifecycle contracts fail-closed", () => "request_crew_member", "spawn_agent", "stop_agent", + "stop_crew", "archive_agent", + "archive_crew", "clear_own_ask", "list_squadrons", "join_squadron", ]); + assert.include(J5ArchiveCrewTool.description ?? "", "only as a unit"); + assert.include(J5ArchiveCrewTool.description ?? "", "confirmation_token"); + assert.include(J5ArchiveCrewTool.description ?? "", "check with the human"); + assert.isTrue(Context.get(J5ArchiveCrewTool.annotations, Tool.Destructive)); // Proposals only file a human-gated request, and must say so: read-only Captains run with // approval policy never and have refused the brief when the gate looked like a shell approval. assert.isFalse(Context.get(J5ProposeCrewTool.annotations, Tool.Destructive)); diff --git a/apps/server/src/j5/a2a/mcp/tools.ts b/apps/server/src/j5/a2a/mcp/tools.ts index f4d29808b1e05..94c5ca8bc4143 100644 --- a/apps/server/src/j5/a2a/mcp/tools.ts +++ b/apps/server/src/j5/a2a/mcp/tools.ts @@ -17,6 +17,7 @@ import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagem import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { AgentCrewInstanceService } from "../AgentCrewInstanceService.ts"; import { ArchiveAgentService } from "../ArchiveAgentService.ts"; +import { ArchiveCrewService } from "../ArchiveCrewService.ts"; import { CREW_NAME_MAX_CHARS, CREW_NAME_PATTERN, @@ -26,6 +27,7 @@ import { CREW_TEXT_MAX_CHARS, } from "../crewLimits.ts"; import { CrewProposalService } from "../CrewProposalService.ts"; +import { CrewStopService } from "../CrewStopService.ts"; import { A2A_CLEAR_OWN_ASK_TOOL_DESCRIPTION, A2A_LIST_TOOL_DESCRIPTION, @@ -307,6 +309,65 @@ export const J5_JOIN_SQUADRON_DESCRIPTION = export const J5_LIST_SQUADRONS_DESCRIPTION = "The Squadron directory for this environment: every Squadron's squadron_id, name, and the project ids it references, plus your own thread's project id so you can see which Squadron can home you. Use it to obtain the exact squadron_id before join_squadron. Read-only."; +export const J5StopCrewInput = Schema.Struct({ + client_request_id: Schema.optional(NonEmptyString), + squadron_id: SquadronId, + crew_instance_id: NonEmptyString, +}); +export type J5StopCrewInput = typeof J5StopCrewInput.Type; + +export const J5StopCrewResult = Schema.Struct({ + crew_instance_id: NonEmptyString, + members: Schema.Array( + Schema.Struct({ + seat: NonEmptyString, + participant_id: ParticipantId, + result: Schema.Literals(["interrupt_requested", "already_idle", "archived"]), + }), + ), +}); + +export const J5_STOP_CREW_DESCRIPTION = + "Stop a Crew you command: interrupts the running turn of every seat now. Seats that are idle are untouched, nothing settles or is retired, and every seat can be messaged again afterwards. The result says, seat by seat, whether an interrupt was requested or the seat was already idle. Only the Captain may call this; a member escalates to its Captain instead. Requires your current squadron_id. Reuse client_request_id to retry safely."; + +export const J5ArchiveCrewInput = Schema.Struct({ + client_request_id: Schema.optional(NonEmptyString), + squadron_id: SquadronId, + crew_instance_id: NonEmptyString, + confirmation_token: Schema.optional(NonEmptyString), +}); +export type J5ArchiveCrewInput = typeof J5ArchiveCrewInput.Type; + +export const J5ArchiveCrewResult = Schema.Struct({ + status: J5ArchiveAgentResult, + crew_instance_id: NonEmptyString, + members: Schema.Array( + Schema.Struct({ + seat: NonEmptyString, + participant_id: ParticipantId, + result: J5ArchiveAgentResult, + }), + ), +}); + +export const J5ArchiveCrewMemberFacts = Schema.Struct({ + seat: NonEmptyString, + participant_id: ParticipantId, + already_archived: Schema.Boolean, + open_exchanges: Schema.Array(J5ArchiveAgentOpenExchangeFact), + running_turn: Schema.NullOr(J5ArchiveAgentRunningTurnFact), +}); + +export const J5ArchiveCrewFailure = Schema.Struct({ + code: Schema.String, + message: Schema.String, + members: Schema.optional(Schema.Array(J5ArchiveCrewMemberFacts)), + confirmation_token: Schema.optional(Schema.NullOr(Schema.String)), + archived_seats: Schema.optional(Schema.Array(NonEmptyString)), + failed_seat: Schema.optional(NonEmptyString), +}); +export type J5ArchiveCrewFailure = typeof J5ArchiveCrewFailure.Type; + export const J5_SPAWN_AGENT_DESCRIPTION = "Spawn a Peer Agent: a full-citizen teammate with its own top-level thread, starting on your brief as its first turn. It joins your Squadron, is placed under you, and records you as its immutable spawner; it is addressable the moment this returns. In your brief, tell the new agent what it should do first and whether it should reply to you. Choose provider, model, and reasoning for the work in the brief — see orchestrator_capabilities for what's available. To run a saved agent, set the `agent` parameter to its id: the spawn gets that saved agent's instructions and runtime policy, and provider, model, and reasoning must be one of that agent's declared routes. Reuse client_request_id to retry the same spawn safely."; @@ -325,6 +386,9 @@ export const J5_STOP_AGENT_DESCRIPTION = export const J5_ARCHIVE_AGENT_DESCRIPTION = "Archive one Peer Agent reversibly. Unarchive restores the same identity, but does not reopen Exchanges or replay cancelled messages. A clean archive — no open exchanges, no running turn — completes immediately. Otherwise the call refuses and lists exactly what archiving ends — the asks that will close, the turn that will stop — along with a confirmation_token; call again with that token to proceed. The archived agent leaves the active roster; its ledger and conversation stay readable forever. Requires your current squadron_id. Reuse client_request_id to retry safely."; +export const J5_ARCHIVE_CREW_DESCRIPTION = + "Retire a whole Crew you command, for good. Crews archive only as a unit — members are never retired one by one. A clean archive (no open exchanges, no running turns on any seat) completes immediately. Otherwise the call refuses and lists, seat by seat, exactly what archiving ends — who is waiting on which ask, which turns will stop — along with a confirmation_token. Before retrying with that token, check with the human: a Crew others are waiting on is a judgment call, and a successor Crew needs a fresh brief you write. Nothing is destroyed: worktrees, branches, and ledgers stay readable. Requires your current squadron_id. Reuse client_request_id to retry safely."; + const sendDependencies = [ McpInvocationContext.McpInvocationContext, A2ASendService, @@ -397,6 +461,23 @@ const archiveDependencies = [ A2ALedger, ParticipantPlacementService, ArchiveAgentService, + AgentCrewInstanceService, +]; + +const stopCrewDependencies = [ + McpInvocationContext.McpInvocationContext, + A2ASendService, + Crypto.Crypto, + A2ALedger, + CrewStopService, +]; + +const archiveCrewDependencies = [ + McpInvocationContext.McpInvocationContext, + A2ASendService, + Crypto.Crypto, + A2ALedger, + ArchiveCrewService, ]; export const J5SendMessageTool = Tool.make("send_message", { @@ -532,6 +613,34 @@ export const J5ArchiveAgentTool = Tool.make("archive_agent", { .annotate(Tool.Idempotent, false) .annotate(Tool.OpenWorld, false); +export const J5StopCrewTool = Tool.make("stop_crew", { + description: J5_STOP_CREW_DESCRIPTION, + parameters: J5StopCrewInput, + success: J5StopCrewResult, + failure: J5McpFailure, + failureMode: "return", + dependencies: stopCrewDependencies, +}) + .annotate(Tool.Title, "Stop a Crew's running seats") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + +export const J5ArchiveCrewTool = Tool.make("archive_crew", { + description: J5_ARCHIVE_CREW_DESCRIPTION, + parameters: J5ArchiveCrewInput, + success: J5ArchiveCrewResult, + failure: J5ArchiveCrewFailure, + failureMode: "return", + dependencies: archiveCrewDependencies, +}) + .annotate(Tool.Title, "Retire a Crew as a unit") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, false) + .annotate(Tool.OpenWorld, false); + export const J5ClearOwnAskTool = Tool.make("clear_own_ask", { description: A2A_CLEAR_OWN_ASK_TOOL_DESCRIPTION, parameters: J5ClearOwnAskInput, @@ -558,5 +667,7 @@ export const J5Toolkit = Toolkit.make( J5RequestCrewMemberTool, J5StopAgentTool, J5ArchiveAgentTool, + J5StopCrewTool, + J5ArchiveCrewTool, J5ClearOwnAskTool, ); diff --git a/apps/server/src/j5/a2a/runtimeLayer.test.ts b/apps/server/src/j5/a2a/runtimeLayer.test.ts index 443620ed25561..b7030d88c1538 100644 --- a/apps/server/src/j5/a2a/runtimeLayer.test.ts +++ b/apps/server/src/j5/a2a/runtimeLayer.test.ts @@ -93,7 +93,7 @@ const measureNestedRuntimeBuilds = (nested: "http" | "mcp") => ), ), Layer.provide(database), - // The crew launcher reads the saved-agent library through the file system. + // The crew artifact writer reaches the project artifacts directory through the file system. Layer.provide(NodeServices.layer), ), ); diff --git a/apps/server/src/j5/a2a/runtimeLayer.ts b/apps/server/src/j5/a2a/runtimeLayer.ts index 610507709972d..7c86f529b24d3 100644 --- a/apps/server/src/j5/a2a/runtimeLayer.ts +++ b/apps/server/src/j5/a2a/runtimeLayer.ts @@ -4,6 +4,7 @@ import { layer as artifactWorkspaceLayer } from "../artifacts/ArtifactWorkspace. import { layer as agentCrewInstanceLayer } from "./AgentCrewInstanceService.ts"; import { layer as archiveFactsLayer, placementFactsLayer } from "./ArchiveFactsService.ts"; import { layer as archiveAgentLayer } from "./ArchiveAgentService.ts"; +import { layer as archiveCrewLayer } from "./ArchiveCrewService.ts"; import { layer as agentCrewProposalLayer } from "./AgentCrewProposalService.ts"; import { manualLayer as crewLaunchReporterLayer } from "./CrewLaunchReporter.ts"; import { layer as crewLaunchLayer } from "./CrewLaunchService.ts"; @@ -12,6 +13,8 @@ import { layer as crewProposalLayer, } from "./CrewProposalService.ts"; import { layer as crewSeatFinishNotifierLayer } from "./CrewSeatFinishNotifier.ts"; +import { layer as captainArchiveCascadeLayer } from "./CrewCaptainArchiveCascade.ts"; +import { layer as crewStopLayer } from "./CrewStopService.ts"; import { layer as deliveryWorkerLayer } from "./DeliveryWorker.ts"; import { live as deliveryTransportLayer } from "./DeliveryTransport.ts"; import { @@ -86,6 +89,10 @@ export const makeJ5A2AAuxiliaryLayer = ( const agentHandoffNudgeWorkerProvided = agentHandoffNudgeWorkerLayer.pipe( Layer.provide(agentHandoffNudgeQueueLayer), ); + const archiveCrewProvided = archiveCrewLayer.pipe( + Layer.provideMerge(archiveAgentProvided), + Layer.provideMerge(agentCrewInstanceLayer), + ); const crewLaunchProvided = crewLaunchLayer.pipe( Layer.provideMerge(spawnCompositionProvided), Layer.provideMerge(agentCrewInstanceLayer), @@ -96,6 +103,7 @@ export const makeJ5A2AAuxiliaryLayer = ( Layer.provideMerge(agentCrewProposalLayer), Layer.provideMerge(agentCrewInstanceLayer), ); + const crewStopProvided = crewStopLayer.pipe(Layer.provideMerge(agentCrewInstanceLayer)); const crewProposalProvided = crewProposalLayer.pipe( Layer.provideMerge(crewLaunchProvided), Layer.provideMerge(crewLaunchReporterProvided), @@ -104,9 +112,15 @@ export const makeJ5A2AAuxiliaryLayer = ( const crewProposalBootSweepProvided = crewProposalBootSweepLayer.pipe( Layer.provide(crewProposalProvided), ); + // A person's archive of a Captain retires its Crews from the same event stream the notifier reads. + const captainArchiveCascadeProvided = captainArchiveCascadeLayer.pipe( + Layer.provideMerge(archiveCrewProvided), + Layer.provideMerge(agentCrewInstanceLayer), + ); // The finish notifier tells a Captain when a seat's handoff file appears, so it reads the workspace. const crewSeatFinishNotifierProvided = crewSeatFinishNotifierLayer.pipe( Layer.provideMerge(crewLaunchReporterProvided), + Layer.provideMerge(captainArchiveCascadeProvided), Layer.provideMerge(agentCrewInstanceLayer), Layer.provide(artifactWorkspaceLayer), ); @@ -129,8 +143,10 @@ export const makeJ5A2AAuxiliaryLayer = ( spawnCompositionProvided, squadronJoinProvided, agentCrewInstanceLayer, + archiveCrewProvided, crewProposalProvided, crewProposalBootSweepProvided, + crewStopProvided, crewSeatFinishNotifierProvided, ).pipe(Layer.provideMerge(participantPlacementLayer)); return clientReadsLayer.pipe(Layer.provideMerge(runtimeWithoutClientReads)); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index dcc45f1db658c..760bafb746b43 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -198,6 +198,7 @@ it.effect("production mcp layer lists worktree tools over http", () => expect(toolNames).toContain("list_participants"); expect(toolNames.toSorted()).toEqual([ "archive_agent", + "archive_crew", "clear_own_ask", "delegate_task", "delete_scheduled_task", @@ -229,6 +230,7 @@ it.effect("production mcp layer lists worktree tools over http", () => "send_message", "spawn_agent", "stop_agent", + "stop_crew", "t3_thread_list", "t3_thread_read", "t3_worktree_handoff", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3d1de1827f206..48e2846f59c2f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -55,6 +55,7 @@ import { type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import { J5_CLAUDE_MCP_ALLOWED_TOOLS } from "../../j5/a2a/mcp/claudeAllowedTools.ts"; import { CLAUDE_AGENT_SDK_QUERY_PROTOCOL, CLAUDE_DEFAULT_INSTANCE_ID, @@ -82,7 +83,6 @@ import { type ClaudeAgentSdkQueryOptions, type ClaudeAgentSdkQueryOpenInput, } from "./ClaudeAdapterV2.ts"; -import { J5_CLAUDE_MCP_ALLOWED_TOOLS } from "../../j5/a2a/mcp/claudeAllowedTools.ts"; import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); diff --git a/apps/web/src/j5/a2a/ArchiveWarningContent.tsx b/apps/web/src/j5/a2a/ArchiveWarningContent.tsx index 067cf981ffcba..03e771ec13652 100644 --- a/apps/web/src/j5/a2a/ArchiveWarningContent.tsx +++ b/apps/web/src/j5/a2a/ArchiveWarningContent.tsx @@ -25,11 +25,61 @@ export type ArchiveWarningPlacement = | { readonly state: "unknown" } | { readonly state: "known"; readonly participants: ReadonlyArray }; +export interface ArchiveWarningCrewSeat { + readonly seat: string; + readonly participant: ArchiveWarningParticipant; + readonly runningTurn: boolean; + readonly openAsks: number; +} + +/** One live Crew the agent commands; it retires as a unit, seat by seat, before the agent does. */ +export interface ArchiveWarningCrew { + readonly crewInstanceId: string; + readonly crewName: string; + readonly seats: ReadonlyArray; +} + +export type ArchiveWarningCrews = + | { readonly state: "unknown" } + | { readonly state: "known"; readonly crews: ReadonlyArray }; + export interface ArchiveWarningPayload { readonly threadTitle: string; readonly factsUnavailable: boolean; readonly placement: ArchiveWarningPlacement; readonly openAsks: ReadonlyArray; + /** Live Crews the agent commands; absent when the caller has no Crew facts to show. */ + readonly crews?: ArchiveWarningCrews; +} + +const seatConsequences = (seat: ArchiveWarningCrewSeat) => + [ + seat.runningTurn ? "running turn" : null, + seat.openAsks > 0 ? `${seat.openAsks} open ask${seat.openAsks === 1 ? "" : "s"}` : null, + ] + .filter((part) => part !== null) + .join(", "); + +/** The seats of one Crew with what ends for each; shared by the Captain's dialog and the Fleet page. */ +export function ArchiveWarningCrewSeats({ crew }: { readonly crew: ArchiveWarningCrew }) { + return ( +
    + {crew.seats.map((seat) => { + const consequences = seatConsequences(seat); + return ( +
  • + + {seat.seat} + + + {consequences === "" ? null : ( + {consequences} + )} +
  • + ); + })} +
+ ); } const urgencyPresentation = { @@ -94,6 +144,7 @@ function ArchiveWarningRowView({ row }: { readonly row: ArchiveWarningRow }) { export function ArchiveWarningContent({ payload }: { readonly payload: ArchiveWarningPayload }) { const askCount = payload.openAsks.length; + const crews = payload.crews?.state === "known" ? payload.crews.crews : []; const askHeader = `${askCount} open ask${askCount === 1 ? "" : "s"} will be terminated — ${ askCount === 1 ? "counterparty is" : "counterparties are" } notified`; @@ -103,6 +154,33 @@ export function ArchiveWarningContent({ payload }: { readonly payload: ArchiveWa {payload.placement.state === "unknown" && !payload.factsUnavailable ? (

Placement subtree: couldn't check.

) : null} + {payload.crews?.state === "unknown" && !payload.factsUnavailable ? ( +

Crews it commands: couldn't check.

+ ) : null} + {crews.length > 0 ? ( +
+

+ A Captain is never archived alone. {crews.length === 1 ? "This Crew" : "These Crews"}{" "} + {crews.length === 1 ? "retires" : "retire"} with it, every seat together: +

+
    + {crews.map((crew) => ( +
  1. +

    + Crew · {crew.crewName}{" "} + + ({crew.seats.length} {crew.seats.length === 1 ? "seat" : "seats"}) + +

    + +
  2. + ))} +
+
+ ) : null} {payload.placement.state === "known" ? (

Also archives {payload.placement.participants.length} agent diff --git a/apps/web/src/j5/a2a/archiveFlow.test.ts b/apps/web/src/j5/a2a/archiveFlow.test.ts index cebeff70665ed..b2805b764eb97 100644 --- a/apps/web/src/j5/a2a/archiveFlow.test.ts +++ b/apps/web/src/j5/a2a/archiveFlow.test.ts @@ -20,15 +20,27 @@ const registered = (overrides: Partial = {}): client.Arc retired: false, openExchanges: [], placementSubtree: { state: "none" }, + liveCrews: [], + crewSeat: null, ...overrides, } as Extract, participantLabels: new Map([ ["agent:waiter", "Waiter"], ["agent:recipient", "Recipient"], ["agent:child", "Child"], + ["agent:critic", "Critic"], ]), }); +const reviewPair: client.LiveCrew = { + crewInstanceId: "crew:review-pair", + crewName: "Review Pair", + seats: [ + { seat: "builder", participantId: "agent:builder", runningTurn: false, openAsks: 0 }, + { seat: "critic", participantId: "agent:critic", runningTurn: true, openAsks: 1 }, + ], +}; + const archiveThreadRef = scopeThreadRef( "environment:archive-flow" as never, ThreadId.make("thread:archive-flow"), @@ -88,6 +100,67 @@ describe("archive flow", () => { expect(markup).toContain("Send the final note"); }); + it("lists a Captain's live Crews seat by seat and leaves the retiring to the server", async () => { + const preflight = registered({ liveCrews: [reviewPair] }); + expect(needsArchiveWarning(preflight)).toBe(true); + const warning = formatArchiveWarning({ threadTitle: "Captain", preflight }); + const markup = renderToStaticMarkup(warning.content); + expect(warning.confirmLabel).toBe("Archive anyway"); + expect(markup).toContain("A Captain is never archived alone."); + expect(markup).toContain("Review Pair"); + expect(markup).toContain("Critic"); + expect(markup).toContain("running turn, 1 open ask"); + + // The lifecycle cascade retires the Crews once the archive commits; the delegate only archives. + vi.spyOn(client, "readArchivePreflight").mockResolvedValue(preflight); + const archive = vi.fn(async () => "archived"); + await expect( + archiveWithPreflight({ + threadRef: archiveThreadRef, + threadTitle: "Captain", + confirm: async () => true, + archive, + }), + ).resolves.toBe("archived"); + expect(archive).toHaveBeenCalledTimes(1); + }); + + it("refuses to archive a Crew seat on its own, as archive_agent does for agents", async () => { + vi.spyOn(client, "readArchivePreflight").mockResolvedValue( + registered({ + crewSeat: { crewInstanceId: "crew:review-pair", crewName: "Review Pair", seat: "critic" }, + }), + ); + const confirm = vi.fn(async () => true); + const archive = vi.fn(async () => "archived"); + await expect( + archiveWithPreflight({ + threadRef: archiveThreadRef, + threadTitle: "Critic", + confirm, + archive, + }), + ).resolves.toBeUndefined(); + expect(confirm).not.toHaveBeenCalled(); + expect(archive).not.toHaveBeenCalled(); + }); + + it("treats a missing or failed Crew read as a fact to warn about, not as no Crews", () => { + const failed = registered({ liveCrews: null }); + expect(needsArchiveWarning(failed)).toBe(true); + expect( + renderToStaticMarkup( + formatArchiveWarning({ threadTitle: "Captain", preflight: failed }).content, + ), + ).toContain("Crews it commands: couldn't check."); + const { liveCrews: _absent, ...older } = registered().facts as Extract< + client.PreArchiveFacts, + { state: "registered" } + >; + expect(needsArchiveWarning({ facts: older, participantLabels: new Map() })).toBe(true); + expect(needsArchiveWarning(registered({ liveCrews: [] }))).toBe(false); + }); + it("never presents an unreadable preflight as an empty clean list", () => { expect(needsArchiveWarning({ facts: null, participantLabels: new Map() })).toBe(true); expect( diff --git a/apps/web/src/j5/a2a/archiveFlow.ts b/apps/web/src/j5/a2a/archiveFlow.ts index ee58b189ae165..569d247a9fe60 100644 --- a/apps/web/src/j5/a2a/archiveFlow.ts +++ b/apps/web/src/j5/a2a/archiveFlow.ts @@ -1,10 +1,12 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; import { createElement, type ReactNode } from "react"; +import { toastManager } from "../../components/ui/toast"; import { readArchivePreflight, type ArchivePreflight } from "./archiveFlowClient"; import { presentParticipantIdentity } from "./ParticipantIdentity"; import { ArchiveWarningContent, + type ArchiveWarningCrews, type ArchiveWarningParticipant, type ArchiveWarningPlacement, type ArchiveWarningPayload, @@ -36,7 +38,13 @@ export function needsArchiveWarning(preflight: ArchivePreflight): boolean { const { facts } = preflight; if (facts === null) return true; if (facts.state !== "registered") return false; - return facts.openExchanges.length > 0 || facts.placementSubtree.state !== "none"; + // A missing Crew read (older server) and a failed one both warn: neither is "no Crews". + return ( + facts.openExchanges.length > 0 || + facts.placementSubtree.state !== "none" || + facts.liveCrews == null || + facts.liveCrews.length > 0 + ); } /** Exact measured facts for the one destructive confirmation; no fact becomes an empty success state. */ @@ -57,6 +65,7 @@ export function formatArchiveWarning(input: { factsUnavailable: true, placement: { state: "unknown" }, openAsks: [], + crews: { state: "unknown" }, }, }), }; @@ -64,6 +73,26 @@ export function formatArchiveWarning(input: { if (facts.state !== "registered") { return { message: title, content: null, confirmLabel: "Archive" }; } + const liveCrews = facts.liveCrews ?? null; + const crews: ArchiveWarningCrews = + liveCrews === null + ? { state: "unknown" } + : { + state: "known", + crews: liveCrews.map((crew) => ({ + crewInstanceId: crew.crewInstanceId, + crewName: crew.crewName, + seats: crew.seats.map((seat) => ({ + seat: seat.seat, + participant: displayParticipant(seat.participantId, participantLabels), + runningTurn: seat.runningTurn, + openAsks: seat.openAsks, + })), + })), + }; + const crewsHaveConsequences = + crews.state === "known" && + crews.crews.some((crew) => crew.seats.some((seat) => seat.runningTurn || seat.openAsks > 0)); const placement: ArchiveWarningPlacement = facts.placementSubtree.state === "known" @@ -88,15 +117,23 @@ export function formatArchiveWarning(input: { factsUnavailable: false, placement, openAsks, + crews, }; return { message: title, content: createElement(ArchiveWarningContent, { payload }), - confirmLabel: openAsks.length > 0 ? "Archive anyway" : "Archive", + confirmLabel: openAsks.length > 0 || crewsHaveConsequences ? "Archive anyway" : "Archive", }; } -/** The action menu owns archive mutation; this J5 delegate owns preflight and the one warning. */ +/** + * The action menu owns archive mutation; this J5 delegate owns preflight and the one warning. + * Two Crew rules ride on it. A seat is never archived one by one (Crews AC16): the seat's archive + * is refused here with the way out, as `archive_agent` refuses it for agents. A Captain is never + * archived alone (AC17): the dialog shows the live Crews it commands, and once the archive + * commits the server's lifecycle cascade retires them as units, whichever door the archive came + * through, so nothing here has to run after the confirmation. + */ export async function archiveWithPreflight(input: { readonly threadRef: ScopedThreadRef; readonly threadTitle: string; @@ -111,6 +148,16 @@ export async function archiveWithPreflight(input: { } catch { preflight = { facts: null, participantLabels: new Map() }; } + const crewSeat = preflight.facts?.state === "registered" ? preflight.facts.crewSeat : null; + if (crewSeat != null) { + toastManager.add({ + type: "warning", + title: `${input.threadTitle} is seat ${crewSeat.seat} of Crew ${crewSeat.crewName}`, + description: + "Crew members are never archived one by one. Retire the whole Crew with Archive crew on the Fleet page, or message the member instead.", + }); + return undefined; + } if (needsArchiveWarning(preflight)) { const confirmed = await input.confirm( formatArchiveWarning({ threadTitle: input.threadTitle, preflight }), diff --git a/apps/web/src/j5/a2a/archiveFlowClient.ts b/apps/web/src/j5/a2a/archiveFlowClient.ts index 382b0be9ee577..fc36af6c789db 100644 --- a/apps/web/src/j5/a2a/archiveFlowClient.ts +++ b/apps/web/src/j5/a2a/archiveFlowClient.ts @@ -2,6 +2,11 @@ import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import { environmentEndpointUrl } from "@t3tools/client-runtime/environment"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { type EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { + PreArchiveCrewSeat, + PreArchiveLiveCrew, + type PreArchiveLiveCrew as PreArchiveLiveCrewType, +} from "@t3tools/contracts/j5"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -42,6 +47,7 @@ const PlacementSubtree = Schema.Union([ ), }), ]); +export type LiveCrew = PreArchiveLiveCrewType; const PreArchiveFacts = Schema.Union([ Schema.Struct({ state: Schema.Literal("not-an-a2a-participant"), @@ -57,6 +63,10 @@ const PreArchiveFacts = Schema.Union([ retired: Schema.Boolean, openExchanges: Schema.Array(OpenExchange), placementSubtree: PlacementSubtree, + /** Absent from older servers, null when the Crew read failed: both read as "couldn't check". */ + liveCrews: Schema.optional(Schema.NullOr(Schema.Array(PreArchiveLiveCrew))), + /** The seat this agent holds in a live Crew; seats are never archived one by one. */ + crewSeat: Schema.optional(Schema.NullOr(PreArchiveCrewSeat)), }), ]); export type PreArchiveFacts = typeof PreArchiveFacts.Type; @@ -222,6 +232,7 @@ export const readArchivePreflightEffect = Effect.fn("j5.a2a.archiveFlowClient.re new Set([ ...facts.openExchanges.map((exchange) => exchange.counterpartyId), ...(facts.placementSubtree.state === "known" ? facts.placementSubtree.participantIds : []), + ...(facts.liveCrews ?? []).flatMap((crew) => crew.seats.map((seat) => seat.participantId)), ]), ); const participantLabels = yield* readParticipantLabelsEffect({ diff --git a/apps/web/src/j5/crew/crewArchiveClient.ts b/apps/web/src/j5/crew/crewArchiveClient.ts new file mode 100644 index 0000000000000..15c4e37066a6f --- /dev/null +++ b/apps/web/src/j5/crew/crewArchiveClient.ts @@ -0,0 +1,18 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; + +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { j5Environment } from "../state"; + +/** + * Retire a Crew as a unit on the environment that holds it. The caller has already shown the + * person every seat's consequences and taken their confirmation; nothing is deleted. + */ +export async function archiveCrew(environmentId: EnvironmentId, crewInstanceId: string) { + const result = await j5Environment.archiveCrew.run(appAtomRegistry, { + environmentId, + input: { crewInstanceId }, + }); + if (result._tag === "Failure") throw Cause.squash(result.cause); + return result.value; +} diff --git a/apps/web/src/j5/crew/crewStopClient.ts b/apps/web/src/j5/crew/crewStopClient.ts new file mode 100644 index 0000000000000..d7b50d428688d --- /dev/null +++ b/apps/web/src/j5/crew/crewStopClient.ts @@ -0,0 +1,15 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; + +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { j5Environment } from "../state"; + +/** Interrupt every running seat of a Crew on the environment that holds it; nothing is retired. */ +export async function stopCrew(environmentId: EnvironmentId, crewInstanceId: string) { + const result = await j5Environment.stopCrew.run(appAtomRegistry, { + environmentId, + input: { crewInstanceId }, + }); + if (result._tag === "Failure") throw Cause.squash(result.cause); + return result.value; +} diff --git a/apps/web/src/j5/fleet/FleetPage.tsx b/apps/web/src/j5/fleet/FleetPage.tsx index 9ceb53da5288e..865ad12029241 100644 --- a/apps/web/src/j5/fleet/FleetPage.tsx +++ b/apps/web/src/j5/fleet/FleetPage.tsx @@ -21,10 +21,31 @@ import { buildThreadRouteParams } from "../../threadRoutes"; import { formatElapsedDurationLabel } from "../../timestampFormat"; import { CaptainMark } from "../squadron/CaptainMark"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; -import { fleetSourcesAtom } from "../state"; -import { formatCrewStateSummary, summarizeCrewState } from "../crew/crewState"; -import { buildFleetTree, originLabel, type FleetNode, type FleetRow } from "./fleet.logic"; -import { mergeFleetSources, refreshFleet, useFleetRefresh } from "./fleetClient"; +import { fleetDetailSourcesAtom } from "../state"; +import { requestConfirmDialog } from "../../confirmDialog"; +import { ArchiveWarningCrewSeats, type ArchiveWarningCrew } from "../a2a/ArchiveWarningContent"; +import { archiveCrew } from "../crew/crewArchiveClient"; +import { + classifyCrewSeat, + crewHasRunningSeat, + formatCrewStateSummary, + summarizeCrewState, +} from "../crew/crewState"; +import { stopCrew } from "../crew/crewStopClient"; +import { + buildFleetTree, + originLabel, + retiredCrews, + type FleetNode, + type FleetRow, +} from "./fleet.logic"; +import { + mergeFleetSources, + refreshFleet, + useFleetDetailRefresh, + type FleetCrew, + type ScopedFleetSquadron, +} from "./fleetClient"; /** * The Roster (SB6): every agent in every Squadron on every connected environment, indented by @@ -36,8 +57,8 @@ import { mergeFleetSources, refreshFleet, useFleetRefresh } from "./fleetClient" export function FleetPage() { const navigate = useNavigate(); const threads = useThreadShells(); - const sources = useAtomValue(fleetSourcesAtom); - useFleetRefresh(); + const sources = useAtomValue(fleetDetailSourcesAtom); + useFleetDetailRefresh(); const [refreshing, setRefreshing] = useState(false); const squadrons = useMemo(() => mergeFleetSources(sources), [sources]); const showEnvironment = spansMultipleEnvironments(squadrons); @@ -154,6 +175,7 @@ export function FleetPage() { onOpenThread={openThread} /> )} + ))} @@ -208,6 +230,76 @@ function FleetNodeRows( ), ), ); + // The person's Stop crew: interrupts every running seat, retires nothing. The seats' status + // pills already tell the truth afterwards, so a failed call needs no second message here. + const [busy, setBusy] = useState(null); + const stop = async (crewInstanceId: string) => { + setBusy(crewInstanceId); + try { + await stopCrew(props.environmentId, crewInstanceId); + } catch { + // measured state on the rows is the report + } finally { + setBusy(null); + } + }; + // The person's Archive crew: the same unit archive the Captain has, behind one dialog that + // lists every seat with what ends for it. The Crew moves to the Squadron's retired list. + const archive = async ( + crew: { crewInstanceId: string; crewName: string }, + seats: ReadonlyArray, + ) => { + const warning: ArchiveWarningCrew = { + crewInstanceId: crew.crewInstanceId, + crewName: crew.crewName, + seats: seats.map(({ agent }) => { + const thread = + agent.threadId === null + ? undefined + : props.threadsByKey.get( + scopedThreadKey(scopeThreadRef(props.environmentId, ThreadId.make(agent.threadId))), + ); + const displayName = thread?.title ?? agent.displayName; + return { + seat: agent.crew?.seat ?? agent.participantId, + participant: + displayName === null + ? { displayName: "Unnamed participant", tooltipParticipantId: agent.participantId } + : { displayName, tooltipParticipantId: null }, + runningTurn: classifyCrewSeat(thread) === "running", + openAsks: agent.openAsks, + }; + }), + }; + const consequential = warning.seats.some((seat) => seat.runningTurn || seat.openAsks > 0); + const confirmed = await (requestConfirmDialog( + `Archive crew ${crew.crewName}?`, + { variant: "destructive" }, + { + content: ( +

+

Its {warning.seats.length === 1 ? "seat retires" : "seats retire"} together:

+ +

+ Worktrees, branches, and pull requests remain. The roster stays readable under Retired + crews. +

+
+ ), + confirmLabel: consequential ? "Archive anyway" : "Archive", + }, + ) ?? Promise.resolve(false)); + if (!confirmed) return; + setBusy(crew.crewInstanceId); + try { + await archiveCrew(props.environmentId, crew.crewInstanceId); + refreshFleet(); + } catch { + // the rows keep reporting the measured state; the person can retry + } finally { + setBusy(null); + } + }; return ( <> + + {crewHasRunningSeat(state) ? ( + + ) : null} + + ); })()} @@ -262,6 +384,84 @@ function FleetNodeRows( ); } +/** + * Retired Crews of one Squadron, collapsed: the brief, the approved roster with who approved + * each seat and why, and the Captain's thread, so a successor can be proposed from what was + * decided rather than from memory (Crews AC20). Handoffs live on the Artifacts page. + */ +function RetiredCrews(props: { + readonly squadron: ScopedFleetSquadron; + readonly onOpenThread: FleetRowsProps["onOpenThread"]; +}) { + const crews = retiredCrews(props.squadron); + if (crews.length === 0) return null; + return ( +
+ + + Retired crews ({crews.length}) + +
    + {crews.map((crew) => ( + + ))} +
+
+ ); +} + +function RetiredCrewItem(props: { + readonly crew: FleetCrew; + readonly environmentId: EnvironmentId; + readonly onOpenThread: FleetRowsProps["onOpenThread"]; +}) { + const { crew } = props; + const retired = + crew.archivedAt === null ? null : formatElapsedDurationLabel(crew.archivedAt) || "just now"; + return ( +
  • +
    + Crew · {crew.crewName} + + v{crew.version} + {retired === null ? "" : ` · retired ${retired}`} + + {crew.captainThreadId === null ? null : ( + + )} +
    +

    {crew.brief}

    +
      + {crew.roster.map((member) => ( +
    • + {member.seat} + {member.agentId} + + {member.addedVersion > 1 ? `joined at v${member.addedVersion}` : "approved roster"} + {member.reason === null ? "" : ` · ${member.reason}`} + +
    • + ))} +
    +
  • + ); +} + function FleetRowItem( props: FleetRowsProps & { readonly row: FleetRow; diff --git a/apps/web/src/j5/fleet/fleet.logic.test.ts b/apps/web/src/j5/fleet/fleet.logic.test.ts index 230059dbd43ae..cf64054c92577 100644 --- a/apps/web/src/j5/fleet/fleet.logic.test.ts +++ b/apps/web/src/j5/fleet/fleet.logic.test.ts @@ -7,8 +7,9 @@ import { countFleetAlerts, fleetInvolvedThreadRefs, originLabel, + retiredCrews, } from "./fleet.logic"; -import type { FleetAgent, FleetSquadron } from "./fleetClient"; +import type { FleetAgent, FleetCrew, FleetSquadron } from "./fleetClient"; const agent = (participantId: string, overrides: Partial = {}): FleetAgent => ({ participantId, @@ -119,3 +120,31 @@ describe("fleet involvement", () => { ]); }); }); + +describe("retired crews", () => { + const crew = (id: string, archivedAt: string | null): FleetCrew => ({ + crewInstanceId: id, + crewName: id, + captainParticipantId: "captain", + captainThreadId: "thread:captain", + brief: "Land the PR.", + version: 1, + createdAt: "2026-09-14T09:00:00.000Z", + archivedAt, + roster: [], + }); + + it("keeps only archived Crews, newest retirement first, so the snapshot stays readable", () => { + const squadron: FleetSquadron = { + id: "squadron:alpha", + name: "Alpha", + agents: [], + crews: [ + crew("live", null), + crew("older", "2026-09-14T10:00:00.000Z"), + crew("newer", "2026-09-14T12:00:00.000Z"), + ], + }; + expect(retiredCrews(squadron).map((entry) => entry.crewInstanceId)).toEqual(["newer", "older"]); + }); +}); diff --git a/apps/web/src/j5/fleet/fleet.logic.ts b/apps/web/src/j5/fleet/fleet.logic.ts index 3cadfb7045453..330d61bb5a8cd 100644 --- a/apps/web/src/j5/fleet/fleet.logic.ts +++ b/apps/web/src/j5/fleet/fleet.logic.ts @@ -1,7 +1,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId, type EnvironmentId, type ScopedThreadRef } from "@t3tools/contracts"; -import type { FleetAgent, FleetSquadron } from "./fleetClient"; +import type { FleetAgent, FleetCrew, FleetSquadron } from "./fleetClient"; /** One rendered row of the Roster tree. Crew members hang under their Captain as one unit. */ export interface FleetRow { @@ -90,6 +90,15 @@ export function buildFleetTree(squadron: FleetSquadron): ReadonlyArray => + squadron.crews + .filter((crew) => crew.archivedAt !== null) + .toSorted((left, right) => (right.archivedAt ?? "").localeCompare(left.archivedAt ?? "")); + /** Roster alert badge: measured "needs a human" facts only, so nothing here is guessed. */ export const countFleetAlerts = (squadrons: ReadonlyArray) => squadrons.reduce( diff --git a/apps/web/src/j5/fleet/fleetClient.ts b/apps/web/src/j5/fleet/fleetClient.ts index 89aa8e86918ba..40506f3666cf5 100644 --- a/apps/web/src/j5/fleet/fleetClient.ts +++ b/apps/web/src/j5/fleet/fleetClient.ts @@ -5,7 +5,13 @@ import type { FleetResponse, FleetSquadron } from "@t3tools/contracts/j5"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { refreshCrewMembershipRows } from "../squadron/CrewMembershipsClient"; import { refreshSpawnedChildrenRows } from "../squadron/SpawnedChildrenClient"; -import { fleetQueryAtom, fleetSourcesAtom, refreshJ5Sources } from "../state"; +import { + fleetDetailQueryAtom, + fleetDetailSourcesAtom, + fleetQueryAtom, + fleetSourcesAtom, + refreshJ5Sources, +} from "../state"; import { createVisibleRefreshHook } from "../useVisibleRefresh"; import { fleetInvolvedThreadRefs } from "./fleet.logic"; @@ -31,8 +37,9 @@ export const mergeFleetSources = ( })), ); +/** The page's read, retired Crews and rosters included. */ export const refreshFleet = () => - refreshJ5Sources(fleetSourcesAtom, fleetQueryAtom, { force: true }); + refreshJ5Sources(fleetDetailSourcesAtom, fleetDetailQueryAtom, { force: true }); /** * The always-mounted rail badge polls the live roster only (no retired Crews, no rosters). Once @@ -56,3 +63,8 @@ export const useFleetRefresh = createVisibleRefreshHook(() => { console.warn("[j5] fleet refresh failed", error); }); }, FLEET_POLL_INTERVAL_MS); + +/** The Fleet page's own poll while it is open; retired Crews ride only here. */ +export const useFleetDetailRefresh = createVisibleRefreshHook(() => { + void refreshJ5Sources(fleetDetailSourcesAtom, fleetDetailQueryAtom); +}, FLEET_POLL_INTERVAL_MS); diff --git a/apps/web/src/j5/squadron/SpawnedChildren.tsx b/apps/web/src/j5/squadron/SpawnedChildren.tsx index fb18db1949025..ca70b26f9fdb8 100644 --- a/apps/web/src/j5/squadron/SpawnedChildren.tsx +++ b/apps/web/src/j5/squadron/SpawnedChildren.tsx @@ -1,9 +1,10 @@ import { useAtomValue } from "@effect/atom-react"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { EnvironmentId } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; import { ChevronRightIcon } from "lucide-react"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { resolveThreadStatusPill } from "../../components/Sidebar.logic"; import { Badge } from "../../components/ui/badge"; @@ -14,12 +15,14 @@ import { useThreadShells } from "../../state/entities"; import { environmentServerConfigsAtom } from "../../state/server"; import { buildThreadRouteParams } from "../../threadRoutes"; import { formatElapsedDurationLabel } from "../../timestampFormat"; +import { stopCrew } from "../crew/crewStopClient"; import { useSpawnedChildren, type SpawnedChild } from "./SpawnedChildrenClient"; import { groupSpawnedChildren, readExpandedSpawnParents, selectSpawnedChildRows, spawnedGroupExpansionKey, + stoppableCrew, writeExpandedSpawnParents, type SpawnedChildGroup, } from "./spawnedChildren.logic"; @@ -112,6 +115,7 @@ function SpawnedChildrenRows(props: { {groups.map((group) => ( ; readonly providerEntries: ReadonlyMap; readonly isOpen: boolean; @@ -137,6 +142,8 @@ function SpawnedChildGroupRows(props: { readonly onOpen: (child: EnvironmentThreadShell) => void; }) { const { group, isOpen } = props; + const stoppable = stoppableCrew(group); + const [stopping, setStopping] = useState(false); return (
    @@ -164,6 +171,24 @@ function SpawnedChildGroupRows(props: { /> ) : null} + {stoppable !== null ? ( + // The person's Stop for this Crew alone: interrupts its running seats, retires nothing. + + ) : null}
    {isOpen ? (
      diff --git a/apps/web/src/j5/squadron/spawnedChildren.logic.test.ts b/apps/web/src/j5/squadron/spawnedChildren.logic.test.ts index 32292ed9ef9c4..1f0f251bdab16 100644 --- a/apps/web/src/j5/squadron/spawnedChildren.logic.test.ts +++ b/apps/web/src/j5/squadron/spawnedChildren.logic.test.ts @@ -10,6 +10,7 @@ import { selectSpawnedChildRows, spawnedChildrenNeedAttention, spawnedGroupExpansionKey, + stoppableCrew, writeExpandedSpawnParents, } from "./spawnedChildren.logic"; @@ -101,6 +102,33 @@ describe("spawned children under a sidebar row", () => { ); }); + it("offers Stop on a Crew's own group only while one of its seats is running", () => { + const blog = { crewInstanceId: "crew:2", crewName: "Blog Migration" }; + const running = new Map([ + ["builder", thread("builder", "2026-09-09T10:00:00Z", { runtime: { status: "running" } })], + ["critic", thread("critic", "2026-09-09T11:00:00Z")], + ["writer", thread("writer", "2026-09-09T12:00:00Z")], + ["solo", thread("solo", "2026-09-09T13:00:00Z", { runtime: { status: "running" } })], + ]); + const groups = groupSpawnedChildren( + selectSpawnedChildRows( + [ + child("builder", "builder"), + child("critic", "critic"), + child("writer", "writer", blog), + child("solo", null), + ], + running, + ), + ); + // Blog Migration is idle, Review Pair has a running seat, and the solo group never stops. + expect(groups.map((group) => stoppableCrew(group))).toEqual([ + null, + { crewInstanceId: "crew:1", crewName: "Review Pair" }, + null, + ]); + }); + it("remembers expansion per parent and tolerates broken or missing storage", () => { const backing = new Map(); const storage = { diff --git a/apps/web/src/j5/squadron/spawnedChildren.logic.ts b/apps/web/src/j5/squadron/spawnedChildren.logic.ts index d7e3ca0cf6266..00725820733bd 100644 --- a/apps/web/src/j5/squadron/spawnedChildren.logic.ts +++ b/apps/web/src/j5/squadron/spawnedChildren.logic.ts @@ -1,4 +1,9 @@ -import { formatCrewStateSummary, summarizeCrewState, type CrewSeatThread } from "../crew/crewState"; +import { + crewHasRunningSeat, + formatCrewStateSummary, + summarizeCrewState, + type CrewSeatThread, +} from "../crew/crewState"; import type { SpawnedChild } from "./SpawnedChildrenClient"; export interface SpawnedChildThread extends CrewSeatThread { @@ -104,6 +109,18 @@ export const spawnedGroupExpansionKey = ( groupKey: string, ) => `${environmentId}/${parentThreadId}/${groupKey}`; +/** + * The Crew a Stop control on a group's header would stop: the group is a Crew and at least one + * seat has a turn to interrupt. The solo-peer group and an idle Crew offer nothing. + */ +export const stoppableCrew = ( + group: SpawnedChildGroup, +): { readonly crewInstanceId: string; readonly crewName: string } | null => + group.crew !== null && + crewHasRunningSeat(summarizeCrewState(group.rows.map(({ thread }) => thread))) + ? group.crew + : null; + const STORAGE_KEY = "j5:sidebar:spawned-children:expanded"; export const readExpandedSpawnParents = ( diff --git a/apps/web/src/j5/state.ts b/apps/web/src/j5/state.ts index c5503a51b43e9..3fa799d25fc56 100644 --- a/apps/web/src/j5/state.ts +++ b/apps/web/src/j5/state.ts @@ -34,6 +34,8 @@ export const crewProposalsQueryAtom = (environmentId: EnvironmentId) => j5Environment.crewProposals({ environmentId, input: {} }); export const fleetQueryAtom = (environmentId: EnvironmentId) => j5Environment.fleet({ environmentId, input: {} }); +export const fleetDetailQueryAtom = (environmentId: EnvironmentId) => + j5Environment.fleet({ environmentId, input: { includeRetired: true } }); export const squadronSourcesAtom = createJ5ReadSourcesAtom({ ...sourcesInput, @@ -65,6 +67,12 @@ export const fleetSourcesAtom = createJ5ReadSourcesAtom({ capability: "j5Squadrons", queryAtom: fleetQueryAtom, }); +export const fleetDetailSourcesAtom = createJ5ReadSourcesAtom({ + ...sourcesInput, + label: "web-j5:fleet-detail-sources", + capability: "j5Squadrons", + queryAtom: fleetDetailQueryAtom, +}); export const inboxCountSourcesAtom = createJ5ReadSourcesAtom({ ...sourcesInput, label: "web-j5:inbox-count-sources", diff --git a/docs/j5/product/a2a/agent-tools.md b/docs/j5/product/a2a/agent-tools.md index 4414709731840..c33dfc70aeb33 100644 --- a/docs/j5/product/a2a/agent-tools.md +++ b/docs/j5/product/a2a/agent-tools.md @@ -108,7 +108,7 @@ A plain spawn without `agent` is unchanged and inherits the parent's runtime mod **Result:** exactly one of `interrupt_requested` (a running turn is being interrupted) or `already_idle` (no running turn; no side effect). An interrupt acknowledgement and an observed terminal run state are separate facts; the tool never claims a turn stopped merely because interruption was requested. Anything else is an error naming the caller's actual Squadron and the corrected retry. -**Rules.** A caller's runtime policy never gates `stop_agent` or `archive_agent`: a read-only persona may run them, because identity and the human's confirmation token are the gates, and the sandbox guards the workspace rather than the platform's verbs (Bryant, 2026-09-14). Stop and archive are single-target; the unit cascade belongs to Crews, which stop and archive as units through their own verbs when they exist. A stop is final across restarts: a committed stop wins even if the provider has not yet acknowledged it, so a stopped run is never resumed by upstream's restart continuation. +**Rules.** A caller's runtime policy never gates `stop_agent`, `archive_agent`, or `archive_crew`: a read-only persona may run them, because identity (the Captain, its own Crew) and the human's confirmation token are the gates, and the sandbox guards the workspace rather than the platform's verbs (Bryant, 2026-09-14). Stop and archive are single-target; the unit cascade belongs to Crews, which stop and archive as units through their own verbs when they exist. A stop is final across restarts: a committed stop wins even if the provider has not yet acknowledged it, so a stopped run is never resumed by upstream's restart continuation. ### `archive_agent` @@ -265,6 +265,66 @@ handoffs live in application storage, never in the sandboxed workspace. (Withdra the 2026-09-10 `deliver_artifact` verb, its ledger table, and the crew-only `read_artifact` and `list_artifacts`, which collided with the project artifact toolkit's names.) +### `stop_crew` + +**Description (contract):** "Stop a Crew you command: interrupts the running turn of every seat +now. Seats that are idle are untouched, nothing settles or is retired, and every seat can be +messaged again afterwards. The result says, seat by seat, whether an interrupt was requested or the +seat was already idle. Only the Captain may call this; a member escalates to its Captain instead. +Requires your current squadron_id. Reuse client_request_id to retry safely." + +| Input | Type | Required | Meaning | +| ------------------- | ----------------- | -------- | ---------------------------------------------- | +| `squadron_id` | SquadronId | yes | The caller's current Squadron | +| `crew_instance_id` | string | yes | The id from the `` roster notice | +| `client_request_id` | string, non-empty | no | Supply and reuse to make retries safe | + +Result: `crew_instance_id` and `members` (seat, participant_id, result: `interrupt_requested`, +`already_idle`, or `archived`). Semantics: the unit form of `stop_agent`. Only the Captain may call +it; anyone else is refused and pointed at asking the Captain; an archived Crew is refused naming +its state. Every seat with a run in flight is interrupted through the ordinary single-agent stop +(so a committed stop wins over restart continuation here too); idle seats are reported as such and +left alone; nothing settles, nothing is retired, no Exchange closes, and the seats stay addressable. +The person has the same act as a **Stop crew** control on the Crew's header on the Fleet page and on +the Captain's expander in the sidebar, shown only while a seat is running; it is not a Crew +participant, so no Captain check applies to it (Bryant, 2026-09-14). + +### `archive_crew` + +**Description (contract):** "Retire a whole Crew you command, for good. Crews archive only as a +unit — members are never retired one by one. A clean archive (no open exchanges, no running turns +on any seat) completes immediately. Otherwise the call refuses and lists, seat by seat, exactly +what archiving ends — who is waiting on which ask, which turns will stop — along with a +confirmation_token. Before retrying with that token, check with the human: a Crew others are +waiting on is a judgment call, and a successor Crew needs a fresh brief you write. Nothing is +destroyed: worktrees, branches, and ledgers stay readable. Requires your current squadron_id. Reuse +client_request_id to retry safely." + +| Input | Type | Required | Meaning | +| -------------------- | ----------------- | -------- | ---------------------------------------------------- | +| `squadron_id` | SquadronId | yes | The caller's current Squadron | +| `crew_instance_id` | string | yes | The id from the `` roster notice | +| `confirmation_token` | string | no | Token from the refusal; confirms exactly those facts | +| `client_request_id` | string, non-empty | no | Supply and reuse to make retries safe | + +Result: `status` (`archived` or `already_archived`), `crew_instance_id`, and `members` (seat, +participant_id, per-member result). Semantics: only the Captain — the participant that launched the +Crew — may archive it (R19); anyone else is refused and pointed at asking the Captain. Facts are +read for every member before anything happens. If any member has an open Exchange or a running +turn, the call refuses with a per-seat list (`members[].open_exchanges`, `members[].running_turn`, +`already_archived`) and a signed `confirmation_token` over exactly those facts. With a matching +token, members retire in seat order through the ordinary single-agent archive with their own +confirmation already satisfied (interrupt, thread archive, participant retirement, loud Exchange +terminations to every waiter), then the Crew record is marked archived. A token stays valid when +the current facts are a subset of the confirmed ones, so a retry after a partial failure finishes +the job; new work on any seat makes it stale and yields a fresh token. Partial failures name the +seats retired so far and the seat that failed; retry with the same `client_request_id` and token. + +**Members are never archived one by one (R14):** `archive_agent` refuses a target that sits in a +Crew and names the `archive_crew` call to make instead. Members settle automatically when a run +ends with nothing owed; settlement is not archive. `stop_agent` on a member is still allowed; +stopping retires nothing. + ### Kept upstream tools - `orchestrator_capabilities` — providers and models (ids, labels, option descriptors) for spawn targeting, plus runtime and interaction-mode facts. It deliberately stays silent about delegation even though `delegate_task` is back on the surface: that tool's own description carries its saved-agent use, and J5 verbs are advertised by their own descriptions. @@ -293,6 +353,8 @@ the 2026-09-10 `deliver_artifact` verb, its ledger table, and the crew-only `rea 16. `join_squadron` establishes a home only for a thread that has none, only in a Squadron that references the thread's project, leaves the thread and its running work untouched, returns the existing registration when the thread is already homed there, and refuses a thread homed elsewhere, an archived or deleted thread, and a retired identity. 17. `list_agents` returns every saved agent with its availability and route; `propose_crew` and `request_crew_member` file a human gate and refuse unknown, disabled, duplicate, or over-cap seats before anything is recorded; both succeed under every sandbox and approval policy, including Codex approval policy `never`. 18. Approving a proposal spawns exactly once; a second approval finds it claimed; a spawn that fails after reserving its seats reopens the gate, and the retry converges on those seats. +19. `archive_crew` is Captain-only, refuses with per-seat facts and a token when any seat has an open Exchange or a running turn, and finishes a partial archive on retry; `archive_agent` refuses a Crew member and a Captain of a live Crew, naming the `archive_crew` call. +20. `stop_crew` is Captain-only, interrupts every seat with a running turn and reports each seat as interrupted, already idle, or archived; it settles, retires, and closes nothing, and a non-Captain or an archived Crew is refused naming the next step. The person's Stop crew control does the same through the operate scope. ## History @@ -307,9 +369,11 @@ the 2026-09-10 `deliver_artifact` verb, its ledger table, and the crew-only `rea - 2026-09-12 — archiving is reversible: unarchive restores the same identity without reopening Exchanges; deletion is a separate permanent act for people only; the address book hides archived agents unless asked (PR #132). - 2026-09-12 — `list_squadrons` and `join_squadron` added for the one case of a native thread with no home (issue #129, PR #131). - 2026-09-07 — rewritten from a stack of dated contract revisions into current-state contracts; every verb's build state true as of this date (all six verbs shipped; `regarding` and the person follow-up rule are issue #111). -- 2026-09-09 — `list_agents`, `propose_crew`, and `request_crew_member`: Crews composed at launch through a human gate ([record](../../worklog/2026-09-14-crews-consolidation.md)). +- 2026-09-09 — `list_agents`, `propose_crew`, `request_crew_member`, and `archive_crew`: Crews composed at launch through a human gate ([record](../../worklog/2026-09-14-crews-consolidation.md)). - 2026-09-10 — human approval is the authority for seat access; proposals and requests pre-approved for Codex under approval policy `never`; `deliver_artifact` added. -- 2026-09-14 — `deliver_artifact` and the crew-only artifact reads withdrawn in favor of the shared handoff files; Codex pre-approval narrowed to a per-tool list ([record](../../worklog/2026-09-14-crews-consolidation.md)). +- 2026-09-14 — lifecycle verbs stay available to read-only personas by decision (Sentry S-4 closed). +- 2026-09-14 — `deliver_artifact` and the crew-only artifact reads withdrawn in favor of the shared handoff files; Codex pre-approval narrowed to a per-tool list; `archive_agent` refuses Captains of live Crews ([record](../../worklog/2026-09-14-crews-consolidation.md)). - 2026-09-14 — `t3_thread_wait` withdrawn: blocking inside a turn starves a participant of the queued notices it is waiting for ([record](../../worklog/2026-09-14-crews-consolidation.md)). +- 2026-09-14 — `stop_crew`: the unit form of stop for the Captain over MCP and for the person as a Stop crew control; interrupts running seats only ([record](../../worklog/2026-09-14-crews-consolidation.md)). - 2026-09-14 — `delegate_task`, `task_status`, and `task_cancel` return to the J5 surface, with a saved-agent `agent` parameter on `delegate_task` replacing the J5-only `invoke_agent` ([review](https://github.com/Jacksondr5/j5code/pull/124#issuecomment-5663559782)). - 2026-09-15 — machine participants appear in `list_participants` as named senders that receive nothing (issue #74). diff --git a/docs/j5/product/features/archive-flow.md b/docs/j5/product/features/archive-flow.md index d1fb98c59d90c..48a959aa7bd20 100644 --- a/docs/j5/product/features/archive-flow.md +++ b/docs/j5/product/features/archive-flow.md @@ -36,7 +36,7 @@ Archiving is **not** deleting, **not** a way to archive a single Crew member, an ### The dialog 2. An agent with no open Exchanges and no agents beneath it archives immediately, with no dialog. -3. Otherwise the dialog names the agent in its title, lists the agents beneath it, lists every open ask with its urgency, direction, counterpart, time open, and verbatim intent, states the count of asks that will be terminated, and reassures that the workspace survives. +3. Otherwise the dialog names the agent in its title, lists the agents beneath it, lists every live Crew the agent commands with each seat's running turn and open asks, lists every open ask with its urgency, direction, counterpart, time open, and verbatim intent, states the count of asks that will be terminated, and reassures that the workspace survives. 4. An ask row names its counterpart by display name — an agent's thread title, a person's name with "(inbox)" — and never shows a raw id; an unnamed counterpart reads "Unnamed participant". 5. The dialog has one confirm and no type-to-confirm. @@ -49,7 +49,7 @@ Archiving is **not** deleting, **not** a way to archive a single Crew member, an ### Boundaries -10. A Crew member cannot be archived individually through this flow. +10. A Crew member cannot be archived individually through this flow; the dialog refuses with the way out. A Captain is never archived alone through it: the dialog lists its live Crews seat by seat, and once the archive commits the platform retires those Crews as units, from this flow or any other door ([Crews](crews.md) AC17). 11. The flow is available from the agent's thread and from its Fleet page row. ### Unarchiving and deleting diff --git a/docs/j5/product/features/fleet-page.md b/docs/j5/product/features/fleet-page.md index c20dba7a29511..6aabbba3eda78 100644 --- a/docs/j5/product/features/fleet-page.md +++ b/docs/j5/product/features/fleet-page.md @@ -89,6 +89,11 @@ The Fleet page is **not** the inbox: it never demands a reply. It is **not** a p 26. The Squadron list shows each Squadron's measured cost, and a Squadron's page shows what each of its agents contributed. +### Crews + +27. A Crew's header offers Stop crew while a seat is running and Archive crew always; Archive crew shows the seats with their running turns and open asks before retiring the Crew as a unit ([Crews](crews.md) AC17, AC21). +28. A Squadron's retired Crews are listed, collapsed, beneath its agents with the brief, the roster snapshot (seat, agent, who approved it and why, the version it joined at), when it retired, and a way to the Captain's thread; nothing about a retired Crew is inferred ([Crews](crews.md) AC20). + ## Scenarios - **Reading one Squadron.** The user selects Billing Migration on the page's Squadron list. The coordinator agent is the first row; the two builders it spawned sit indented beneath it in the order they were created, each showing status, owed and awaited asks, and last activity. Clicking a builder opens its thread. (AC6, AC8, AC9, AC12, AC14) diff --git a/docs/user/agents.md b/docs/user/agents.md index abb7e56e6df48..c7d29574178e9 100644 --- a/docs/user/agents.md +++ b/docs/user/agents.md @@ -65,6 +65,10 @@ When a seat's agent is defined to return a report, such as a review handoff, it Crews are a web and desktop feature; the mobile app shows crew members as ordinary agents and has no roster gate. Members stay out of the thread list so it shows the conversations you started; the Captain's row shows an anchor mark (hover it for the crews it commands) and, for each crew it runs, a toggle named for that crew that opens into its members with seat, status, and last activity; agents it started outside a crew get a toggle of their own. When a member finishes and owes no reply, the Captain is told how it ended; the member's thread settles under the same rules as any other thread. **Fleet**, from the icon beside the inbox bell, shows every agent by Squadron with each crew under its Captain. +To stop a crew that is heading the wrong way, use **Stop crew** on the crew's row in Fleet or the small **Stop** beside that crew's toggle under the Captain in the thread list; both appear only while a member is running. Stopping interrupts every running member's turn and nothing else: members stay on the roster and can be messaged again. The Captain can do the same from its own tools. + +A crew is retired as a unit, by the Captain or by you. The Captain is shown exactly what will end, seat by seat, and asked to confirm; the guidance is to check with you first. You can retire a crew yourself with **Archive crew** on its row in Fleet, and archiving a Captain's thread retires its crews with it; from the thread list, a dialog first lists every member with any running turn or open ask. Individual crew members cannot be archived on their own. Archiving never deletes worktrees, branches, or conversation history, and a retired crew's brief and roster stay readable under **Retired crews** at the bottom of its Squadron in Fleet. + Persona instructions describe intended behavior. They do not grant permissions or guarantee that an agent obeys them. The displayed runtime policy reflects supported provider controls; unsupported modes appear as **Blocked**. The trash action also works for folder-loaded agents and bundled examples. Source files remain unchanged, and removal survives restarts. A removed example or folder agent stays in the list marked **Removed** with a **Restore** action that brings it back; a removed imported copy is gone and must be imported again. diff --git a/docs/user/artifacts.md b/docs/user/artifacts.md index f65c15d843ac5..3d2e23b2434fe 100644 --- a/docs/user/artifacts.md +++ b/docs/user/artifacts.md @@ -34,5 +34,5 @@ Artifacts are local to the environment and project where they were generated. Th not synced between machines or treated as shared Squadron documents. Handoff reports from saved agents, including crew members, land here too, under `handoffs/`, -one file per agent task. Rewriting the report adds a new version at the top of the same file. See -**Handoff artifacts** in [Agents](agents.md). +one file per agent task. Rewriting the file replaces the earlier version. See **Handoff +artifacts** in [Agents](agents.md). diff --git a/packages/client-runtime/src/j5/http.ts b/packages/client-runtime/src/j5/http.ts index fb1a957618c82..3e35e0ef0e789 100644 --- a/packages/client-runtime/src/j5/http.ts +++ b/packages/client-runtime/src/j5/http.ts @@ -4,15 +4,20 @@ import { CrewMembershipsResponse, CrewProposalResolveResponse, CrewProposalsResponse, + CrewArchiveResponse, + CrewStopResponse, FleetResponse, - SpawnedChildrenResponse, HumanInboxResponse, J5_API_PATHS, OpenInboxCountResponse, + SpawnedChildrenResponse, SquadronListResponse, ThreadHomesResponse, type AnswerHumanExchangeRequest, type CrewProposalResolveRequest, + type CrewArchiveRequest, + type CrewStopRequest, + type FleetReadRequest, } from "@t3tools/contracts/j5"; import type { ProjectId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -193,6 +198,17 @@ export const resolveCrewProposal = Effect.fn("j5.http.resolveCrewProposal")(func return yield* HttpClientResponse.schemaBodyJson(CrewProposalResolveResponse)(response); }); +export const readFleet = Effect.fn("j5.http.readFleet")(function* ( + prepared: PreparedConnection, + input: FleetReadRequest = {}, +) { + const request = yield* HttpClientRequest.post(J5_API_PATHS.fleet).pipe( + HttpClientRequest.bodyJson(input), + ); + const response = yield* executeJ5Request(prepared, request, READ_TIMEOUT_MS); + return yield* HttpClientResponse.schemaBodyJson(FleetResponse)(response); +}); + export const listCrewMemberships = Effect.fn("j5.http.listCrewMemberships")(function* ( prepared: PreparedConnection, threadIds: ReadonlyArray, @@ -204,14 +220,6 @@ export const listCrewMemberships = Effect.fn("j5.http.listCrewMemberships")(func return (yield* HttpClientResponse.schemaBodyJson(CrewMembershipsResponse)(response)).entries; }); -export const readFleet = Effect.fn("j5.http.readFleet")(function* (prepared: PreparedConnection) { - const request = yield* HttpClientRequest.post(J5_API_PATHS.fleet).pipe( - HttpClientRequest.bodyJson({}), - ); - const response = yield* executeJ5Request(prepared, request, READ_TIMEOUT_MS); - return yield* HttpClientResponse.schemaBodyJson(FleetResponse)(response); -}); - export const listSpawnedChildren = Effect.fn("j5.http.listSpawnedChildren")(function* ( prepared: PreparedConnection, threadIds: ReadonlyArray, @@ -222,3 +230,25 @@ export const listSpawnedChildren = Effect.fn("j5.http.listSpawnedChildren")(func const response = yield* executeJ5Request(prepared, request, READ_TIMEOUT_MS); return (yield* HttpClientResponse.schemaBodyJson(SpawnedChildrenResponse)(response)).entries; }); + +export const archiveCrew = Effect.fn("j5.http.archiveCrew")(function* ( + prepared: PreparedConnection, + input: CrewArchiveRequest, +) { + const request = yield* HttpClientRequest.post(J5_API_PATHS.crewArchive).pipe( + HttpClientRequest.bodyJson(input), + ); + const response = yield* executeJ5Request(prepared, request, WRITE_TIMEOUT_MS); + return yield* HttpClientResponse.schemaBodyJson(CrewArchiveResponse)(response); +}); + +export const stopCrew = Effect.fn("j5.http.stopCrew")(function* ( + prepared: PreparedConnection, + input: CrewStopRequest, +) { + const request = yield* HttpClientRequest.post(J5_API_PATHS.crewStop).pipe( + HttpClientRequest.bodyJson(input), + ); + const response = yield* executeJ5Request(prepared, request, WRITE_TIMEOUT_MS); + return yield* HttpClientResponse.schemaBodyJson(CrewStopResponse)(response); +}); diff --git a/packages/client-runtime/src/j5/state.ts b/packages/client-runtime/src/j5/state.ts index 4590c2b039229..5c9b42dacc707 100644 --- a/packages/client-runtime/src/j5/state.ts +++ b/packages/client-runtime/src/j5/state.ts @@ -1,5 +1,11 @@ import type { ProjectId } from "@t3tools/contracts"; -import type { AnswerHumanExchangeRequest, CrewProposalResolveRequest } from "@t3tools/contracts/j5"; +import type { + AnswerHumanExchangeRequest, + CrewProposalResolveRequest, + CrewArchiveRequest, + CrewStopRequest, + FleetReadRequest, +} from "@t3tools/contracts/j5"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -54,8 +60,8 @@ export function createJ5EnvironmentAtoms( fleet: createEnvironmentQueryAtomFamily(runtime, { label: "j5:fleet", staleTimeMs: 30_000, - execute: (_input: Record) => - preparedConnection.pipe(Effect.flatMap(J5Http.readFleet)), + execute: (input: FleetReadRequest) => + preparedConnection.pipe(Effect.flatMap((prepared) => J5Http.readFleet(prepared, input))), }), // Crew gates are read per environment like the inbox; a Captain on any connected server // reaches the human's bell and thread. @@ -72,6 +78,16 @@ export function createJ5EnvironmentAtoms( Effect.flatMap((prepared) => J5Http.resolveCrewProposal(prepared, input)), ), }), + archiveCrew: createEnvironmentCommand(runtime, { + label: "j5:archive-crew", + execute: (input: CrewArchiveRequest) => + preparedConnection.pipe(Effect.flatMap((prepared) => J5Http.archiveCrew(prepared, input))), + }), + stopCrew: createEnvironmentCommand(runtime, { + label: "j5:stop-crew", + execute: (input: CrewStopRequest) => + preparedConnection.pipe(Effect.flatMap((prepared) => J5Http.stopCrew(prepared, input))), + }), createSquadron: createEnvironmentCommand(runtime, { label: "j5:create-squadron", execute: (input: { readonly name: string; readonly projectId: ProjectId }) => diff --git a/packages/contracts/src/j5.ts b/packages/contracts/src/j5.ts index 4f1d5b9184be3..33426fad76513 100644 --- a/packages/contracts/src/j5.ts +++ b/packages/contracts/src/j5.ts @@ -222,9 +222,68 @@ export const FleetSquadron = Schema.Struct({ crews: Schema.Array(FleetCrew), }); export type FleetSquadron = typeof FleetSquadron.Type; +/** The rail badge reads the live roster only; the Fleet page asks for retired Crews as well. */ +export const FleetReadRequest = Schema.Struct({ includeRetired: Schema.optional(Schema.Boolean) }); +export type FleetReadRequest = typeof FleetReadRequest.Type; export const FleetResponse = Schema.Struct({ squadrons: Schema.Array(FleetSquadron) }); export type FleetResponse = typeof FleetResponse.Type; +/** A person stopping a Crew from the app: every running seat is interrupted, nothing is retired. */ +export const CrewStopRequest = Schema.Struct({ crewInstanceId: Schema.String }); +export type CrewStopRequest = typeof CrewStopRequest.Type; +export const CrewStopResponse = Schema.Struct({ + crewInstanceId: Schema.String, + members: Schema.Array( + Schema.Struct({ + seat: Schema.String, + participantId: Schema.String, + result: Schema.Literals(["interrupt_requested", "already_idle", "archived"]), + }), + ), +}); +export type CrewStopResponse = typeof CrewStopResponse.Type; + +/** A live Crew the archived agent commands, seat by seat, as the archive dialog shows it. */ +export const PreArchiveLiveCrew = Schema.Struct({ + crewInstanceId: Schema.String, + crewName: Schema.String, + seats: Schema.Array( + Schema.Struct({ + seat: Schema.String, + participantId: Schema.String, + runningTurn: Schema.Boolean, + openAsks: Schema.Number, + }), + ), +}); +export type PreArchiveLiveCrew = typeof PreArchiveLiveCrew.Type; +/** The Crew seat the archived agent holds; seats are never archived one by one. */ +export const PreArchiveCrewSeat = Schema.Struct({ + crewInstanceId: Schema.String, + crewName: Schema.String, + seat: Schema.String, +}); +export type PreArchiveCrewSeat = typeof PreArchiveCrewSeat.Type; + +/** + * A person retiring a Crew from the app, after a dialog that listed every seat's consequences: + * the seats archive as one unit and nothing is deleted. + */ +export const CrewArchiveRequest = Schema.Struct({ crewInstanceId: Schema.String }); +export type CrewArchiveRequest = typeof CrewArchiveRequest.Type; +export const CrewArchiveResponse = Schema.Struct({ + crewInstanceId: Schema.String, + status: Schema.Literals(["archived", "already_archived"]), + members: Schema.Array( + Schema.Struct({ + seat: Schema.String, + participantId: Schema.String, + result: Schema.Literals(["archived", "already_archived"]), + }), + ), +}); +export type CrewArchiveResponse = typeof CrewArchiveResponse.Type; + export const J5_API_PATHS = { squadrons: "/api/j5/squadrons", threadHomes: "/api/j5/a2a/client-reads/participant-homes", @@ -233,6 +292,8 @@ export const J5_API_PATHS = { openCount: "/api/j5/a2a/client-reads/open-count", crewProposals: "/api/j5/a2a/crews/proposals", crewProposalResolve: "/api/j5/a2a/crews/proposals/resolve", + crewStop: "/api/j5/a2a/crews/stop", + crewArchive: "/api/j5/a2a/crews/archive", fleet: "/api/j5/a2a/client-reads/fleet", crewMemberships: "/api/j5/a2a/client-reads/crew-memberships", spawnedChildren: "/api/j5/a2a/client-reads/spawned-children", From e36cf67602c28640132b638a3699407bb0f19f3c Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Wed, 16 Sep 2026 23:21:13 -0400 Subject: [PATCH 2/7] fix(agents): a lone seat is refused on the server, and a boot sweep catches what the stream missed The rule that a Crew member is never archived alone lived only in the web preflight and the MCP verb, so a mobile swipe on an idle seat partially archived a live Crew. Every client door sends its archive as one dispatchCommand over the socket, so a J5-owned guard now sits on that handler and refuses thread.archive and thread.delete for a live Crew's seat with a message the client shows; archive_crew and the Captain cascade archive seats through the lifecycle service and never meet it. The Captain cascade and the settler ride a stream that resumes from its high-water mark and log rather than retry a failure, so the settler daemon runs one sweep before streaming: live Crews whose Captain thread is archived or deleted retire, and live seats whose newest run finished with nothing settling them settle and tell their Captains. Stop crew and Archive crew failures on the web now say so in a toast, and the adapter test's import order is upstream's again. Co-Authored-By: Claude Fable 5.1 --- FORK.md | 5 +- .../j5/a2a/AgentCrewInstanceService.test.ts | 2 + .../src/j5/a2a/AgentCrewInstanceService.ts | 11 ++ .../j5/a2a/CrewCaptainArchiveCascade.test.ts | 69 +++++++++++- .../src/j5/a2a/CrewCaptainArchiveCascade.ts | 58 +++++++++- .../src/j5/a2a/CrewSeatFinishNotifier.test.ts | 101 ++++++++++++++++++ .../src/j5/a2a/CrewSeatFinishNotifier.ts | 61 ++++++++++- apps/server/src/j5/a2a/PreArchiveFactsHttp.ts | 3 +- .../src/j5/a2a/crewSeatArchiveGuard.test.ts | 77 +++++++++++++ .../server/src/j5/a2a/crewSeatArchiveGuard.ts | 78 ++++++++++++++ .../Adapters/ClaudeAdapterV2.test.ts | 2 +- apps/server/src/ws.ts | 9 ++ apps/web/src/j5/fleet/FleetPage.tsx | 28 +++-- apps/web/src/j5/squadron/SpawnedChildren.tsx | 9 +- 14 files changed, 494 insertions(+), 19 deletions(-) create mode 100644 apps/server/src/j5/a2a/crewSeatArchiveGuard.test.ts create mode 100644 apps/server/src/j5/a2a/crewSeatArchiveGuard.ts diff --git a/FORK.md b/FORK.md index e22353c85a55e..bcd0627c157f3 100644 --- a/FORK.md +++ b/FORK.md @@ -75,7 +75,7 @@ Landing-order collision: two cases landed as `15.`; resolved by the `b` suffix o 20. E7's revised Squadron-picker row shape: `apps/web/src/components/CommandPalette.tsx:990-1004` delegates its row fields to J5-owned `apps/web/src/j5/squadron/SquadronPicker.logic.ts:18-23`. The visible row is the Squadron name alone and has no folder-description second line; folder title/path remain search terms, availability state, and launch substrate only. Amended by case 34 (Jackson's ruling, 2026-09-12): once the directory spans more than one environment, the row's description is the owning environment's label, because two environments can each hold a Squadron of the same name; a single-environment directory keeps the name-only row. The row helper's focused tests prove both the retained non-visual mechanics, the absence of a `description` field for one environment, and the label for two. On every rebase, preserve this presentation without changing the Registrar-derived identity or the missing-folder failure behavior. -21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone. The Captain rule itself is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the crew settler and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. +21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone up front; the rule itself is on the server since case 37. The Captain rule is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the crew settler and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). Both reactions ride a stream that resumes from its high-water mark and log rather than retry a failure, so the settler daemon runs one sweep before streaming (2026-09-17, Bryant's choice after Jackson's review of PR #151): `CrewCaptainArchiveCascade.reconcile` retires every live Crew whose Captain thread is archived or gone, and `CrewMemberSettler.reconcile` settles every live seat whose newest run finished with nothing settling it, telling its Captain as usual. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. 22. B4's typed confirmation presentation seam: `apps/web/src/confirmDialog.ts:4-31,92-158` extends only the in-memory `requestConfirmDialog(message, options, presentation?)` coordinator state with optional `ReactNode` content and `confirmLabel`; `apps/web/src/components/ConfirmDialogHost.tsx:57-67,79-116` renders the typed node and label when present and otherwise keeps its existing string title/description plus `Confirm` path. The host has no J5 import, payload decoding, or private prefix branch. J5 owns the archive row content in `apps/web/src/j5/a2a/ArchiveWarningContent.tsx:6-130`; `apps/web/src/j5/a2a/archiveFlow.ts:13-17,40-94` builds and passes it for consequential archive facts, with `Archive anyway` only when one or more open asks are measured and `Archive` otherwise. `apps/web/src/components/Sidebar.tsx:3169-3181` plus `apps/web/src/components/LegacySidebar.tsx:1824-1836,2012-2018` use the existing confirmation coordinator to carry the presentation. Existing string callers omit the third argument and remain byte-for-byte on the prior path. Jackson's Variant B title `Archive ?` supersedes the earlier Designer condition that put “anyway” in the question; the explicit button label now disambiguates the action. On every rebase, verify the host stays J5-agnostic, the absent-presentation string branch remains intact, and the three archive doors continue to pass J5-owned presentation only for fact-backed warnings. @@ -102,6 +102,7 @@ Landing-order collision: two cases landed as `15.`; resolved by the `b` suffix o 35. Issue #74's machine-sender surface (Jackson's 2026-09-15 ruling: a first-class scope rather than a scope-less token convention). `packages/contracts/src/auth.ts` appends the `a2a:send` literal as `AuthA2ASendScope` to `AuthEnvironmentScope`, deliberately outside both `AuthStandardClientScopes` and `AuthAdministrativeScopes`, so no pairing link and no administrative session carries it; only `j5 a2a token issue` mints it, bound by the session subject to one `machine:` participant, and the machine routes authorize on that scope plus a registered subject. `apps/server/src/bin.ts` appends `a2aCommand` from J5-owned `apps/server/src/j5/cli/a2a.ts` to the subcommand list. Everything else is add-beside: the `machine` participant kind in `apps/server/src/j5/a2a/contracts.ts`, migration 13, `MachineParticipantService.ts`, `RosterService.ts`, and `MachineSenderHttp.ts` entering through the case 9 aggregate; the machine envelope template and the web card's automation tag live in J5-owned files. On every rebase, verify the literal is still outside both bundles, `PAIRING_SCOPE_OPTIONS` in `ConnectionsSettings.tsx` still omits it, and the `a2aCommand` append survives. 36. Provider event log file names: `apps/server/src/provider/Layers/EventNdjsonLogger.ts` `resolveThreadSegment` ends the segment with a twelve-character hash of the whole thread id whenever normalization shortened the id (a colon, a run of punctuation, or the eighty-character cap), because platform-spawned threads (Crew seats, Peer Agents) carry deterministic ids identical up to the cut and every seat of a Crew was landing in one log file. Only a bare uuid id, which normalization leaves untouched, keeps its plain name; ids with colons that were under the cap change name once on upgrade. One appended branch; `EventNdjsonLogger.test.ts` covers it. +37. The lone-seat archive guard (2026-09-17, Bryant's choice of the server dispatch handler over a client-side mirror or an after-the-fact reversal, after Jackson's review of PR #151): `apps/server/src/ws.ts` yields J5-owned `makeCrewSeatArchiveGuard` (`apps/server/src/j5/a2a/crewSeatArchiveGuard.ts`) once beside `threadManagement` and calls it as one appended line in the `dispatchCommand` handler before the command is enqueued. Every client door (web sidebar, mobile swipe, any future client) sends `thread.archive` and `thread.delete` through that one handler, so a live Crew's seat is refused there with a typed error whose message reaches the client through the existing dispatch-error detail; `archive_crew` and the Captain cascade archive seats through the lifecycle service and never meet the guard. A Crew store that cannot be read lets the command through, because a refusal rests on a live fact, not an outage. The web keeps its pre-archive toast as the friendlier early message. DV5 extends cases 9–12 without opening a second creation door. `apps/web/src/j5/squadron/FirstRunGate.tsx` creates a named Squadron only from the one explicitly selected folder, and state-names an unavailable or read-only environment refusal (originally a non-primary refusal; case 34 routes creation to the folder's own environment); `Sidebar.tsx` replaces the old project scope zone with a Squadron scope whose pure predicate filters the real thread list only by immutable Registrar home: selected scope excludes unknown/native, while the explicit unscoped state shows all rows. `DraftHeroHeadline.tsx` renders only `What should we build in ⟨Squadron⟩?`. The plan→implementation behavior is recorded separately in case 11, including its additive `sourcePlanRef` contract exception. On every rebase, verify the inherited-home lookup remains after durable child identity and before preparation, the Sidebar home read has no project proxy, and the separately named mobile, scheduled, system-bootstrap, and agent-spawn returns remain explicit in `SquadronLaunchPolicy.ts` and `docs/j5/product/dogfood-v0.md` DV5. @@ -557,7 +558,7 @@ indicate approval. The deleted hook remains explicitly marked. | `apps/server/src/serviceLauncher.ts` | R | H | | `apps/server/src/telemetry/Identify.ts` | R | H | | `apps/server/src/textGeneration/CodexTextGeneration.test.ts` | N | T | -| `apps/server/src/ws.ts` | A | 10, 11 | +| `apps/server/src/ws.ts` | A | 10, 11, 37 | | `apps/web/index.html` | R | B | | `apps/web/src/branding.test.ts` | R | B | | `apps/web/src/branding.ts` | R | B | diff --git a/apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts b/apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts index ba5b347cf948f..403e175c24a88 100644 --- a/apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts +++ b/apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts @@ -97,9 +97,11 @@ it.effect("records a crew once, exposes membership, and lists by captain", () => }), [], ); + assert.deepStrictEqual(yield* service.listLive(), [first]); yield* service.markArchived(input.id, "2026-09-09T17:00:00.000Z"); yield* service.markArchived(input.id, "2026-09-09T18:00:00.000Z"); assert.equal((yield* service.read(input.id))?.archivedAt, "2026-09-09T17:00:00.000Z"); + assert.deepStrictEqual(yield* service.listLive(), []); // A retired Crew's seat is a plain agent again: it may spawn, propose, and be archived alone. assert.isNull(yield* service.findMembership(input.members[1]!.participantId)); }).pipe(Effect.provide(testLayer)), diff --git a/apps/server/src/j5/a2a/AgentCrewInstanceService.ts b/apps/server/src/j5/a2a/AgentCrewInstanceService.ts index cc8d476592510..33372e2ab869b 100644 --- a/apps/server/src/j5/a2a/AgentCrewInstanceService.ts +++ b/apps/server/src/j5/a2a/AgentCrewInstanceService.ts @@ -99,6 +99,8 @@ export interface AgentCrewInstanceServiceShape { readonly listForSquadron: ( squadronId: SquadronId, ) => Effect.Effect, SqlError>; + /** Every Crew not yet retired, across Squadrons; the boot reconciliation walks this. */ + readonly listLive: () => Effect.Effect, SqlError>; /** Every Crew that any of these threads sits in or that any of these participants commands. */ readonly listInvolving: (input: { readonly threadIds: ReadonlyArray; @@ -325,6 +327,14 @@ export const layer: Layer.Layer` + SELECT * FROM j5_agent_crew_instance WHERE archived_at IS NULL + ORDER BY created_at, id + `; + return yield* readMany(rows); + }); + const listInvolving = Effect.fn("j5.a2a.agentCrewInstances.listInvolving")(function* (input: { readonly threadIds: ReadonlyArray; readonly participantIds: ReadonlyArray; @@ -384,6 +394,7 @@ export const layer: Layer.Layer ({ }); const archivedEvent = (threadId: ThreadId, type = "thread.archived"): OrchestrationV2StoredEvent => ({ sequence: 1, commandId: null, event: { type, threadId } }) as never; +const captainProjection = (archivedAt: string | null, deletedAt: string | null = null) => + ({ + thread: { id: captainThread, archivedAt, deletedAt }, + }) as unknown as OrchestrationV2ThreadProjection; +const noThreads = Layer.mock(ThreadManagementService)({}); it.effect("retires the live Crews a Captain commanded when the person archives its thread", () => Effect.gen(function* () { @@ -57,6 +67,7 @@ it.effect("retires the live Crews a Captain commanded when the person archives i Effect.as({ status: "archived" as const, members: [] }), ), }), + noThreads, ), ), ); @@ -85,3 +96,59 @@ it.effect("retires the live Crews a Captain commanded when the person archives i }).pipe(Effect.provide(layer)); }), ); + +it.effect( + "the boot sweep retires live Crews whose Captain is archived or gone, and no others", + () => + Effect.gen(function* () { + const goneCaptain = ThreadId.make("thread:gone-captain"); + const liveCaptain = ThreadId.make("thread:live-captain"); + const unreadable = ThreadId.make("thread:unreadable-captain"); + const commanded = (id: string, captain: ThreadId): AgentCrewInstance => ({ + ...crew(id, null), + captainThreadId: captain, + captainParticipantId: participantIdForThread(captain), + }); + const calls = yield* Ref.make>([]); + const layer = cascadeLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(AgentCrewInstanceService)({ + listLive: () => + Effect.succeed([ + crew("crew:archived-captain", null), + commanded("crew:gone-captain", goneCaptain), + commanded("crew:live-captain", liveCaptain), + commanded("crew:unreadable", unreadable), + ]), + }), + Layer.mock(ArchiveCrewService)({ + archive: (input) => + Ref.update(calls, (items) => [...items, input]).pipe( + Effect.as({ status: "archived" as const, members: [] }), + ), + }), + Layer.mock(ThreadManagementService)({ + getThreadProjection: (threadId) => + threadId === captainThread + ? Effect.succeed(captainProjection("2026-09-17T08:00:00.000Z")) + : threadId === liveCaptain + ? Effect.succeed(captainProjection(null)) + : threadId === goneCaptain + ? Effect.succeed(captainProjection(null, "2026-09-17T08:30:00.000Z")) + : Effect.die(new Error("projection store unavailable")), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const cascade = yield* CrewCaptainArchiveCascade; + const retired = yield* cascade.reconcile; + assert.deepStrictEqual(retired, ["crew:archived-captain", "crew:gone-captain"]); + assert.deepStrictEqual( + (yield* Ref.get(calls)).map((call) => call.crewInstanceId), + ["crew:archived-captain", "crew:gone-captain"], + ); + }).pipe(Effect.provide(layer)); + }), +); diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts index b58778db93522..c917e241d572b 100644 --- a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts @@ -3,7 +3,9 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; +import { ThreadManagementService } from "../../orchestration-v2/ThreadManagementService.ts"; import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; import { ArchiveCrewService } from "./ArchiveCrewService.ts"; import { participantIdForThread } from "./HomeRegistrar.ts"; @@ -19,6 +21,13 @@ export interface CrewCaptainArchiveCascadeShape { readonly handleStoredEvent: ( stored: OrchestrationV2StoredEvent, ) => Effect.Effect | null>; + /** + * The boot sweep: retire every live Crew whose Captain thread is already archived or gone. + * Covers a cascade that failed partway (the loop logs and moves on, so nothing else retries + * it) and an archive that landed while the server was down, since the event stream resumes + * from its high-water mark. Returns the ids retired. + */ + readonly reconcile: Effect.Effect>; } export class CrewCaptainArchiveCascade extends Context.Service< @@ -41,6 +50,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const crews = yield* AgentCrewInstanceService; const archives = yield* ArchiveCrewService; + const threads = yield* ThreadManagementService; const retire = Effect.fn("j5.a2a.captainArchiveCascade.retire")(function* ( captainThreadId: ThreadId, @@ -109,6 +119,52 @@ export const layer = Layer.effect( ), ); - return CrewCaptainArchiveCascade.of({ handleStoredEvent }); + /** + * Archived or deleted: either way the Captain no longer commands, so its Crews retire. A + * deleted thread keeps its projection with `deletedAt` set, so both read as facts; a + * projection that cannot be read is the store's problem and leaves the Crew alone this pass. + */ + const captainIsGone = Effect.fn("j5.a2a.captainArchiveCascade.captainIsGone")(function* ( + instance: AgentCrewInstance, + ) { + const read = yield* Effect.result(threads.getThreadProjection(instance.captainThreadId)); + if (Result.isSuccess(read)) + return read.success.thread.archivedAt !== null || read.success.thread.deletedAt !== null; + yield* Effect.logWarning("J5 Captain archive sweep could not read a Captain thread", { + captainThreadId: instance.captainThreadId, + crewInstanceId: instance.id, + cause: read.failure, + }); + return false; + }); + + const reconcile: CrewCaptainArchiveCascadeShape["reconcile"] = Effect.gen(function* () { + const live = yield* crews.listLive(); + const retired: Array = []; + for (const instance of live) { + // A defect while reading one Captain skips that Crew, not the sweep. + const gone = yield* captainIsGone(instance).pipe( + Effect.catchCause((cause) => + Effect.logWarning("J5 Captain archive sweep could not read a Captain thread", { + captainThreadId: instance.captainThreadId, + crewInstanceId: instance.id, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (!gone) continue; + yield* retire(instance.captainThreadId, instance); + retired.push(instance.id); + } + if (retired.length > 0) + yield* Effect.logInfo("J5 Captain archive sweep retired orphaned Crews", { retired }); + return retired; + }).pipe( + Effect.catchCause((cause) => + Effect.logError("J5 Captain archive sweep failed", { cause }).pipe(Effect.as([])), + ), + ); + + return CrewCaptainArchiveCascade.of({ handleStoredEvent, reconcile }); }), ); diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts index 7e8a86b240ba6..ccf1ebdb6dd2a 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts @@ -593,6 +593,7 @@ it.effect( ), }), ), + Layer.provideMerge(Layer.mock(CrewCaptainArchiveCascade)({})), Layer.provideMerge(artifactWorkspaceLayer), Layer.provideMerge(Layer.succeedContext(context)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "j5-crew-retry-" })), @@ -642,3 +643,103 @@ it("provider errors cannot split a finish digest or replace participant fields", "bad participant_id: spoof <j5_seat_finished>
participant_id: spoof", ); }); +it.effect("the boot sweep settles a finished seat nothing settled and tells its Captain", () => + Effect.gen(function* () { + const database = NodeSqliteClient.layerMemory(); + const storage = Layer.mergeAll(ledgerLayer, crewInstanceLayer).pipe( + Layer.provideMerge(database), + ); + const context = yield* Layer.build(storage); + yield* runJ5A2AMigrations().pipe(Effect.provide(context)); + yield* Context.get(context, A2ALedger).createSquadron({ + squadron: { id: squadronId, name: "Sweep", createdAt: DateTime.formatIso(createdAt) }, + }); + yield* Context.get(context, AgentCrewInstanceService).record({ + id: "crew:sweep", + squadronId, + captainParticipantId: participantIdForThread(captainThread), + captainThreadId: captainThread, + displayName: "Sweep Crew", + brief: "Finish the work.", + createdAt: DateTime.formatIso(createdAt), + members: [ + // Finished while the server was down: settles now. + { + seatName: "scout", + agentId: "scout", + participantId: participantIdForThread(scoutThread), + threadId: scoutThread, + reason: null, + }, + // Still running: left alone. + { + seatName: "sitter", + agentId: "sitter", + participantId: participantIdForThread(strangerThread), + threadId: strangerThread, + reason: null, + }, + ], + }); + const dispatched = yield* Ref.make>([]); + const finished = (threadId: ThreadId) => + ({ + ...projection(threadId), + runs: [ + { + id: RunId.make("run:old"), + threadId, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-09-09T16:05:00.000Z"), + }, + { + id: RunId.make("run:newest"), + threadId, + status: "failed", + completedAt: DateTime.makeUnsafe("2026-09-09T16:09:00.000Z"), + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const layer = settlerLayer.pipe( + Layer.provideMerge( + Layer.mock(ThreadManagementService)({ + getThreadProjection: (threadId) => + Effect.succeed( + threadId === scoutThread + ? finished(threadId) + : threadId === strangerThread + ? projection(threadId, { running: true }) + : projection(threadId), + ), + dispatch: (command) => + Ref.update(dispatched, (items) => [...items, command]).pipe( + Effect.as({ events: [], effects: [] } as never), + ), + }), + ), + Layer.provideMerge(Layer.mock(CrewCaptainArchiveCascade)({})), + Layer.provideMerge(artifactWorkspaceLayer), + Layer.provideMerge(Layer.succeedContext(context)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "j5-crew-sweep-" })), + Layer.provideMerge(NodeServices.layer), + ); + yield* Effect.gen(function* () { + const settler = yield* CrewMemberSettler; + assert.deepStrictEqual(yield* settler.reconcile, [scoutThread]); + const commands = yield* Ref.get(dispatched); + assert.deepStrictEqual( + commands.map((command) => command.type), + ["message.dispatch", "thread.settle"], + ); + const notice = commands[0]; + if (notice?.type === "message.dispatch") { + assert.equal(notice.threadId, captainThread); + assert.include(notice.text, "seat: scout"); + // The newest finish is the one reported. + assert.include(notice.text, "run_status: failed"); + } + const settle = commands[1]; + if (settle?.type === "thread.settle") assert.equal(settle.threadId, scoutThread); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts index e19c4c0e9209c..195905ab5dc13 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts @@ -56,6 +56,12 @@ export interface CrewSeatFinishNotifierShape { readonly handleStoredEvent: ( event: OrchestrationV2StoredEvent, ) => Effect.Effect; + /** + * The boot sweep: settle every live Crew member whose last run finished while nothing settled + * it, telling its Captain as usual. Covers a notice that failed (the seat was left unsettled + * on purpose) and a finish that landed while the server was down. Returns the threads settled. + */ + readonly reconcile: Effect.Effect>; } export class CrewSeatFinishNotifier extends Context.Service< @@ -340,6 +346,52 @@ const makeLayer = (daemon: boolean) => return threadId; }); + /** The run a sweep would report: the newest finish, only when nothing is running. */ + const outstandingFinish = (projection: OrchestrationV2ThreadProjection) => { + if (ThreadManagement.latestActiveRun(projection) !== undefined) return undefined; + return projection.runs + .filter((run) => run.status === "completed" || run.status === "failed") + .toSorted( + (a, b) => + (b.completedAt === null ? 0 : DateTime.toEpochMillis(b.completedAt)) - + (a.completedAt === null ? 0 : DateTime.toEpochMillis(a.completedAt)), + )[0]; + }; + + const reconcile: CrewSeatFinishNotifierShape["reconcile"] = Effect.gen(function* () { + const reportedNow: Array = []; + for (const instance of yield* crews.listLive()) { + for (const member of instance.members) { + // A seat whose thread cannot be read (gone, or the store hiccuped) is skipped this pass. + const projection = yield* threads + .getThreadProjection(member.threadId) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (projection === null) continue; + if (projection.thread.archivedAt !== null) continue; + const run = outstandingFinish(projection); + if (run === undefined) continue; + const outcome = yield* notifyIfFinished(member.threadId, run).pipe( + Effect.catchCause((cause) => + Effect.logWarning("J5 crew seat finish sweep skipped a seat", { + threadId: member.threadId, + cause, + }).pipe(Effect.as(null)), + ), + ); + if (outcome !== null) reportedNow.push(outcome); + } + } + if (reportedNow.length > 0) + yield* Effect.logInfo("J5 crew seat finish sweep reported finished seats", { + reportedNow, + }); + return reportedNow; + }).pipe( + Effect.catchCause((cause) => + Effect.logError("J5 crew seat finish sweep failed", { cause }).pipe(Effect.as([])), + ), + ); + const handleStoredEvent: CrewSeatFinishNotifierShape["handleStoredEvent"] = (stored) => Effect.gen(function* () { const run = finishedRun(stored); @@ -354,8 +406,9 @@ const makeLayer = (daemon: boolean) => ); if (daemon) { - // Start from the current high-water mark: a missed finish is harmless and the next - // terminal run for that member catches up. + // The stream starts from the current high-water mark, so everything that landed while + // the server was down, and every reaction that failed before it, is caught up by one + // sweep first: orphaned Crews retire, finished seats tell their Captains. const runDaemon = Effect.gen(function* () { const rows = yield* sql<{ readonly sequence: number }>` SELECT COALESCE(MAX(sequence), 0) AS sequence FROM orchestration_v2_events @@ -364,6 +417,8 @@ const makeLayer = (daemon: boolean) => // Launches the server lost mid-report are reported first, so a first turn that failed // while it was down is a launch outcome rather than a finish. yield* reporter.reconcile; + yield* cascade.reconcile; + yield* reconcile; // Suspended so each resume after a stream failure starts from the last handled // sequence rather than from the daemon's start. return yield* Effect.forever( @@ -388,7 +443,7 @@ const makeLayer = (daemon: boolean) => yield* Effect.forkScoped(runDaemon); } - return CrewSeatFinishNotifier.of({ handleStoredEvent }); + return CrewSeatFinishNotifier.of({ handleStoredEvent, reconcile }); }), ); diff --git a/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts b/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts index fcfd02102239d..c168bdf1df546 100644 --- a/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts +++ b/apps/server/src/j5/a2a/PreArchiveFactsHttp.ts @@ -81,7 +81,8 @@ const projectLiveCrew = (entry: ArchiveCrewCaptainFacts): PreArchiveLiveCrew => * facts carry its Crew relations, because a Captain is never archived alone and a seat is never * archived one by one (Crews AC16, AC17): `liveCrews` are the Crews it commands, seat by seat, * which the lifecycle cascade retires as units when the archive commits, and `crewSeat` is the - * seat it holds, which the web refuses to archive alone. A failed Crew read is `null`, shown as + * seat it holds, which the web refuses to archive alone up front (the server's dispatch guard, + * `crewSeatArchiveGuard.ts`, refuses it for every door). A failed Crew read is `null`, shown as * "couldn't check", never as no Crews. */ export const preArchiveFactsHttpRouteLayer = Layer.unwrap( diff --git a/apps/server/src/j5/a2a/crewSeatArchiveGuard.test.ts b/apps/server/src/j5/a2a/crewSeatArchiveGuard.test.ts new file mode 100644 index 0000000000000..557099eeb62d4 --- /dev/null +++ b/apps/server/src/j5/a2a/crewSeatArchiveGuard.test.ts @@ -0,0 +1,77 @@ +import { assert, it } from "@effect/vitest"; +import { CommandId, ThreadId, type OrchestrationV2Command } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { AgentCrewInstanceService, type AgentCrewInstance } from "./AgentCrewInstanceService.ts"; +import { makeCrewSeatArchiveGuard } from "./crewSeatArchiveGuard.ts"; +import { participantIdForThread } from "./HomeRegistrar.ts"; +import { ParticipantId, SquadronId } from "./contracts.ts"; + +const seatThread = ThreadId.make("thread:seat"); +const retiredSeatThread = ThreadId.make("thread:retired-seat"); +const plainThread = ThreadId.make("thread:plain"); +const instance = (id: string, archivedAt: string | null): AgentCrewInstance => ({ + id, + squadronId: SquadronId.make("squadron:guard"), + captainParticipantId: ParticipantId.make("agent:j5:a2a:thread:captain"), + captainThreadId: ThreadId.make("thread:captain"), + displayName: "Review Pair", + brief: "Review it.", + version: 1, + createdAt: "2026-09-17T09:00:00.000Z", + archivedAt, + members: [], +}); +const command = (type: "thread.archive" | "thread.delete" | "thread.pin", threadId: ThreadId) => + ({ + type, + commandId: CommandId.make(`cmd:${type}:${threadId}`), + threadId, + }) as OrchestrationV2Command; + +it.effect( + "refuses to archive or delete a live Crew's seat alone and lets every other thread through", + () => + Effect.gen(function* () { + const crews = Layer.mock(AgentCrewInstanceService)({ + findMembership: (participantId) => + Effect.succeed( + participantId === participantIdForThread(seatThread) + ? { crewInstanceId: "crew:live", seatName: "critic" } + : participantId === participantIdForThread(retiredSeatThread) + ? { crewInstanceId: "crew:old", seatName: "critic" } + : null, + ), + read: (id) => + Effect.succeed( + id === "crew:live" + ? instance(id, null) + : id === "crew:old" + ? instance(id, "2026-09-17T10:00:00.000Z") + : null, + ), + }); + const guard = yield* makeCrewSeatArchiveGuard.pipe(Effect.provide(crews)); + const refused = yield* guard(command("thread.archive", seatThread)).pipe(Effect.flip); + assert.equal(refused._tag, "CrewSeatArchivedAloneError"); + assert.include(refused.message, "seat critic of Crew Review Pair"); + assert.include(refused.message, "never archived one by one"); + const deleted = yield* guard(command("thread.delete", seatThread)).pipe(Effect.flip); + assert.include(deleted.message, "never deleted one by one"); + // A retired Crew's seat is a plain agent again; other commands are not the guard's business. + yield* guard(command("thread.archive", retiredSeatThread)); + yield* guard(command("thread.archive", plainThread)); + yield* guard(command("thread.pin", seatThread)); + }), +); + +it.effect("lets the archive through when the Crew store cannot be read", () => + Effect.gen(function* () { + const crews = Layer.mock(AgentCrewInstanceService)({ + findMembership: () => Effect.die(new Error("database locked")), + }); + const guard = yield* makeCrewSeatArchiveGuard.pipe(Effect.provide(crews)); + yield* guard(command("thread.archive", seatThread)); + }), +); diff --git a/apps/server/src/j5/a2a/crewSeatArchiveGuard.ts b/apps/server/src/j5/a2a/crewSeatArchiveGuard.ts new file mode 100644 index 0000000000000..99ea1b906b588 --- /dev/null +++ b/apps/server/src/j5/a2a/crewSeatArchiveGuard.ts @@ -0,0 +1,78 @@ +import type { OrchestrationV2Command, ThreadId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; + +import { AgentCrewInstanceService } from "./AgentCrewInstanceService.ts"; +import { participantIdForThread } from "./HomeRegistrar.ts"; + +/** + * A Crew member is never archived or deleted one by one (Crews AC16): the unit retires through + * Archive crew, on the Fleet page or by its Captain's `archive_crew`, or when the Captain's own + * thread is archived and the cascade retires its Crews. Every client door sends its archive as + * one `dispatchCommand` over the socket, so this check sits on that handler and covers the web + * sidebar, a mobile swipe, and any future door alike; the web's pre-archive read still gives the + * person the friendlier early toast. `archive_crew` and the cascade archive seats through the + * lifecycle service, not this handler, so the unit paths are untouched. + */ +export class CrewSeatArchivedAloneError extends Data.TaggedError("CrewSeatArchivedAloneError")<{ + readonly threadId: string; + readonly action: "archive" | "delete"; + readonly seatName: string; + readonly crewName: string; + readonly crewInstanceId: string; +}> { + override get message(): string { + return `This thread is seat ${this.seatName} of Crew ${this.crewName}. Crew members are never ${this.action === "archive" ? "archived" : "deleted"} one by one; retire the whole Crew with Archive crew on the Fleet page, or message the member instead.`; + } +} + +const guardedTarget = ( + command: OrchestrationV2Command, +): { readonly action: "archive" | "delete"; readonly threadId: ThreadId } | null => + command.type === "thread.archive" + ? { action: "archive", threadId: command.threadId } + : command.type === "thread.delete" + ? { action: "delete", threadId: command.threadId } + : null; + +export type CrewSeatArchiveGuard = ( + command: OrchestrationV2Command, +) => Effect.Effect; + +/** + * Resolves the Crew store once and returns the per-command check. A store that cannot be read + * never blocks the person's archive: the check logs and lets the command through, because a + * refusal must rest on a live fact, not on an outage. + */ +export const makeCrewSeatArchiveGuard: Effect.Effect< + CrewSeatArchiveGuard, + never, + AgentCrewInstanceService +> = Effect.gen(function* () { + const crews = yield* AgentCrewInstanceService; + return Effect.fn("j5.a2a.crewSeatArchiveGuard")(function* (command: OrchestrationV2Command) { + const target = guardedTarget(command); + if (target === null) return; + const seat = yield* Effect.gen(function* () { + const membership = yield* crews.findMembership(participantIdForThread(target.threadId)); + if (membership === null) return null; + const instance = yield* crews.read(membership.crewInstanceId); + return instance === null || instance.archivedAt !== null + ? null + : { + seatName: membership.seatName, + crewName: instance.displayName, + crewInstanceId: instance.id, + }; + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("J5 crew seat archive guard could not read the Crew; allowing", { + threadId: target.threadId, + cause, + }).pipe(Effect.as(null)), + ), + ); + if (seat === null) return; + return yield* new CrewSeatArchivedAloneError({ ...target, ...seat }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 48e2846f59c2f..3d1de1827f206 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -55,7 +55,6 @@ import { type ProviderAdapterV2TurnInput, } from "../ProviderAdapter.ts"; import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; -import { J5_CLAUDE_MCP_ALLOWED_TOOLS } from "../../j5/a2a/mcp/claudeAllowedTools.ts"; import { CLAUDE_AGENT_SDK_QUERY_PROTOCOL, CLAUDE_DEFAULT_INSTANCE_ID, @@ -83,6 +82,7 @@ import { type ClaudeAgentSdkQueryOptions, type ClaudeAgentSdkQueryOpenInput, } from "./ClaudeAdapterV2.ts"; +import { J5_CLAUDE_MCP_ALLOWED_TOOLS } from "../../j5/a2a/mcp/claudeAllowedTools.ts"; import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; const DEFAULT_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 18f314531b9cf..62d1bc6cedd85 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -133,6 +133,10 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { + makeCrewSeatArchiveGuard, + type CrewSeatArchiveGuard, +} from "./j5/a2a/crewSeatArchiveGuard.ts"; import { makeAgentPersonaRpcHandlers } from "./j5/agents/agentPersonaRpc.ts"; import { makeArtifactRpcHandlers } from "./j5/artifacts/artifactRpc.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -527,6 +531,8 @@ const makeWsRpcLayer = ( clientAnalyticsProps: Readonly>, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], artifactWorkspace: ArtifactWorkspace.ArtifactWorkspace["Service"], + // J5: a Crew member is never archived or deleted alone, whichever client door asks. + j5CrewSeatArchiveGuard: CrewSeatArchiveGuard, ) => ServerWsRpcGroup.toLayer( Effect.gen(function* () { @@ -1346,6 +1352,7 @@ const makeWsRpcLayer = ( claimed === null || command.type !== "message.dispatch" ? command : { ...command, attachments: claimed.attachments }; + yield* j5CrewSeatArchiveGuard(command); return yield* startup .enqueueCommand( threadManagement.dispatch( @@ -2717,6 +2724,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const artifactWorkspace = yield* ArtifactWorkspace.ArtifactWorkspace; + const j5CrewSeatArchiveGuard = yield* makeCrewSeatArchiveGuard; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; // J5: the revision counter the saved-agent handoff observer bumps; one instance per server. @@ -2765,6 +2773,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( clientAnalyticsProps, previewAutomationBroker, artifactWorkspace, + j5CrewSeatArchiveGuard, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), diff --git a/apps/web/src/j5/fleet/FleetPage.tsx b/apps/web/src/j5/fleet/FleetPage.tsx index 865ad12029241..56148bd4a3c14 100644 --- a/apps/web/src/j5/fleet/FleetPage.tsx +++ b/apps/web/src/j5/fleet/FleetPage.tsx @@ -14,6 +14,7 @@ import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { ScrollArea } from "../../components/ui/scroll-area"; import { SidebarInset } from "../../components/ui/sidebar"; +import { toastManager } from "../../components/ui/toast"; import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { useThreadShells } from "../../state/entities"; @@ -231,14 +232,19 @@ function FleetNodeRows( ), ); // The person's Stop crew: interrupts every running seat, retires nothing. The seats' status - // pills already tell the truth afterwards, so a failed call needs no second message here. + // pills tell the truth afterwards; a refused or failed call says so, since a button that does + // nothing visible is a lying spinner. const [busy, setBusy] = useState(null); - const stop = async (crewInstanceId: string) => { - setBusy(crewInstanceId); + const stop = async (crew: { crewInstanceId: string; crewName: string }) => { + setBusy(crew.crewInstanceId); try { - await stopCrew(props.environmentId, crewInstanceId); - } catch { - // measured state on the rows is the report + await stopCrew(props.environmentId, crew.crewInstanceId); + } catch (error) { + toastManager.add({ + type: "error", + title: `Could not stop crew ${crew.crewName}`, + description: error instanceof Error ? error.message : String(error), + }); } finally { setBusy(null); } @@ -294,8 +300,12 @@ function FleetNodeRows( try { await archiveCrew(props.environmentId, crew.crewInstanceId); refreshFleet(); - } catch { - // the rows keep reporting the measured state; the person can retry + } catch (error) { + toastManager.add({ + type: "error", + title: `Could not archive crew ${crew.crewName}`, + description: error instanceof Error ? error.message : String(error), + }); } finally { setBusy(null); } @@ -338,7 +348,7 @@ function FleetNodeRows( onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void stop(crew.crewInstanceId); + void stop(crew); }} size="xs" variant="outline" diff --git a/apps/web/src/j5/squadron/SpawnedChildren.tsx b/apps/web/src/j5/squadron/SpawnedChildren.tsx index ca70b26f9fdb8..7cca6789adaa2 100644 --- a/apps/web/src/j5/squadron/SpawnedChildren.tsx +++ b/apps/web/src/j5/squadron/SpawnedChildren.tsx @@ -9,6 +9,7 @@ import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; import { resolveThreadStatusPill } from "../../components/Sidebar.logic"; import { Badge } from "../../components/ui/badge"; import { ProviderInstanceIcon } from "../../components/chat/ProviderInstanceIcon"; +import { toastManager } from "../../components/ui/toast"; import { cn } from "../../lib/utils"; import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../../providerInstances"; import { useThreadShells } from "../../state/entities"; @@ -182,7 +183,13 @@ function SpawnedChildGroupRows(props: { event.stopPropagation(); setStopping(true); void stopCrew(props.environmentId, stoppable.crewInstanceId) - .catch(() => undefined) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: `Could not stop crew ${stoppable.crewName}`, + description: error instanceof Error ? error.message : String(error), + }); + }) .finally(() => setStopping(false)); }} > From 53253c6dc9224adde51cd6d8a71471f00ee28fe1 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Thu, 17 Sep 2026 08:07:19 -0400 Subject: [PATCH 3/7] fix(agents): shorter stop_crew and archive_crew descriptions, and the boot sweep reports instead of settling Jackson's trims taken: the descriptions lean on the schema and the refusal payload for the details, archive no longer says for good since seat threads can be unarchived, and the agent checks with the user. The sweep and the archive cascade name the seat finish notifier now that no seat is settled by the platform. Co-Authored-By: Claude Fable 5.1 --- FORK.md | 2 +- .../src/j5/a2a/ArchiveCrewService.test.ts | 2 +- apps/server/src/j5/a2a/ArchiveCrewService.ts | 2 +- .../src/j5/a2a/CrewSeatFinishNotifier.test.ts | 67 +++++++++---------- .../src/j5/a2a/CrewSeatFinishNotifier.ts | 6 +- apps/server/src/j5/a2a/mcp/handlers.test.ts | 2 +- apps/server/src/j5/a2a/mcp/tools.test.ts | 2 +- apps/server/src/j5/a2a/mcp/tools.ts | 4 +- docs/j5/product/a2a/agent-tools.md | 21 +++--- docs/j5/product/features/crews.md | 2 +- 10 files changed, 52 insertions(+), 58 deletions(-) diff --git a/FORK.md b/FORK.md index bcd0627c157f3..1764ecfae7745 100644 --- a/FORK.md +++ b/FORK.md @@ -75,7 +75,7 @@ Landing-order collision: two cases landed as `15.`; resolved by the `b` suffix o 20. E7's revised Squadron-picker row shape: `apps/web/src/components/CommandPalette.tsx:990-1004` delegates its row fields to J5-owned `apps/web/src/j5/squadron/SquadronPicker.logic.ts:18-23`. The visible row is the Squadron name alone and has no folder-description second line; folder title/path remain search terms, availability state, and launch substrate only. Amended by case 34 (Jackson's ruling, 2026-09-12): once the directory spans more than one environment, the row's description is the owning environment's label, because two environments can each hold a Squadron of the same name; a single-environment directory keeps the name-only row. The row helper's focused tests prove both the retained non-visual mechanics, the absence of a `description` field for one environment, and the label for two. On every rebase, preserve this presentation without changing the Registrar-derived identity or the missing-folder failure behavior. -21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone up front; the rule itself is on the server since case 37. The Captain rule is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the crew settler and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). Both reactions ride a stream that resumes from its high-water mark and log rather than retry a failure, so the settler daemon runs one sweep before streaming (2026-09-17, Bryant's choice after Jackson's review of PR #151): `CrewCaptainArchiveCascade.reconcile` retires every live Crew whose Captain thread is archived or gone, and `CrewMemberSettler.reconcile` settles every live seat whose newest run finished with nothing settling it, telling its Captain as usual. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. +21. B4's archive preflight is a J5-owned, per-thread read before every web archive door: `apps/web/src/j5/a2a/archiveFlow.ts` carries the complete `ScopedThreadRef`, and `archiveFlowClient.ts` resolves that ref's environment before its authenticated `/api/j5/a2a/pre-archive-facts` POST; it must never fall back to the primary environment. `apps/server/src/j5/a2a/J5AuthenticatedRoutes.ts` composes the read through the one authenticated J5 aggregate, while `runtimeLayer.ts` supplies the real `placementFactsLayer` alongside A2S's `archiveAgentLayer`. `ArchiveFactsService` reports a failed placement read explicitly as unknown, and the web warning renders a failed preflight as “Couldn't check” rather than a reassuring empty list. `Sidebar.tsx` and `LegacySidebar.tsx` route their direct, bulk, and hover archive paths through this same flow: consequential threads get the measured destructive warning; clean threads preserve their existing generic confirmation behavior, with batch cancellation and unresolved selection recovery retained. Mobile archive doors remain explicitly deferred. Since 2026-09-15 the preflight read also attaches the thread's Crew relations with no further Sidebar edit: the live Crews its agent commands (`ArchiveCrewService.readCaptainFacts`), which the warning lists seat by seat, and the seat it holds, which `archiveWithPreflight` refuses to archive alone up front; the rule itself is on the server since case 37. The Captain rule is enforced server-side: J5-owned `CrewCaptainArchiveCascade.ts` reads the same stored-event stream as the seat finish notifier and retires a Captain's live Crews as units when its `thread.archived` or `thread.deleted` commits, whichever door the archive came through; the person's own Archive crew on the Fleet header uses the J5 route `POST /api/j5/a2a/crews/archive` (`CrewArchiveHttp.ts`, operate scope). Both reactions ride a stream that resumes from its high-water mark and log rather than retry a failure, so the notifier daemon runs one sweep before streaming (2026-09-17, Bryant's choice after Jackson's review of PR #151): `CrewCaptainArchiveCascade.reconcile` retires every live Crew whose Captain thread is archived or gone, and `CrewSeatFinishNotifier.reconcile` tells the Captain of every live seat whose newest run finished with nothing reporting it. On every rebase, verify the web call sites still carry the scoped environment through the client seam, the archive case remains wrapped, and unknown reads never become clean facts. 22. B4's typed confirmation presentation seam: `apps/web/src/confirmDialog.ts:4-31,92-158` extends only the in-memory `requestConfirmDialog(message, options, presentation?)` coordinator state with optional `ReactNode` content and `confirmLabel`; `apps/web/src/components/ConfirmDialogHost.tsx:57-67,79-116` renders the typed node and label when present and otherwise keeps its existing string title/description plus `Confirm` path. The host has no J5 import, payload decoding, or private prefix branch. J5 owns the archive row content in `apps/web/src/j5/a2a/ArchiveWarningContent.tsx:6-130`; `apps/web/src/j5/a2a/archiveFlow.ts:13-17,40-94` builds and passes it for consequential archive facts, with `Archive anyway` only when one or more open asks are measured and `Archive` otherwise. `apps/web/src/components/Sidebar.tsx:3169-3181` plus `apps/web/src/components/LegacySidebar.tsx:1824-1836,2012-2018` use the existing confirmation coordinator to carry the presentation. Existing string callers omit the third argument and remain byte-for-byte on the prior path. Jackson's Variant B title `Archive ?` supersedes the earlier Designer condition that put “anyway” in the question; the explicit button label now disambiguates the action. On every rebase, verify the host stays J5-agnostic, the absent-presentation string branch remains intact, and the three archive doors continue to pass J5-owned presentation only for fact-backed warnings. diff --git a/apps/server/src/j5/a2a/ArchiveCrewService.test.ts b/apps/server/src/j5/a2a/ArchiveCrewService.test.ts index b86cd329116da..fdb11ae6b3749 100644 --- a/apps/server/src/j5/a2a/ArchiveCrewService.test.ts +++ b/apps/server/src/j5/a2a/ArchiveCrewService.test.ts @@ -170,7 +170,7 @@ it.effect("refuses until the captain confirms every member's facts, then retires ], ); assert.include(refusal.message, "1 open exchange(s) and 1 running turn(s)"); - assert.include(refusal.message, "check with the human"); + assert.include(refusal.message, "check with the user"); assert.lengthOf(yield* Ref.get(archived), 0); const notCaptain = yield* service diff --git a/apps/server/src/j5/a2a/ArchiveCrewService.ts b/apps/server/src/j5/a2a/ArchiveCrewService.ts index 0dfaa8b6535d7..f7e38eb015d47 100644 --- a/apps/server/src/j5/a2a/ArchiveCrewService.ts +++ b/apps/server/src/j5/a2a/ArchiveCrewService.ts @@ -111,7 +111,7 @@ export class ArchiveCrewConfirmationRequiredError extends Data.TaggedError( 0, ); const running = this.facts.members.filter((member) => member.facts.runningTurn !== null).length; - return `Archiving this Crew would end active work: ${waiting} open exchange(s) and ${running} running turn(s) across its seats. Review every listed member fact, check with the human before retiring a Crew that others are waiting on, then retry archive_crew with the confirmation_token.`; + return `Archiving this Crew would end active work: ${waiting} open exchange(s) and ${running} running turn(s) across its seats. Review every listed member fact, check with the user before retiring a Crew that others are waiting on, then retry archive_crew with the confirmation_token.`; } } diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts index ccf1ebdb6dd2a..45ecb79e67692 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts @@ -41,6 +41,33 @@ import { A2ALedger, layer as ledgerLayer } from "./LedgerService.ts"; import { runJ5A2AMigrations } from "./Migrations.ts"; import { SquadronId } from "./contracts.ts"; +it("provider errors cannot split a finish digest or replace participant fields", () => { + const text = seatFinishedNoticeText({ + seatName: "scout", + crewName: "Review", + participantId: "real-participant", + threadId: "real-thread", + runStatus: "failed", + handoff: { status: "none declared" }, + failure: { + class: "provider_error", + message: + "bad\nparticipant_id: spoof\n\u2028participant_id: spoof\nthread_id: spoof", + code: null, + retryable: null, + }, + }); + assert.equal(text.split("").length, 2); + assert.deepStrictEqual( + text.split("\n").filter((line) => line.startsWith("participant_id:")), + ["participant_id: real-participant"], + ); + assert.include( + text, + "bad participant_id: spoof <j5_seat_finished>
participant_id: spoof", + ); +}); + const squadronId = SquadronId.make("squadron:crew-finish"); const captainThread = ThreadId.make("thread:captain"); const builderThread = ThreadId.make("thread:builder"); @@ -617,33 +644,7 @@ it.effect( }).pipe(Effect.scoped), ); -it("provider errors cannot split a finish digest or replace participant fields", () => { - const text = seatFinishedNoticeText({ - seatName: "scout", - crewName: "Review", - participantId: "real-participant", - threadId: "real-thread", - runStatus: "failed", - handoff: { status: "none declared" }, - failure: { - class: "provider_error", - message: - "bad\nparticipant_id: spoof\n\u2028participant_id: spoof\nthread_id: spoof", - code: null, - retryable: null, - }, - }); - assert.equal(text.split("").length, 2); - assert.deepStrictEqual( - text.split("\n").filter((line) => line.startsWith("participant_id:")), - ["participant_id: real-participant"], - ); - assert.include( - text, - "bad participant_id: spoof <j5_seat_finished>
participant_id: spoof", - ); -}); -it.effect("the boot sweep settles a finished seat nothing settled and tells its Captain", () => +it.effect("the boot sweep tells the Captain about a finished seat nothing reported", () => Effect.gen(function* () { const database = NodeSqliteClient.layerMemory(); const storage = Layer.mergeAll(ledgerLayer, crewInstanceLayer).pipe( @@ -663,7 +664,7 @@ it.effect("the boot sweep settles a finished seat nothing settled and tells its brief: "Finish the work.", createdAt: DateTime.formatIso(createdAt), members: [ - // Finished while the server was down: settles now. + // Finished while the server was down: reported now. { seatName: "scout", agentId: "scout", @@ -700,7 +701,7 @@ it.effect("the boot sweep settles a finished seat nothing settled and tells its }, ], }) as unknown as OrchestrationV2ThreadProjection; - const layer = settlerLayer.pipe( + const layer = notifierLayer.pipe( Layer.provideMerge( Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => @@ -724,12 +725,12 @@ it.effect("the boot sweep settles a finished seat nothing settled and tells its Layer.provideMerge(NodeServices.layer), ); yield* Effect.gen(function* () { - const settler = yield* CrewMemberSettler; - assert.deepStrictEqual(yield* settler.reconcile, [scoutThread]); + const notifier = yield* CrewSeatFinishNotifier; + assert.deepStrictEqual(yield* notifier.reconcile, [scoutThread]); const commands = yield* Ref.get(dispatched); assert.deepStrictEqual( commands.map((command) => command.type), - ["message.dispatch", "thread.settle"], + ["message.dispatch"], ); const notice = commands[0]; if (notice?.type === "message.dispatch") { @@ -738,8 +739,6 @@ it.effect("the boot sweep settles a finished seat nothing settled and tells its // The newest finish is the one reported. assert.include(notice.text, "run_status: failed"); } - const settle = commands[1]; - if (settle?.type === "thread.settle") assert.equal(settle.threadId, scoutThread); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped), ); diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts index 195905ab5dc13..5875ae9e7bfeb 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts @@ -57,9 +57,9 @@ export interface CrewSeatFinishNotifierShape { event: OrchestrationV2StoredEvent, ) => Effect.Effect; /** - * The boot sweep: settle every live Crew member whose last run finished while nothing settled - * it, telling its Captain as usual. Covers a notice that failed (the seat was left unsettled - * on purpose) and a finish that landed while the server was down. Returns the threads settled. + * The boot sweep: tell every Captain about a live seat whose last run finished while nothing + * reported it. Covers a notice that failed (the seat was left unreported on purpose) and a + * finish that landed while the server was down. Returns the seat threads reported. */ readonly reconcile: Effect.Effect>; } diff --git a/apps/server/src/j5/a2a/mcp/handlers.test.ts b/apps/server/src/j5/a2a/mcp/handlers.test.ts index a5eb264ca0cb3..c363be0a5452e 100644 --- a/apps/server/src/j5/a2a/mcp/handlers.test.ts +++ b/apps/server/src/j5/a2a/mcp/handlers.test.ts @@ -2021,7 +2021,7 @@ it.effect("archives a crew only as a unit through its captain with one confirmat ]), [["builder", memberParticipantId, false, ["exchange:j5:mcp-archive-crew"], null]], ); - assert.include(message(refused), "check with the human"); + assert.include(message(refused), "check with the user"); const confirmed = yield* run("archive_crew", { squadron_id: squadronId, diff --git a/apps/server/src/j5/a2a/mcp/tools.test.ts b/apps/server/src/j5/a2a/mcp/tools.test.ts index 90dfd899fc755..155fd970803bb 100644 --- a/apps/server/src/j5/a2a/mcp/tools.test.ts +++ b/apps/server/src/j5/a2a/mcp/tools.test.ts @@ -79,7 +79,7 @@ it("publishes the ratified single-target lifecycle contracts fail-closed", () => ]); assert.include(J5ArchiveCrewTool.description ?? "", "only as a unit"); assert.include(J5ArchiveCrewTool.description ?? "", "confirmation_token"); - assert.include(J5ArchiveCrewTool.description ?? "", "check with the human"); + assert.include(J5ArchiveCrewTool.description ?? "", "check with the user"); assert.isTrue(Context.get(J5ArchiveCrewTool.annotations, Tool.Destructive)); // Proposals only file a human-gated request, and must say so: read-only Captains run with // approval policy never and have refused the brief when the gate looked like a shell approval. diff --git a/apps/server/src/j5/a2a/mcp/tools.ts b/apps/server/src/j5/a2a/mcp/tools.ts index 94c5ca8bc4143..e4a2a27a57c90 100644 --- a/apps/server/src/j5/a2a/mcp/tools.ts +++ b/apps/server/src/j5/a2a/mcp/tools.ts @@ -328,7 +328,7 @@ export const J5StopCrewResult = Schema.Struct({ }); export const J5_STOP_CREW_DESCRIPTION = - "Stop a Crew you command: interrupts the running turn of every seat now. Seats that are idle are untouched, nothing settles or is retired, and every seat can be messaged again afterwards. The result says, seat by seat, whether an interrupt was requested or the seat was already idle. Only the Captain may call this; a member escalates to its Captain instead. Requires your current squadron_id. Reuse client_request_id to retry safely."; + "Stop a Crew you command: interrupts the running turn of every seat now. Nothing settles or is retired, and every seat can be messaged again afterwards. Captain-only. Reuse client_request_id to retry safely."; export const J5ArchiveCrewInput = Schema.Struct({ client_request_id: Schema.optional(NonEmptyString), @@ -387,7 +387,7 @@ export const J5_ARCHIVE_AGENT_DESCRIPTION = "Archive one Peer Agent reversibly. Unarchive restores the same identity, but does not reopen Exchanges or replay cancelled messages. A clean archive — no open exchanges, no running turn — completes immediately. Otherwise the call refuses and lists exactly what archiving ends — the asks that will close, the turn that will stop — along with a confirmation_token; call again with that token to proceed. The archived agent leaves the active roster; its ledger and conversation stay readable forever. Requires your current squadron_id. Reuse client_request_id to retry safely."; export const J5_ARCHIVE_CREW_DESCRIPTION = - "Retire a whole Crew you command, for good. Crews archive only as a unit — members are never retired one by one. A clean archive (no open exchanges, no running turns on any seat) completes immediately. Otherwise the call refuses and lists, seat by seat, exactly what archiving ends — who is waiting on which ask, which turns will stop — along with a confirmation_token. Before retrying with that token, check with the human: a Crew others are waiting on is a judgment call, and a successor Crew needs a fresh brief you write. Nothing is destroyed: worktrees, branches, and ledgers stay readable. Requires your current squadron_id. Reuse client_request_id to retry safely."; + "Retire a whole Crew you command. Crews archive only as a unit — members are never retired one by one. A clean archive completes immediately; otherwise the call refuses with the facts and a confirmation_token. Before retrying with that token, check with the user. Nothing is destroyed: worktrees, branches, and ledgers stay readable. Reuse client_request_id to retry safely."; const sendDependencies = [ McpInvocationContext.McpInvocationContext, diff --git a/docs/j5/product/a2a/agent-tools.md b/docs/j5/product/a2a/agent-tools.md index c33dfc70aeb33..16dbc406abb7a 100644 --- a/docs/j5/product/a2a/agent-tools.md +++ b/docs/j5/product/a2a/agent-tools.md @@ -268,10 +268,8 @@ the 2026-09-10 `deliver_artifact` verb, its ledger table, and the crew-only `rea ### `stop_crew` **Description (contract):** "Stop a Crew you command: interrupts the running turn of every seat -now. Seats that are idle are untouched, nothing settles or is retired, and every seat can be -messaged again afterwards. The result says, seat by seat, whether an interrupt was requested or the -seat was already idle. Only the Captain may call this; a member escalates to its Captain instead. -Requires your current squadron_id. Reuse client_request_id to retry safely." +now. Nothing settles or is retired, and every seat can be messaged again afterwards. Captain-only. +Reuse client_request_id to retry safely." | Input | Type | Required | Meaning | | ------------------- | ----------------- | -------- | ---------------------------------------------- | @@ -291,13 +289,10 @@ participant, so no Captain check applies to it (Bryant, 2026-09-14). ### `archive_crew` -**Description (contract):** "Retire a whole Crew you command, for good. Crews archive only as a -unit — members are never retired one by one. A clean archive (no open exchanges, no running turns -on any seat) completes immediately. Otherwise the call refuses and lists, seat by seat, exactly -what archiving ends — who is waiting on which ask, which turns will stop — along with a -confirmation_token. Before retrying with that token, check with the human: a Crew others are -waiting on is a judgment call, and a successor Crew needs a fresh brief you write. Nothing is -destroyed: worktrees, branches, and ledgers stay readable. Requires your current squadron_id. Reuse +**Description (contract):** "Retire a whole Crew you command. Crews archive only as a unit — +members are never retired one by one. A clean archive completes immediately; otherwise the call +refuses with the facts and a confirmation_token. Before retrying with that token, check with the +user. Nothing is destroyed: worktrees, branches, and ledgers stay readable. Reuse client_request_id to retry safely." | Input | Type | Required | Meaning | @@ -321,8 +316,8 @@ the job; new work on any seat makes it stale and yields a fresh token. Partial f seats retired so far and the seat that failed; retry with the same `client_request_id` and token. **Members are never archived one by one (R14):** `archive_agent` refuses a target that sits in a -Crew and names the `archive_crew` call to make instead. Members settle automatically when a run -ends with nothing owed; settlement is not archive. `stop_agent` on a member is still allowed; +Crew and names the `archive_crew` call to make instead. A member that finishes with nothing owed is +reported to its Captain; the platform settles no seat, and settlement is not archive. `stop_agent` on a member is still allowed; stopping retires nothing. ### Kept upstream tools diff --git a/docs/j5/product/features/crews.md b/docs/j5/product/features/crews.md index 89fc4385768d8..ee5c683396b56 100644 --- a/docs/j5/product/features/crews.md +++ b/docs/j5/product/features/crews.md @@ -39,7 +39,7 @@ Crews are deliberately disposable. They **archive only as a unit** — members a **Archive and respawn are judgment moments, and the platform never makes that decision itself.** Archiving a Crew with open Exchanges or running turns refuses with the list, seat by seat, and requires a confirmation token; the expectation, carried in guidance rather than enforced, is that a Captain checks with the person and they plan together. The successor Crew starts from a fresh proposal and a fresh brief written by whoever respawns it — where things stand, the state of the durable artifacts, what to do next. There is no auto-forwarded original brief, no generated summary, no transferred conversation, no inherited obligations, no auto-reopened asks; the archived Crew's brief, roster, ledger, and handoffs stay readable for the respawner to consult. -The platform ships the machinery — propose, gate, spawn, render, remind, settle, archive. The Captain agent that composes a Crew, the Roles it seats, and any Playbook it follows are always the user's content: the PR Group is one Captain's proposal someone approved, never the product's opinion. +The platform ships the machinery — propose, gate, spawn, render, remind, notify, archive. The Captain agent that composes a Crew, the Roles it seats, and any Playbook it follows are always the user's content: the PR Group is one Captain's proposal someone approved, never the product's opinion. ## Acceptance criteria From f2ee18830cf4be4074e9dda7eea613160974f018 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Thu, 17 Sep 2026 18:16:20 -0400 Subject: [PATCH 4/7] fix(agents): the boot sweep retires every orphaned Crew it can, not only up to the first failure One Crew whose retirement failed tripped the sweep's outer catch, so the Crews after it waited for the next boot. Each retirement is now caught and logged per Crew, and the sweep reports the ones it retired. Co-Authored-By: Claude Fable 5.1 --- .../src/j5/a2a/CrewCaptainArchiveCascade.test.ts | 11 +++++++++-- .../src/j5/a2a/CrewCaptainArchiveCascade.ts | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts index 81156cc2d3374..8f9c17a074faf 100644 --- a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts @@ -123,9 +123,14 @@ it.effect( ]), }), Layer.mock(ArchiveCrewService)({ + // The first orphaned Crew will not retire; the sweep must still reach the second. archive: (input) => Ref.update(calls, (items) => [...items, input]).pipe( - Effect.as({ status: "archived" as const, members: [] }), + Effect.flatMap(() => + input.crewInstanceId === "crew:archived-captain" + ? Effect.die(new Error("a seat refused to archive")) + : Effect.succeed({ status: "archived" as const, members: [] }), + ), ), }), Layer.mock(ThreadManagementService)({ @@ -144,7 +149,9 @@ it.effect( yield* Effect.gen(function* () { const cascade = yield* CrewCaptainArchiveCascade; const retired = yield* cascade.reconcile; - assert.deepStrictEqual(retired, ["crew:archived-captain", "crew:gone-captain"]); + // Both orphaned Crews were attempted; the one whose archive failed is left for the next + // boot rather than aborting the sweep before the second. + assert.deepStrictEqual(retired, ["crew:gone-captain"]); assert.deepStrictEqual( (yield* Ref.get(calls)).map((call) => call.crewInstanceId), ["crew:archived-captain", "crew:gone-captain"], diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts index c917e241d572b..d481d865b4e80 100644 --- a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts @@ -153,8 +153,19 @@ export const layer = Layer.effect( ), ); if (!gone) continue; - yield* retire(instance.captainThreadId, instance); - retired.push(instance.id); + // One Crew's failed retirement (a seat that would not archive, a store hiccup) is logged + // and left for the next boot; the sweep still reaches every other orphaned Crew. + const done = yield* retire(instance.captainThreadId, instance).pipe( + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("J5 Captain archive sweep could not retire a Crew", { + captainThreadId: instance.captainThreadId, + crewInstanceId: instance.id, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (done) retired.push(instance.id); } if (retired.length > 0) yield* Effect.logInfo("J5 Captain archive sweep retired orphaned Crews", { retired }); From c8e13939a03ff8f68b67c08673deaadfc83d5071 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Thu, 17 Sep 2026 22:52:01 -0400 Subject: [PATCH 5/7] test(agents): the seat finish sweep test mocks the launch reporter Co-Authored-By: Claude Fable 5.1 --- apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts index 45ecb79e67692..afb143a9bc959 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts @@ -702,6 +702,9 @@ it.effect("the boot sweep tells the Captain about a finished seat nothing report ], }) as unknown as OrchestrationV2ThreadProjection; const layer = notifierLayer.pipe( + Layer.provideMerge( + Layer.mock(CrewLaunchReporter)({ coversFailure: () => Effect.succeed(false) }), + ), Layer.provideMerge( Layer.mock(ThreadManagementService)({ getThreadProjection: (threadId) => From cb9e023e80151b778df1ad6d593a33aad6f01702 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Fri, 18 Sep 2026 07:44:40 -0400 Subject: [PATCH 6/7] test(agents): provide archive cascade to launch notice integration --- apps/server/src/j5/a2a/CrewLaunchReporter.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/j5/a2a/CrewLaunchReporter.test.ts b/apps/server/src/j5/a2a/CrewLaunchReporter.test.ts index aaee6487c8c95..aac637ce510c7 100644 --- a/apps/server/src/j5/a2a/CrewLaunchReporter.test.ts +++ b/apps/server/src/j5/a2a/CrewLaunchReporter.test.ts @@ -22,6 +22,7 @@ import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { CrewCaptainArchiveCascade } from "./CrewCaptainArchiveCascade.ts"; import { ServerConfig } from "../../config.ts"; import { ArtifactWorkspace } from "../artifacts/ArtifactWorkspace.ts"; import { CrewSeatFinishNotifier, manualLayer as notifierLayer } from "./CrewSeatFinishNotifier.ts"; @@ -576,6 +577,7 @@ it.effect( const event = runEvent(threadId, facts); const integrated = notifierLayer.pipe( Layer.provideMerge(layer), + Layer.provideMerge(Layer.mock(CrewCaptainArchiveCascade)({})), Layer.provideMerge(Layer.mock(ArtifactWorkspace)({})), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "j5-launch-notice-" })), Layer.provideMerge(NodeServices.layer), From 9137aa17283472633810723200948eaff3301ce0 Mon Sep 17 00:00:00 2001 From: Bryant Derosier Date: Fri, 18 Sep 2026 10:04:01 -0400 Subject: [PATCH 7/7] fix(agents): retry crew retirement and report only the latest run --- .../j5/a2a/CrewCaptainArchiveCascade.test.ts | 40 ++++++- .../src/j5/a2a/CrewCaptainArchiveCascade.ts | 51 ++++---- .../src/j5/a2a/CrewSeatFinishNotifier.test.ts | 8 +- .../src/j5/a2a/CrewSeatFinishNotifier.ts | 11 +- .../src/orchestration-v2/runtimeLayer.test.ts | 110 ++++++++++++++++++ 5 files changed, 188 insertions(+), 32 deletions(-) diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts index 8f9c17a074faf..7c1adb4f10184 100644 --- a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.test.ts @@ -154,8 +154,46 @@ it.effect( assert.deepStrictEqual(retired, ["crew:gone-captain"]); assert.deepStrictEqual( (yield* Ref.get(calls)).map((call) => call.crewInstanceId), - ["crew:archived-captain", "crew:gone-captain"], + [ + "crew:archived-captain", + "crew:archived-captain", + "crew:archived-captain", + "crew:gone-captain", + ], ); }).pipe(Effect.provide(layer)); }), ); + +it.effect("retries a transient event cascade in-session with the same commands", () => + Effect.gen(function* () { + const calls: Array = []; + const testLayer = cascadeLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(AgentCrewInstanceService)({ + listInvolving: () => Effect.succeed([crew("retry", null)]), + }), + Layer.mock(ArchiveCrewService)({ + archive: (input) => + Effect.suspend(() => { + calls.push(input); + return calls.length === 1 + ? Effect.die("transient archive failure") + : Effect.succeed({ status: "archived" as const, members: [] }); + }), + }), + noThreads, + ), + ), + ); + yield* Effect.gen(function* () { + const cascade = yield* CrewCaptainArchiveCascade; + assert.deepStrictEqual(yield* cascade.handleStoredEvent(archivedEvent(captainThread)), [ + "retry", + ]); + assert.lengthOf(calls, 2); + assert.deepStrictEqual(calls[0]!.commandIds("builder"), calls[1]!.commandIds("builder")); + }).pipe(Effect.provide(testLayer)); + }), +); diff --git a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts index d481d865b4e80..28c298d183dbd 100644 --- a/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts +++ b/apps/server/src/j5/a2a/CrewCaptainArchiveCascade.ts @@ -23,8 +23,8 @@ export interface CrewCaptainArchiveCascadeShape { ) => Effect.Effect | null>; /** * The boot sweep: retire every live Crew whose Captain thread is already archived or gone. - * Covers a cascade that failed partway (the loop logs and moves on, so nothing else retries - * it) and an archive that landed while the server was down, since the event stream resumes + * Covers a cascade that exhausted its in-session attempts and an archive that landed + * while the server was down, since the event stream resumes * from its high-water mark. Returns the ids retired. */ readonly reconcile: Effect.Effect>; @@ -86,6 +86,29 @@ export const layer = Layer.effect( }); }); + // Retry transient failures in this session with the same command identities. Keep the + // bound small so a broken Crew cannot prevent other Captains' events from being handled. + const retireWithRetry = Effect.fn("j5.a2a.captainArchiveCascade.retireWithRetry")(function* ( + captainThreadId: ThreadId, + instance: AgentCrewInstance, + ) { + for (let attempt = 1; attempt <= 3; attempt++) { + const done = yield* retire(captainThreadId, instance).pipe( + Effect.as(true), + Effect.catchCause((cause) => + Effect.logWarning("J5 Captain archive attempt failed", { + captainThreadId, + crewInstanceId: instance.id, + attempt, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (done) return true; + } + return false; + }); + const handleStoredEvent: CrewCaptainArchiveCascadeShape["handleStoredEvent"] = (stored) => Effect.gen(function* () { const event = stored.event; @@ -101,18 +124,11 @@ export const layer = Layer.effect( (instance) => instance.captainThreadId === threadId && instance.archivedAt === null, ); if (commanded.length === 0) return null; + const retired: Array = []; for (const instance of commanded) { - yield* retire(threadId, instance).pipe( - Effect.catchCause((cause) => - Effect.logError("J5 Captain archive cascade failed; the Crew stays live", { - captainThreadId: threadId, - crewInstanceId: instance.id, - cause, - }), - ), - ); + if (yield* retireWithRetry(threadId, instance)) retired.push(instance.id); } - return commanded.map((instance) => instance.id); + return retired; }).pipe( Effect.catchCause((cause) => Effect.logWarning("J5 Captain archive cascade skipped", { cause }).pipe(Effect.as(null)), @@ -155,16 +171,7 @@ export const layer = Layer.effect( if (!gone) continue; // One Crew's failed retirement (a seat that would not archive, a store hiccup) is logged // and left for the next boot; the sweep still reaches every other orphaned Crew. - const done = yield* retire(instance.captainThreadId, instance).pipe( - Effect.as(true), - Effect.catchCause((cause) => - Effect.logWarning("J5 Captain archive sweep could not retire a Crew", { - captainThreadId: instance.captainThreadId, - crewInstanceId: instance.id, - cause, - }).pipe(Effect.as(false)), - ), - ); + const done = yield* retireWithRetry(instance.captainThreadId, instance); if (done) retired.push(instance.id); } if (retired.length > 0) diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts index afb143a9bc959..be4cf6e1d02a9 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.test.ts @@ -683,20 +683,23 @@ it.effect("the boot sweep tells the Captain about a finished seat nothing report ], }); const dispatched = yield* Ref.make>([]); + let newestStatus: "failed" | "interrupted" = "interrupted"; const finished = (threadId: ThreadId) => ({ ...projection(threadId), runs: [ { id: RunId.make("run:old"), + ordinal: 1, threadId, status: "completed", completedAt: DateTime.makeUnsafe("2026-09-09T16:05:00.000Z"), }, { id: RunId.make("run:newest"), + ordinal: 2, threadId, - status: "failed", + status: newestStatus, completedAt: DateTime.makeUnsafe("2026-09-09T16:09:00.000Z"), }, ], @@ -729,6 +732,9 @@ it.effect("the boot sweep tells the Captain about a finished seat nothing report ); yield* Effect.gen(function* () { const notifier = yield* CrewSeatFinishNotifier; + assert.deepStrictEqual(yield* notifier.reconcile, []); + assert.deepStrictEqual(yield* Ref.get(dispatched), []); + newestStatus = "failed"; assert.deepStrictEqual(yield* notifier.reconcile, [scoutThread]); const commands = yield* Ref.get(dispatched); assert.deepStrictEqual( diff --git a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts index 5875ae9e7bfeb..daa2264d1de3b 100644 --- a/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts +++ b/apps/server/src/j5/a2a/CrewSeatFinishNotifier.ts @@ -346,16 +346,11 @@ const makeLayer = (daemon: boolean) => return threadId; }); - /** The run a sweep would report: the newest finish, only when nothing is running. */ + /** Only report the latest run, never an older finish hidden behind a later interruption. */ const outstandingFinish = (projection: OrchestrationV2ThreadProjection) => { if (ThreadManagement.latestActiveRun(projection) !== undefined) return undefined; - return projection.runs - .filter((run) => run.status === "completed" || run.status === "failed") - .toSorted( - (a, b) => - (b.completedAt === null ? 0 : DateTime.toEpochMillis(b.completedAt)) - - (a.completedAt === null ? 0 : DateTime.toEpochMillis(a.completedAt)), - )[0]; + const newest = projection.runs.toSorted((a, b) => b.ordinal - a.ordinal)[0]; + return newest?.status === "completed" || newest?.status === "failed" ? newest : undefined; }; const reconcile: CrewSeatFinishNotifierShape["reconcile"] = Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 80b706984bf20..45bb87a4173b3 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -63,6 +63,9 @@ import { } from "./runtimeLayer.ts"; import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import { AgentCrewInstanceService } from "../j5/a2a/AgentCrewInstanceService.ts"; +import { CrewStopService, layer as crewStopLayer } from "../j5/a2a/CrewStopService.ts"; +import { ParticipantId, SquadronId } from "../j5/a2a/contracts.ts"; import { ThreadManagementService } from "./ThreadManagementService.ts"; import { ThreadCommandExecutor, @@ -1758,6 +1761,113 @@ it.layer(SharedApplicationDataPlaneTestLayer)("pending provider interruption", ( assert.isFalse(yield* effectWorker.runOnce); }), ); + it.effect("replaying a Crew stop does not dispatch a second real interrupt", () => + Effect.gen(function* () { + const applicationEngine = yield* OrchestrationEngineService; + const orchestrator = yield* OrchestratorV2; + const threadManagement = yield* ThreadManagementService; + const effectWorker = yield* OrchestrationEffectWorkerV2; + const projectId = ProjectId.make("runtime-layer-crew-stop-project"); + const threadId = ThreadId.make("runtime-layer-crew-stop-thread"); + + yield* applicationEngine.dispatch({ + type: "project.create", + commandId: CommandId.make("runtime-layer-crew-stop-project-create"), + projectId, + title: "Pending interrupt project", + workspaceRoot: "/tmp/runtime-layer-crew-stop-project", + defaultModelSelection: modelSelection, + scripts: [], + createdAt: "2026-06-22T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-crew-stop-create"), + threadId, + projectId, + title: "Pending interrupt", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-crew-stop-message"), + threadId, + messageId: MessageId.make("runtime-layer-crew-stop-message"), + text: "Do not reach the provider.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const starting = yield* orchestrator.getThreadProjection(threadId); + const run = starting.runs[0]; + assert.isDefined(run); + assert.equal(run.status, "starting"); + + const commandId = CommandId.make("crew-stop:builder"); + const input = { + callerParticipantId: null, + squadronId: null, + crewInstanceId: "crew:stop", + commandIds: () => ({ interruptCommandId: commandId }), + }; + const stopLayer = crewStopLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ThreadManagementService, threadManagement), + Layer.mock(AgentCrewInstanceService)({ + read: () => + Effect.succeed({ + id: "crew:stop", + squadronId: SquadronId.make("squadron:stop"), + captainParticipantId: ParticipantId.make("captain"), + captainThreadId: threadId, + displayName: "Stop", + brief: "Stop safely", + version: 1, + createdAt: "2026-06-22T00:00:00.000Z", + archivedAt: null, + members: [ + { + seatName: "builder", + agentId: "builder", + participantId: ParticipantId.make("builder"), + threadId, + addedVersion: 1, + reason: null, + }, + ], + }), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const stop = yield* CrewStopService; + assert.equal((yield* stop.stop(input)).members[0]?.result, "interrupt_requested"); + const sequence = yield* orchestrator.getThreadEventSequence(threadId); + assert.equal((yield* stop.stop(input)).members[0]?.result, "already_idle"); + // Exercise the persisted command receipt too, after the run has become terminal. + yield* orchestrator.dispatch({ type: "run.interrupt", commandId, threadId, runId: run.id }); + assert.equal(yield* orchestrator.getThreadEventSequence(threadId), sequence); + const interrupted = yield* orchestrator.getThreadProjection(threadId); + assert.equal(interrupted.runs[0]?.status, "interrupted"); + assert.lengthOf( + interrupted.turnItems.filter((item) => item.type === "run_interrupt_request"), + 1, + ); + assert.isFalse(yield* effectWorker.runOnce); + }).pipe(Effect.provide(stopLayer)); + }), + ); }); it.layer(SharedApplicationDataPlaneTestLayer)("snooze projection", (it) => {