From 55f7e0eef840f25d9296335fc5b479d9ebddbefe Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:24:17 -0400 Subject: [PATCH] feat(agents): mark agents the provider runs in the background The Agents tab could not tell a background agent from one the turn was waiting on, even though the Claude SDK reports it on every agent task. The roster gains an optional isBackgrounded flag (new nullable column on projection_thread_subagents, migration 049; NULL/0/1 mapped at the row boundary the same way message streaming flags are). The Claude adapter sends it with the depth at task start and again when a foreground agent is moved to the background. Live rows show "background" in the meta line after the model and effort; finished rows do not, since how an agent was launched no longer matters on a receipt. Codex never sets it. Also fixes a latent projection bug the move exposed: a metadata update that carried no depth information reset the agent's depth to 0. --- .../Layers/ProjectionSnapshotQuery.ts | 12 ++++--- .../orchestration/subagentProjection.test.ts | 36 +++++++++++++++++++ .../src/orchestration/subagentProjection.ts | 19 +++++++++- .../Layers/ProjectionThreadSubagents.ts | 27 ++++++++++++-- apps/server/src/persistence/Migrations.ts | 2 ++ ...9_ProjectionThreadSubagentsBackgrounded.ts | 11 ++++++ .../src/provider/Layers/ClaudeAdapter.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 14 +++++++- .../components/chat/agentsPanel.logic.test.ts | 27 ++++++++++++++ .../src/components/chat/agentsPanel.logic.ts | 3 ++ apps/web/src/session-logic.ts | 4 +++ packages/contracts/src/orchestration.ts | 4 +++ packages/contracts/src/providerRuntime.ts | 2 ++ 13 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4a51d2015..aeadf26b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -53,7 +53,10 @@ import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionT import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; -import { ProjectionThreadSubagent } from "../../persistence/Services/ProjectionThreadSubagents.ts"; +import { + ProjectionThreadSubagentDbRowSchema, + toProjectionThreadSubagent, +} from "../../persistence/Layers/ProjectionThreadSubagents.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; @@ -105,7 +108,6 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession; -const ProjectionThreadSubagentDbRowSchema = ProjectionThreadSubagent; const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( Struct.assign({ files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), @@ -337,7 +339,7 @@ function mapThreadActivityRow( function mapThreadSubagentRow( row: Schema.Schema.Type, ): OrchestrationSubagent { - const { threadId: _threadId, ...subagent } = row; + const { threadId: _threadId, ...subagent } = toProjectionThreadSubagent(row); return subagent; } @@ -754,7 +756,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { parent_agent_thread_id AS "parentAgentThreadId", spawn_call_id AS "spawnCallId", transcript_agent_id AS "transcriptAgentId", turn_id AS "turnId", agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", - tree_depth AS "treeDepth", nickname, role, objective, status, + tree_depth AS "treeDepth", is_backgrounded AS "isBackgrounded", + nickname, role, objective, status, requested_model AS "requestedModel", resolved_model AS "resolvedModel", reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", @@ -1298,6 +1301,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", tree_depth AS "treeDepth", + is_backgrounded AS "isBackgrounded", nickname, role, objective, diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts index faf508f97..598ce2710 100644 --- a/apps/server/src/orchestration/subagentProjection.test.ts +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -82,6 +82,42 @@ describe("projectSubagentActivity", () => { expect(agent?.status).toBe("running"); }); + it("records whether the provider runs the agent in the background, and a later move", () => { + const spawned = projectSubagentActivity( + [], + claudeSpawnActivity({ + id: "a1", + kind: "tool.started", + status: "inProgress", + turnId: TURN_ID, + }), + ); + // Unknown until the provider says: the flag stays absent, not false. + expect(spawned[0]?.isBackgrounded).toBeUndefined(); + + const foreground = projectSubagentActivity( + spawned, + activity({ + id: "m1", + kind: "subagent.metadata", + payload: { callId: SPAWN_TOOL_USE_ID, treeDepth: 1, isBackgrounded: false }, + }), + ); + expect(foreground[0]?.isBackgrounded).toBe(false); + expect(foreground[0]?.treeDepth).toBe(1); + + const moved = projectSubagentActivity( + foreground, + activity({ + id: "m2", + kind: "subagent.metadata", + payload: { callId: SPAWN_TOOL_USE_ID, isBackgrounded: true }, + }), + ); + expect(moved[0]?.isBackgrounded).toBe(true); + expect(moved[0]?.treeDepth).toBe(1); + }); + it("keeps the spawn turn when later live-text updates arrive without a turn", () => { const spawned = projectSubagentActivity( [], diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index bd5f324ba..76e96a6ff 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -73,6 +73,14 @@ function parentPath(agentPath: string | null): string | null { return segments.length > 2 ? `/${segments.slice(0, -1).join("/")}` : null; } +/** The optional flag as an object spread, so an unknown value stays absent + * instead of becoming `undefined` under exact optional property types. */ +function backgroundedField( + value: boolean | undefined, +): { readonly isBackgrounded: boolean } | Record { + return value === undefined ? {} : { isBackgrounded: value }; +} + function treeDepth(agentPath: string | null): number { return Math.max(0, (agentPath?.split("/").filter(Boolean).length ?? 1) - 2); } @@ -87,6 +95,7 @@ interface SubagentPatch { readonly agentPath?: string | null; readonly parentAgentPath?: string | null; readonly treeDepth?: number; + readonly isBackgrounded?: boolean; readonly nickname?: string | null; readonly role?: string | null; readonly objective?: string | null; @@ -115,6 +124,7 @@ function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | n ); const model = text(payload.model); const status = explicitStatus(payload.status); + const depth = integer(payload.treeDepth) ?? (agentPath === null ? null : treeDepth(agentPath)); return { id, agentThreadId, @@ -124,7 +134,12 @@ function metadataPatch(activity: OrchestrationThreadActivity): SubagentPatch | n turnId: (text(payload.turnId) as TurnId | null) ?? activity.turnId, agentPath, parentAgentPath: text(payload.parentAgentPath) ?? parentPath(agentPath), - treeDepth: integer(payload.treeDepth) ?? treeDepth(agentPath), + // Only a payload that knows the depth may set it: a metadata update about + // something else (a move to the background) must not reset it to 0. + ...(depth === null ? {} : { treeDepth: depth }), + ...backgroundedField( + typeof payload.isBackgrounded === "boolean" ? payload.isBackgrounded : undefined, + ), nickname: text(payload.nickname) ?? text(payload.agentNickname), role: text(payload.role) ?? text(payload.agentRole) ?? text(payload.taskName), objective: text(payload.objective) ?? text(payload.prompt), @@ -241,6 +256,7 @@ function mergeSubagent( agentPath: mergeValue(patch.agentPath, current?.agentPath ?? null), parentAgentPath: mergeValue(patch.parentAgentPath, current?.parentAgentPath ?? null), treeDepth: patch.treeDepth ?? current?.treeDepth ?? 0, + ...backgroundedField(patch.isBackgrounded ?? current?.isBackgrounded), nickname: mergeValue(patch.nickname, current?.nickname ?? null), role: mergeValue(patch.role, current?.role ?? null), objective: mergeValue(patch.objective, current?.objective ?? null), @@ -443,6 +459,7 @@ function duplicatePatchFrom(duplicate: OrchestrationSubagent): SubagentPatch { agentPath: duplicate.agentPath, parentAgentPath: duplicate.parentAgentPath, treeDepth: duplicate.treeDepth, + ...backgroundedField(duplicate.isBackgrounded), nickname: duplicate.nickname, role: duplicate.role, objective: duplicate.objective, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts index 548b72e08..74445dabb 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadSubagents.ts @@ -1,6 +1,8 @@ import type { OrchestrationSubagent } from "@threadlines/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; @@ -12,19 +14,38 @@ import { type ProjectionThreadSubagentRepositoryShape, } from "../Services/ProjectionThreadSubagents.ts"; +/** SQLite keeps the optional flag as NULL/0/1. */ +export const ProjectionThreadSubagentDbRowSchema = ProjectionThreadSubagent.mapFields( + Struct.assign({ + isBackgrounded: Schema.NullOr(Schema.Number), + }), +); + +export function toProjectionThreadSubagent( + row: Schema.Schema.Type, +): ProjectionThreadSubagent { + const { isBackgrounded, ...rest } = row; + return isBackgrounded === null ? rest : { ...rest, isBackgrounded: isBackgrounded === 1 }; +} + +export function subagentBackgroundedColumn(row: OrchestrationSubagent): number | null { + return row.isBackgrounded === undefined ? null : row.isBackgrounded ? 1 : 0; +} + const makeProjectionThreadSubagentRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const listRows = SqlSchema.findAll({ Request: ProjectionThreadSubagentThreadInput, - Result: ProjectionThreadSubagent, + Result: ProjectionThreadSubagentDbRowSchema, execute: ({ threadId }) => sql` SELECT thread_id AS "threadId", subagent_id AS "id", agent_thread_id AS "agentThreadId", parent_agent_thread_id AS "parentAgentThreadId", spawn_call_id AS "spawnCallId", transcript_agent_id AS "transcriptAgentId", turn_id AS "turnId", agent_path AS "agentPath", parent_agent_path AS "parentAgentPath", - tree_depth AS "treeDepth", nickname, role, objective, status, + tree_depth AS "treeDepth", is_backgrounded AS "isBackgrounded", + nickname, role, objective, status, requested_model AS "requestedModel", resolved_model AS "resolvedModel", reasoning_effort AS "reasoningEffort", model_provenance AS "modelProvenance", reasoning_effort_provenance AS "reasoningEffortProvenance", @@ -38,6 +59,7 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { const listByThreadId: ProjectionThreadSubagentRepositoryShape["listByThreadId"] = (input) => listRows(input).pipe( + Effect.map((rows) => rows.map(toProjectionThreadSubagent)), Effect.mapError( toPersistenceSqlError("ProjectionThreadSubagentRepository.listByThreadId:query"), ), @@ -63,6 +85,7 @@ const makeProjectionThreadSubagentRepository = Effect.gen(function* () { agent_path: row.agentPath, parent_agent_path: row.parentAgentPath, tree_depth: row.treeDepth, + is_backgrounded: subagentBackgroundedColumn(row), nickname: row.nickname, role: row.role, objective: row.objective, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6fbc786c2..9a75ae61d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -61,6 +61,7 @@ import Migration0045 from "./Migrations/045_SettleStoppedProjectionTurns.ts"; import Migration0046 from "./Migrations/046_ProjectionTurnsCheckpointCompletedAt.ts"; import Migration0047 from "./Migrations/047_ProjectionThreadSubagents.ts"; import Migration0048 from "./Migrations/048_BackfillThreadSubagents.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadSubagentsBackgrounded.ts"; /** * Migration loader with all migrations defined inline. @@ -121,6 +122,7 @@ export const migrationEntries = [ [46, "ProjectionTurnsCheckpointCompletedAt", Migration0046], [47, "ProjectionThreadSubagents", Migration0047], [48, "BackfillThreadSubagents", Migration0048], + [49, "ProjectionThreadSubagentsBackgrounded", Migration0049], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts new file mode 100644 index 000000000..e84d7f035 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadSubagentsBackgrounded.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Whether the provider runs a spawned agent in the background (its spawn + * returned without blocking the turn). NULL when the provider never said; + * 0/1 otherwise. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`ALTER TABLE projection_thread_subagents ADD COLUMN is_backgrounded INTEGER`; +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 603dec18b..832d5684c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -3443,6 +3443,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(depthMetadata?.type, "subagent.metadata.updated"); if (depthMetadata?.type === "subagent.metadata.updated") { assert.equal(depthMetadata.payload.treeDepth, 1); + assert.equal(depthMetadata.payload.isBackgrounded, false); } const watchStarted = startedEvents.find( (event) => event.type === "task.started" && String(event.payload.taskId) === "task-watch", diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d6b8ee224..be5a4b73e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5178,7 +5178,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // The SDK states an agent's nesting depth only here; the spawn call // is what the roster row is keyed by until the agent id is known. if (spawnDepth !== undefined && toolUseId) { - yield* emitSubagentMetadata(context, { callId: toolUseId, treeDepth: spawnDepth }); + yield* emitSubagentMetadata(context, { + callId: toolUseId, + treeDepth: spawnDepth, + ...(isBackgrounded !== undefined ? { isBackgrounded } : {}), + }); } return; } @@ -5255,6 +5259,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(description ? { description } : {}), ...(status ? { status } : {}), }); + // A foreground agent moved to the background (Ctrl+B, or the model + // backgrounding it): the roster row should say so from now on. + if (patch.is_backgrounded === true && previous?.toolUseId) { + yield* emitSubagentMetadata(context, { + callId: previous.toolUseId, + isBackgrounded: true, + }); + } yield* offerRuntimeEvent({ ...base, diff --git a/apps/web/src/components/chat/agentsPanel.logic.test.ts b/apps/web/src/components/chat/agentsPanel.logic.test.ts index c75d1b575..ac2ebdda7 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.test.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.test.ts @@ -361,6 +361,33 @@ describe("buildAgentsPanelView", () => { expect(view.current[0]?.meta).toEqual(["gpt-5.6-sol", "high", "36s"]); }); + it("marks a live agent the provider runs in the background, after its identity", () => { + const build = (isBackgrounded: boolean, status: SubagentProgressItem["status"]) => + buildSubagent({ + id: `bg-${String(isBackgrounded)}-${status}`, + agentThreadId: `bg-${String(isBackgrounded)}-${status}`, + status, + statusLabel: status === "running" ? "Running" : "Done", + model: "claude-opus-5", + reasoningEffort: null, + telemetry: null, + isBackgrounded, + createdAt: "2026-08-11T10:11:24.000Z", + updatedAt: "2026-08-11T10:11:54.000Z", + }); + const view = buildAgentsPanelView({ + subagents: [build(true, "running"), build(false, "running"), build(true, "completed")], + nowMs: Date.parse("2026-08-11T10:12:00.000Z"), + }); + + // A finished row is a receipt; how it was launched no longer matters there. + expect(view.current.map((branch) => branch.meta)).toEqual([ + ["claude-opus-5", "background", "36s"], + ["claude-opus-5", "36s"], + ["claude-opus-5"], + ]); + }); + it("closes the meta line with what the agent wrote, live and in history", () => { const telemetry = { step: null, diff --git a/apps/web/src/components/chat/agentsPanel.logic.ts b/apps/web/src/components/chat/agentsPanel.logic.ts index 946d3a53e..527adfe68 100644 --- a/apps/web/src/components/chat/agentsPanel.logic.ts +++ b/apps/web/src/components/chat/agentsPanel.logic.ts @@ -159,6 +159,9 @@ function subagentBranch( // with the model also keeps the line still while the clock ticks. meta: [ ...agentIdentityMetaParts(item), + // Launched without the turn waiting on it: the parent is not blocked + // here, and the row can outlive the turn. + ...(live && item.isBackgrounded === true ? ["background"] : []), ...formatSubagentMetaParts(item, { context: details.context, elapsed: live ? formatElapsedDurationLabel(item.createdAt, nowMs) : null, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8a67f45a7..c28519b78 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -265,6 +265,8 @@ export interface SubagentProgressItem { parentAgentPath?: string | null; /** Visual nesting below the first child of `/root`. */ treeDepth?: number; + /** See OrchestrationSubagent.isBackgrounded. */ + isBackgrounded?: boolean; turnId: TurnId | null; label: string; nickname?: string | null; @@ -1212,6 +1214,7 @@ function collectSubagentActivityRecords( agentPath: subagent.agentPath, parentAgentPath: subagent.parentAgentPath, treeDepth: subagent.treeDepth, + ...(subagent.isBackgrounded !== undefined ? { isBackgrounded: subagent.isBackgrounded } : {}), turnId: subagent.turnId, label, ...(subagent.nickname ? { nickname: subagent.nickname } : {}), @@ -1743,6 +1746,7 @@ function applySubagentMetadataActivity( agentPath: asTrimmedString(payload.agentPath) ?? previous?.agentPath ?? null, parentAgentPath: asTrimmedString(payload.parentAgentPath) ?? previous?.parentAgentPath ?? null, treeDepth: previous?.treeDepth ?? 0, + ...(previous?.isBackgrounded !== undefined ? { isBackgrounded: previous.isBackgrounded } : {}), turnId: activity.turnId ?? previous?.turnId ?? null, label: subagentDisplayLabel({ role, nickname: null }), ...(nickname ? { nickname } : {}), diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index a5313bc8c..98e22d6c7 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -688,6 +688,10 @@ export const OrchestrationSubagent = Schema.Struct({ agentPath: Schema.NullOr(TrimmedNonEmptyString), parentAgentPath: Schema.NullOr(TrimmedNonEmptyString), treeDepth: NonNegativeInt, + /** The provider runs this agent in the background: its spawn returned at + * once instead of blocking the turn. Absent when the provider does not say + * (Codex agents always block their parent). */ + isBackgrounded: Schema.optional(Schema.Boolean), nickname: Schema.NullOr(TrimmedNonEmptyString), role: Schema.NullOr(TrimmedNonEmptyString), objective: Schema.NullOr(TrimmedNonEmptyString), diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 039ad9b71..628dbe05a 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -353,6 +353,8 @@ export const SubagentMetadataUpdatedPayload = Schema.Struct({ /** Nesting depth (1 = spawned by the main agent), for providers that report * it directly instead of through an agent path. */ treeDepth: Schema.optional(NonNegativeInt), + /** See OrchestrationSubagent.isBackgrounded. */ + isBackgrounded: Schema.optional(Schema.Boolean), agentNickname: Schema.optional(TrimmedNonEmptyStringSchema), agentRole: Schema.optional(TrimmedNonEmptyStringSchema), taskName: Schema.optional(TrimmedNonEmptyStringSchema),