Skip to content
7 changes: 4 additions & 3 deletions FORK.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewInstanceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/j5/a2a/AgentCrewInstanceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export interface AgentCrewInstanceServiceShape {
readonly listForSquadron: (
squadronId: SquadronId,
) => Effect.Effect<ReadonlyArray<AgentCrewInstance>, SqlError>;
/** Every Crew not yet retired, across Squadrons; the boot reconciliation walks this. */
readonly listLive: () => Effect.Effect<ReadonlyArray<AgentCrewInstance>, SqlError>;
/** Every Crew that any of these threads sits in or that any of these participants commands. */
readonly listInvolving: (input: {
readonly threadIds: ReadonlyArray<ThreadId>;
Expand Down Expand Up @@ -325,6 +327,14 @@ export const layer: Layer.Layer<AgentCrewInstanceService, never, SqlClient.SqlCl
return yield* readMany(rows);
});

const listLive = Effect.fn("j5.a2a.agentCrewInstances.listLive")(function* () {
const rows = yield* sql<InstanceRow>`
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<ThreadId>;
readonly participantIds: ReadonlyArray<ParticipantId>;
Expand Down Expand Up @@ -384,6 +394,7 @@ export const layer: Layer.Layer<AgentCrewInstanceService, never, SqlClient.SqlCl
removeMembers,
listForCaptain,
listForSquadron,
listLive,
listInvolving,
markArchived,
});
Expand Down
33 changes: 31 additions & 2 deletions apps/server/src/j5/a2a/ArchiveAgentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ArchiveAgentResult, ArchiveAgentError>;
/** Consequence facts without side effects, so a unit archive can warn about every member at once. */
readonly readFacts: (
target: ArchiveAgentTarget,
) => Effect.Effect<ArchiveAgentTargetFacts, ArchiveAgentError>;
}

export class ArchiveAgentService extends Context.Service<
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 });
}),
);
Loading
Loading