Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/server/integration/transferBudgetV2.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const layerManagement = Layer.unwrap(
projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie),
getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie),
getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie),
listActiveSubagentThreadIds: () =>
projections.listActiveSubagentThreadIds().pipe(Effect.orDie),
readShellSnapshot: (options) =>
projections.readShellSnapshot(options).pipe(Effect.map(Effect.orDie), Effect.orDie),
streamStoredEventsFrom: (input) =>
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ export interface OrchestratorV2Shape {
readonly getShellSnapshot: (
options?: ShellSnapshotOptions,
) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, OrchestratorV2Error>;
readonly listActiveSubagentThreadIds: () => Effect.Effect<
ReadonlyArray<ThreadId>,
OrchestratorV2Error
>;
readonly readShellSnapshot: (
options?: ShellSnapshotOptions,
) => Effect.Effect<
Expand Down Expand Up @@ -12237,6 +12241,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))),
getShellSnapshot: (options) =>
projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)),
listActiveSubagentThreadIds: () =>
projectionStore.listActiveSubagentThreadIds().pipe(Effect.mapError(shellProjectionError)),
readShellSnapshot: (options) =>
projectionStore
.readShellSnapshot(options)
Expand Down Expand Up @@ -12382,6 +12388,13 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
cause: "Orchestration V2 live runtime is not configured.",
}),
),
listActiveSubagentThreadIds: () =>
Effect.fail(
new OrchestratorProjectionError({
threadId: ThreadId.make("thread:shell"),
cause: "Orchestration V2 live runtime is not configured.",
}),
),
readShellSnapshot: () =>
Effect.fail(
new OrchestratorProjectionError({
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,36 @@ const assertProjectScopedShells = Effect.fn("assertProjectScopedShells")(functio
return { projectId, activeThreadId, forkThreadId, otherThreadId };
});

it.effect("lists only active subagent IDs without loading shell data", () =>
Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
const sql = yield* SqlClient.SqlClient;
const createdAt = "2026-01-05T00:00:00.000Z";
for (const [id, relationship, archivedAt, deletedAt] of [
["parent", null, null, null],
["active-subagent", "subagent", null, null],
["archived-subagent", "subagent", createdAt, null],
["deleted-subagent", "subagent", null, createdAt],
] as const) {
yield* sql`
INSERT INTO orchestration_v2_projection_threads (
thread_id, project_id, title, default_provider, runtime_mode, interaction_mode,
created_at, updated_at, archived_at, deleted_at, payload_json
) VALUES (
${id}, 'project:test', ${id}, 'codex', 'full-access', 'default',
${createdAt}, ${createdAt}, ${archivedAt}, ${deletedAt},
${JSON.stringify({ lineage: { relationshipToParent: relationship }, archivedAt })}
)
`;
}

assert.sameMembers(
[...(yield* store.listActiveSubagentThreadIds())],
[ThreadId.make("active-subagent")],
);
}).pipe(Effect.provide(layerTest)),
);

