Skip to content
Closed

test #1127

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
125 changes: 125 additions & 0 deletions .docs/subagent-worktrees.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Sub-Agent Worktrees

## Summary

T3 Code now supports specialist sub-agents that run in isolated git worktrees and return only a distilled report to the parent thread.

The current implementation is Codex-first and uses external Codex skills from:

- `CODEX_HOME/skills`
- fallback: `~/.codex/skills`

## User Flow

1. A user runs `/skill <skill-id> <task>`.
2. The server creates a `SubagentRun` on the parent thread with status `preparing`.
3. `SubagentCoordinator` creates a new git worktree and branch for the run.
4. The server creates a hidden orchestration thread with:
- `threadKind = "subagent"`
- `parentThreadId = <main thread id>`
5. The hidden thread starts a normal Codex turn with skill prompt injected through `developerInstructions`.
6. When the hidden turn quiesces, the server synthesizes a structured report from the hidden assistant output and checkpoint data.
7. The parent thread shows a specialist report card instead of the hidden conversation history.

## Important Behavior

- Hidden sub-agent threads do not appear in the normal thread list.
- `Open worktree thread` creates a normal visible thread on the retained sub-agent worktree.
- `Use report` inserts the distilled report into the composer only. It does not auto-send.
- `Discard` cleans up the sub-agent worktree and branch.
- If a visible thread was opened on that worktree, cleanup now detaches it back to a normal local thread so it does not keep a dead `cwd`.
- Generic thread deletion also treats already-missing worktrees as a safe no-op.

## Current Skill Source

Skill discovery is server-side and reads `SKILL.md` files from the external Codex skill home.

V1 assumptions:

- no repo-local `.agents/skills`
- no VS Code extension internals
- no `tools.json`
- no `report-schema.md`

The only required skill artifact is `SKILL.md`.

## Key Files

### Contracts

- `packages/contracts/src/subagent.ts`
- `packages/contracts/src/orchestration.ts`
- `packages/contracts/src/provider.ts`
- `packages/contracts/src/server.ts`

### Server

- `apps/server/src/subagents/Layers/SkillCatalog.ts`
- `apps/server/src/subagents/Layers/SubagentCoordinator.ts`
- `apps/server/src/wsServer.ts`
- `apps/server/src/codexAppServerManager.ts`
- `apps/server/src/provider/Layers/CodexAdapter.ts`
- `apps/server/src/orchestration/Layers/ProjectionPipeline.ts`
- `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts`
- `apps/server/src/git/Layers/GitCore.ts`

### Persistence

- `apps/server/src/persistence/Migrations/014_ProjectionThreadSubagentRuns.ts`
- `apps/server/src/persistence/Layers/ProjectionThreadSubagentRuns.ts`
- `apps/server/src/persistence/Services/ProjectionThreadSubagentRuns.ts`

### Web

- `apps/web/src/composer-logic.ts`
- `apps/web/src/components/chat/ComposerCommandMenu.tsx`
- `apps/web/src/components/chat/MessagesTimeline.tsx`
- `apps/web/src/components/chat/SubagentReportCard.tsx`
- `apps/web/src/components/ChatView.tsx`
- `apps/web/src/session-logic.ts`

## Status Model

`SubagentRun.status` currently uses:

- `preparing`
- `running`
- `report_ready`
- `accepted`
- `retained`
- `cleaned_up`
- `failed`
- `cleanup_failed`

## Cleanup Rules

- Clean worktree after report acceptance: can be auto-cleaned.
- Dirty worktree after report acceptance: retained until explicit discard.
- If cleanup runs after a visible worktree thread was opened, that visible thread is detached from the removed worktree.
- If sidebar thread deletion tries to remove a worktree path that is already gone, `GitCore.removeWorktree` now treats that as a no-op.

## Known Design Choices

- The hidden sub-agent conversation remains server-side state and is not replayed into the main thread.
- The visible thread opened from a sub-agent worktree starts with normal visible-thread history, not the hidden sub-agent transcript.
- The parent thread only receives the distilled report card.
- The server uses turn quiescence and orchestration projections, not file watching, to finalize reports.

## If You Want To Extend This

Common next steps:

- improve report synthesis quality and strictness
- support richer skill metadata beyond `SKILL.md`
- add branch merge/apply UX for retained sub-agent worktrees
- add explicit UI state for detached former worktree threads
- support providers beyond Codex

## Verification

This implementation was verified with:

- `bun fmt`
- `bun lint`
- `bun typecheck`
- `bun run test`
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
RuntimeReceiptBus,
type OrchestrationRuntimeReceipt,
} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
import { SubagentCoordinator } from "../src/subagents/Services/SubagentCoordinator.ts";

import {
makeTestProviderAdapterHarness,
Expand Down Expand Up @@ -292,6 +293,7 @@ export const makeOrchestrationIntegrationHarness = (
const gitCoreLayer = Layer.succeed(GitCore, {
renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
Effect.succeed({ branch: input.newBranch }),
deleteLocalBranch: () => Effect.void,
} as unknown as GitCoreShape);
const textGenerationLayer = Layer.succeed(TextGeneration, {
generateBranchName: () => Effect.succeed({ branch: null }),
Expand All @@ -308,6 +310,11 @@ export const makeOrchestrationIntegrationHarness = (
Layer.provideMerge(runtimeIngestionLayer),
Layer.provideMerge(providerCommandReactorLayer),
Layer.provideMerge(checkpointReactorLayer),
Layer.provideMerge(
Layer.succeed(SubagentCoordinator, {
start: Effect.void,
}),
),
);
const layer = orchestrationReactorLayer.pipe(
Layer.provide(persistenceLayer),
Expand Down
15 changes: 11 additions & 4 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export interface CodexAppServerSendTurnInput {
readonly serviceTier?: string | null;
readonly effort?: string;
readonly interactionMode?: ProviderInteractionMode;
readonly developerInstructions?: string;
}

export interface CodexAppServerStartSessionInput {
Expand Down Expand Up @@ -418,6 +419,7 @@ function buildCodexCollaborationMode(input: {
readonly interactionMode?: "default" | "plan";
readonly model?: string;
readonly effort?: string;
readonly developerInstructions?: string;
}):
| {
mode: "default" | "plan";
Expand All @@ -428,19 +430,21 @@ function buildCodexCollaborationMode(input: {
};
}
| undefined {
if (input.interactionMode === undefined) {
if (input.interactionMode === undefined && input.developerInstructions === undefined) {
return undefined;
}
const mode = input.interactionMode ?? "default";
const model = normalizeCodexModelSlug(input.model) ?? "gpt-5.3-codex";
return {
mode: input.interactionMode,
mode,
settings: {
model,
reasoning_effort: input.effort ?? "medium",
developer_instructions:
input.interactionMode === "plan"
input.developerInstructions ??
(mode === "plan"
? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS),
},
};
}
Expand Down Expand Up @@ -800,6 +804,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
...(input.effort !== undefined ? { effort: input.effort } : {}),
...(input.developerInstructions !== undefined
? { developerInstructions: input.developerInstructions }
: {}),
});
if (collaborationMode) {
if (!turnStartParams.model) {
Expand Down
15 changes: 3 additions & 12 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input),
ensureRemote: (input) => core.ensureRemote(input),
fetchRemoteBranch: (input) => core.fetchRemoteBranch(input),
deleteLocalBranch: (input) => core.deleteLocalBranch(input),
setBranchUpstream: (input) => core.setBranchUpstream(input),
removeWorktree: (input) => core.removeWorktree(input),
renameBranch: (input) => core.renameBranch(input),
Expand Down Expand Up @@ -1633,24 +1634,14 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);

it.effect("includes command context when worktree removal fails", () =>
it.effect("treats removing an already-missing worktree as a no-op", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const core = yield* GitCore;
const missingWorktreePath = path.join(tmp, "missing-worktree");

const removeResult = yield* Effect.result(
core.removeWorktree({ cwd: tmp, path: missingWorktreePath }),
);
expect(removeResult._tag).toBe("Failure");
if (removeResult._tag !== "Failure") {
return;
}
const message = removeResult.failure.message;
expect(message).toContain("git worktree remove");
expect(message).toContain(`cwd: ${tmp}`);
expect(message).toContain(missingWorktreePath);
yield* core.removeWorktree({ cwd: tmp, path: missingWorktreePath });
}),
);

Expand Down
46 changes: 33 additions & 13 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,26 +1253,45 @@ const makeGitCore = Effect.gen(function* () {
input.branch,
]);

const deleteLocalBranch: GitCoreShape["deleteLocalBranch"] = (input) =>
runGit("GitCore.deleteLocalBranch", input.cwd, [
"branch",
input.force ? "-D" : "-d",
input.branch,
]);

const removeWorktree: GitCoreShape["removeWorktree"] = (input) =>
Effect.gen(function* () {
const worktreeExists = yield* fileSystem
.exists(input.path)
.pipe(Effect.orElseSucceed(() => false));
if (!worktreeExists) {
return;
}
const args = ["worktree", "remove"];
if (input.force) {
args.push("--force");
}
args.push(input.path);
yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
timeoutMs: 15_000,
fallbackErrorMessage: "git worktree remove failed",
}).pipe(
Effect.mapError((error) =>
createGitCommandError(
"GitCore.removeWorktree",
input.cwd,
args,
`${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,
error,
),
),
const result = yield* Effect.result(
executeGit("GitCore.removeWorktree", input.cwd, args, {
timeoutMs: 15_000,
fallbackErrorMessage: "git worktree remove failed",
}),
);
if (result._tag === "Success") {
return;
}
const message = result.failure.message.toLowerCase();
if (message.includes("is not a working tree")) {
return;
}
return yield* createGitCommandError(
"GitCore.removeWorktree",
input.cwd,
args,
`${commandLabel(args)} failed (cwd: ${input.cwd}): ${result.failure instanceof Error ? result.failure.message : String(result.failure)}`,
result.failure,
);
});

Expand Down Expand Up @@ -1415,6 +1434,7 @@ const makeGitCore = Effect.gen(function* () {
fetchPullRequestBranch,
ensureRemote,
fetchRemoteBranch,
deleteLocalBranch,
setBranchUpstream,
removeWorktree,
renameBranch,
Expand Down
13 changes: 13 additions & 0 deletions apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export interface GitFetchRemoteBranchInput {
localBranch: string;
}

export interface GitDeleteLocalBranchInput {
cwd: string;
branch: string;
force?: boolean;
}

export interface GitSetBranchUpstreamInput {
cwd: string;
branch: string;
Expand Down Expand Up @@ -175,6 +181,13 @@ export interface GitCoreShape {
input: GitFetchRemoteBranchInput,
) => Effect.Effect<void, GitCommandError>;

/**
* Delete a local branch.
*/
readonly deleteLocalBranch: (
input: GitDeleteLocalBranchInput,
) => Effect.Effect<void, GitCommandError>;

/**
* Set the upstream tracking branch for a local branch.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
import { SubagentCoordinator } from "../../subagents/Services/SubagentCoordinator.ts";

describe("OrchestrationReactor", () => {
let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
Expand Down Expand Up @@ -46,6 +47,13 @@ describe("OrchestrationReactor", () => {
drain: Effect.void,
}),
),
Layer.provideMerge(
Layer.succeed(SubagentCoordinator, {
start: Effect.sync(() => {
started.push("subagent-coordinator");
}),
}),
),
),
);

Expand All @@ -57,6 +65,7 @@ describe("OrchestrationReactor", () => {
"provider-runtime-ingestion",
"provider-command-reactor",
"checkpoint-reactor",
"subagent-coordinator",
]);

await Effect.runPromise(Scope.close(scope, Exit.void));
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ import {
import { CheckpointReactor } from "../Services/CheckpointReactor.ts";
import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts";
import { SubagentCoordinator } from "../../subagents/Services/SubagentCoordinator.ts";

export const makeOrchestrationReactor = Effect.gen(function* () {
const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
const providerCommandReactor = yield* ProviderCommandReactor;
const checkpointReactor = yield* CheckpointReactor;
const subagentCoordinator = yield* SubagentCoordinator;

const start: OrchestrationReactorShape["start"] = Effect.gen(function* () {
yield* providerRuntimeIngestion.start;
yield* providerCommandReactor.start;
yield* checkpointReactor.start;
yield* subagentCoordinator.start;
});

return {
Expand Down
Loading