it.layer(layerTest)("ProjectionStoreV2", (it) => {
// regression: #21969 keep corrupt plan JSON in the typed read error
it.effect("returns a typed read error for malformed stored plan JSON", () =>
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,10 @@ export interface ProjectionStoreV2Shape {
readonly getShellSnapshot: (
options?: ShellSnapshotOptions,
) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, ProjectionStoreV2Error>;
readonly listActiveSubagentThreadIds: () => Effect.Effect<
ReadonlyArray<ThreadId>,
ProjectionStoreV2Error
>;
/**
* Callers that combine the shell with other reads run this inside their own
* transaction and the returned decode after it commits, so the shared
Expand Down Expand Up @@ -6430,6 +6434,20 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
.withTransaction(readShellSnapshotRows(options))
.pipe(Effect.mapError(shellSnapshotReadError), Effect.flatMap(decodeShellSnapshot));

const listActiveSubagentThreadIds = Effect.fn("ProjectionStore.listActiveSubagentThreadIds")(
function* () {
const rows = yield* sql<{ readonly thread_id: string }>`
SELECT thread_id
FROM orchestration_v2_projection_threads
WHERE deleted_at IS NULL
AND json_extract(payload_json, '$.archivedAt') IS NULL
AND json_extract(payload_json, '$.lineage.relationshipToParent') = 'subagent'
`;
return rows.map((row) => ThreadId.make(row.thread_id));
},
Effect.mapError(shellSnapshotReadError),
);

const getThreadShell: ProjectionStoreV2Shape["getThreadShell"] = (threadId) =>
sql
.withTransaction(
Expand Down Expand Up @@ -6522,6 +6540,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
return {
apply,
getShellSnapshot: readOnReader(getShellSnapshot),
listActiveSubagentThreadIds: readOnReader(listActiveSubagentThreadIds),
readShellSnapshot: readOnReader(readShellSnapshot),
getThreadShell: readOnReader(getThreadShell),
getThread: readOnReader(getThread),
Expand Down Expand Up @@ -6630,6 +6649,19 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
archivedThreads: visible.filter((thread) => thread.archivedAt !== null),
};
}),
listActiveSubagentThreadIds: Effect.fn("ProjectionStore.listActiveSubagentThreadIds")(
function* () {
const existing = (yield* Ref.get(replayState)).projections;
return [...existing.values()]
.filter(
({ thread }) =>
thread.deletedAt === null &&
thread.archivedAt === null &&
thread.lineage.relationshipToParent === "subagent",
)
.map(({ thread }) => thread.id);
},
),
readShellSnapshot: (options) =>
service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)),
getThreadShell: Effect.fn("ProjectionStore.getThreadShell")(function* (threadId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ it.effect(
apply: () => Effect.void,
getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"),
getShellSnapshot: () => Effect.die("unused getShellSnapshot"),
listActiveSubagentThreadIds: () => Effect.die("unused listActiveSubagentThreadIds"),
readShellSnapshot: () => Effect.die("unused readShellSnapshot"),
getThreadShell: () => Effect.die("unused getThreadShell"),
getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ThreadManagementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ export interface ThreadManagementServiceShape {
readonly getShellSnapshot: (options?: {
readonly location?: "active" | "archive";
}) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, Orchestrator.OrchestratorV2Error>;
readonly listActiveSubagentThreadIds: Orchestrator.OrchestratorV2["Service"]["listActiveSubagentThreadIds"];
readonly readShellSnapshot: (options?: {
readonly location?: "active" | "archive";
}) => Effect.Effect<
Expand Down Expand Up @@ -1173,6 +1174,7 @@ const make = Effect.gen(function* () {
getProjectThreadRecords,
getProjectThread,
getShellSnapshot: orchestrator.getShellSnapshot,
listActiveSubagentThreadIds: orchestrator.listActiveSubagentThreadIds,
readShellSnapshot: orchestrator.readShellSnapshot,
getThreadShell: orchestrator.getThreadShell,
listProjectThreads,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
catchUp.shellSnapshotReads += 1;
return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] };
}),
listActiveSubagentThreadIds: unused,
readShellSnapshot: unused,
ensureLegacyTranscript: unused,
dispatch: unused,
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ it.live.each(["thread", "shell"] as const)(
const items = yield* subscription.pipe(
Effect.provideService(ThreadManagement.ThreadManagementService, {
ensureLegacyTranscript: () => Effect.void,
getShellSnapshot: () => Effect.succeed({ threads: [] }),
getShellSnapshot: () => Effect.die("shell resume must not load a full snapshot"),
listActiveSubagentThreadIds: () => Effect.succeed([]),
streamStoredEventsFrom: (
input: Parameters<
ThreadManagement.ThreadManagementService["Service"]["streamStoredEventsFrom"]
Expand Down
7 changes: 1 addition & 6 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1172,12 +1172,7 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc
});
}

const currentShell = yield* threadManagement.getShellSnapshot({ location: "active" });
subagentThreadIds = new Set(
currentShell.threads
.filter((thread) => thread.lineage.relationshipToParent === "subagent")
.map((thread) => thread.id),
);
subagentThreadIds = new Set(yield* threadManagement.listActiveSubagentThreadIds());

const loaded = yield* loadProjectMetadataSnapshot(highWater);
const replay = toShellStream(
Expand Down
Loading