Skip to content

Commit d2e6ce8

Browse files
feat(server): t3_thread_launch takes clientRequestId
A launch had no retry key, so an agent (or another environment calling on its behalf) that lost the response could only guess whether the thread was created, and a retry started a second one. t3_thread_launch now takes an optional clientRequestId. The command, thread and message ids derive from the caller and the key, and the launch service already replays a command it accepted, so a retry returns the first thread. Without a key every launch is new, as before. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent e2a86d1 commit d2e6ce8

7 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎apps/server/src/mcp/toolkits/project/handlers.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,44 @@ it.effect("a client launches at its ceiling with the project's default model", (
404404
}),
405405
);
406406

407+
it.effect("a launch retried with the same clientRequestId replays the first one", () =>
408+
Effect.gen(function* () {
409+
const launched: Array<ThreadLaunch.ThreadLaunchInput> = [];
410+
const { projectId, dependencies } = clientLaunchHarness({
411+
runtimeModeCeiling: "auto",
412+
launched,
413+
});
414+
const toolkit = yield* ProjectToolkit.pipe(
415+
Effect.provide(
416+
McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)),
417+
),
418+
);
419+
const launch = (clientRequestId: string | undefined) =>
420+
toolkit
421+
.handle("t3_thread_launch", {
422+
title: "Fix",
423+
projectId,
424+
message: "Fix the bug",
425+
...(clientRequestId === undefined ? {} : { clientRequestId }),
426+
})
427+
.pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies));
428+
429+
yield* launch("fix-1");
430+
yield* launch("fix-1");
431+
yield* launch("fix-2");
432+
yield* launch(undefined);
433+
yield* launch(undefined);
434+
const ids = launched.map((input) => [input.commandId, input.threadId]);
435+
// The same key sends the same command and thread ids, which the launch
436+
// service replays instead of starting a second thread.
437+
expect(ids[0]).toEqual(ids[1]);
438+
expect(ids[0]?.[0]).toContain("client%3Asession-1");
439+
expect(ids[2]).not.toEqual(ids[0]);
440+
// Without a key every launch is new.
441+
expect(ids[3]).not.toEqual(ids[4]);
442+
}),
443+
);
444+
407445
it.effect("a launch binds only an existing checkout that is one of the project's worktrees", () =>
408446
Effect.gen(function* () {
409447
const fileSystem = yield* FileSystem.FileSystem;

‎apps/server/src/mcp/toolkits/project/handlers.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools/contracts";
1+
import {
2+
CommandId,
3+
MessageId,
4+
ThreadId,
5+
OrchestratorMcpFailure,
6+
ProjectId,
7+
} from "@t3tools/contracts";
28
import { formatThreadLink } from "@t3tools/shared/threadLinks";
39
import * as Effect from "effect/Effect";
410
import * as FileSystem from "effect/FileSystem";
@@ -59,7 +65,15 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
5965
Effect.gen(function* () {
6066
const context = yield* readCaller();
6167
const { caller, scope } = context;
62-
const commandId = yield* newCommandId();
68+
// A retry with the same key replays the first launch: the ids derive
69+
// from the caller and the key, and the launch service replays a
70+
// command it already accepted.
71+
const commandId =
72+
input.clientRequestId === undefined
73+
? yield* newCommandId()
74+
: CommandId.make(
75+
`mcp:launch:${encodeURIComponent(scope.requestNamespace)}:${encodeURIComponent(input.clientRequestId)}`,
76+
);
6377
const threadId = ThreadId.make(commandId);
6478
const messageId = MessageId.make(commandId);
6579
const attachments = input.attachments ?? [];

‎apps/server/src/mcp/toolkits/project/tools.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ProjectCreatePayload,
1414
ProjectUpdatePayload,
1515
ProjectId,
16+
OrchestratorMcpClientRequestId,
1617
OrchestratorMcpFailure,
1718
SourceControlCloneRepositoryInput,
1819
SourceControlCloneRepositoryResult,
@@ -103,7 +104,7 @@ const ProjectCloneTool = Tool.make("t3_project_clone", {
103104
const ThreadLaunchTool = Tool.make("t3_thread_launch", {
104105
...shared,
105106
description:
106-
'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Each call creates a new launch with no retry key; retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. After errors or lost responses, inspect t3_thread_list before retrying. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
107+
'Create an ordinary TOP-LEVEL thread with an explicit workspace binding before its agent starts. Use this when the user requests independent work, a new thread, or a PR stack in its own worktree; use delegate_task for child subagents. Set workspaceStrategy to {type:"worktree",baseRef:"parent-branch",branch:"new-branch",startFromOrigin:false} for a new worktree based on local commits, or {type:"existing_worktree",worktreePath:"/absolute/path",branch:"existing-branch"} to use an existing checkout. For upstream commits, set startFromOrigin:true. Omitted workspaceStrategy means the project root, NOT the caller\'s worktree. Omit projectId/modelSelection/modes to inherit those settings from the calling thread; a caller outside a T3 thread must pass projectId and gets the project\'s default model. Set scratch:true instead of projectId for a thread without a project: it runs in a fresh folder of its own, outside any repository. Put the task in message. Do not ask the agent to create its own worktree via shell: that does not update the thread binding. Pass clientRequestId to retry safely: the same key returns the thread the first call created instead of launching another. Retain threadId and use t3_thread_read/t3_thread_wait to follow preparation. Paste the returned link when you mention the thread. Attachments must be pending uploads. The new thread may not run with broader runtime or interaction modes than the caller: the calling T3 thread\'s own modes, or the permission mode an outside agent was approved with.',
107108
parameters: Schema.Struct({
108109
projectId: Schema.optional(ProjectId),
109110
scratch: Schema.optional(
@@ -129,6 +130,7 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
129130
}),
130131
),
131132
attachments: Schema.optional(Schema.Array(McpAttachmentInput).check(Schema.isMaxLength(8))),
133+
clientRequestId: Schema.optional(OrchestratorMcpClientRequestId),
132134
}),
133135
success: Schema.Struct({
134136
threadId: ThreadId,

‎apps/server/src/orchestration-v2/ThreadLaunchService.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,37 @@ it.effect("replays a server-allocated launch", () =>
16071607
}),
16081608
);
16091609

1610+
it.effect("an agent's launch never starts work above the modes it asks for", () => {
1611+
const harness = makeHarness();
1612+
return Effect.gen(function* () {
1613+
const launches = yield* ThreadLaunch.ThreadLaunchService;
1614+
const threads = yield* ThreadManagement.ThreadManagementService;
1615+
// The first launch, at full access, creates the thread without a message.
1616+
const created = yield* launches.launch(
1617+
launchInput({ command: "command:launch:narrowed-retry", thread: "thread:narrowed" }),
1618+
);
1619+
// The same key comes back from a narrower agent, now carrying a message,
1620+
// as a retry or a concurrent launch that lost the create would.
1621+
const failed = yield* launches
1622+
.launch({
1623+
...launchInput({
1624+
command: "command:launch:narrowed-retry",
1625+
thread: "thread:narrowed",
1626+
message: "Run this",
1627+
}),
1628+
runtimeMode: "approval-required",
1629+
interactionMode: "plan",
1630+
createdBy: "agent",
1631+
creationSource: "mcp",
1632+
})
1633+
.pipe(Effect.flip);
1634+
assert.equal(failed.operation, "dispatch-message");
1635+
const projection = yield* threads.getThreadProjection(created.threadId);
1636+
assert.equal(projection.runs.length, 0);
1637+
assert.equal(projection.messages.length, 0);
1638+
}).pipe(Effect.provide(harness.layer));
1639+
});
1640+
16101641
it.effect("rejects a server-allocated launch replay with a mismatching thread id", () => {
16111642
const harness = makeHarness();
16121643
return Effect.gen(function* () {

‎apps/server/src/orchestration-v2/ThreadLaunchService.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import * as IdAllocator from "./IdAllocator.ts";
4747
import type * as Orchestrator from "./Orchestrator.ts";
4848
import { makeProviderFailure } from "./ProviderFailure.ts";
4949
import { randomUuidV4 } from "./RandomUuid.ts";
50+
import { DispatchModeLimit } from "./DispatchModeLimit.ts";
5051
import * as ThreadManagement from "./ThreadManagementService.ts";
5152

5253
export type ThreadLaunchWorkspaceStrategy =
@@ -804,7 +805,6 @@ const make = Effect.gen(function* () {
804805
if (project.id !== input.projectId) {
805806
return yield* mapError(input, "resolve-project", threadId)("Project identity changed.");
806807
}
807-
808808
let runId: RunId | null = null;
809809
let messageWasAlreadyAccepted = false;
810810
if (input.initialMessage !== undefined) {
@@ -837,7 +837,20 @@ const make = Effect.gen(function* () {
837837
createdBy: input.createdBy,
838838
creationSource: input.creationSource,
839839
})
840-
.pipe(Effect.mapError(mapError(input, "dispatch-message", threadId)));
840+
.pipe(
841+
// An agent's launch may land on a thread a replay, or a
842+
// concurrent launch with the same key, created with broader modes
843+
// than it asks for. The orchestrator refuses the message under
844+
// the thread's lock. The user's own clients launch into their
845+
// own drafts, whatever modes those hold.
846+
input.createdBy === "agent"
847+
? Effect.provideService(DispatchModeLimit, {
848+
runtimeMode: input.runtimeMode,
849+
interactionMode: input.interactionMode,
850+
})
851+
: (effect) => effect,
852+
Effect.mapError(mapError(input, "dispatch-message", threadId)),
853+
);
841854
const runCreated = dispatched.storedEvents.find(
842855
(stored) => stored.event.type === "run.created",
843856
);

‎apps/server/src/provider/T3OrchestrationInstructions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ For stacked work, set \`baseRef\` to the intended parent branch and \`startFromO
2424
2525
\`t3_thread_launch\` is the single-thread launch tool. Use \`create_threads\` only for a batch of threads intentionally sharing the caller's checkout: it always inherits the caller's project, branch, and worktree and has no workspace override. Asking an agent to run \`git worktree add\` or \`cd\` in its prompt does not update T3's thread binding. Select the workspace in the launch call instead. \`t3_worktree_handoff\` moves the calling thread, not another thread, and cannot move a thread already attached to a worktree.
2626
27-
\`t3_thread_launch\` has no idempotency key. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance. If a launch fails or its response is lost, inspect \`t3_thread_list\` before retrying, since a thread may already exist.
27+
Pass a \`clientRequestId\` to \`t3_thread_launch\` and reuse it when retrying: the same key returns the thread the first call created. Retain its returned threadId and inspect it with \`t3_thread_read\` / \`t3_thread_wait\`; preparation can still be running after acceptance.
2828
2929
Tool names may include a harness-normalized MCP prefix, such as \`mcp__t3_code__delegate_task\`; the semantics are the same. Some harnesses attach optional MCP servers lazily: if an initial tool-catalog scan does not show T3 tools, do not conclude that cross-provider delegation is unavailable. Make one bounded direct attempt using the known T3 tool name on the next tool step. In Codex code mode, for example, call \`tools.mcp__t3_code__orchestrator_capabilities({})\` before reporting that the capability is absent. Keep polling/wait loops bounded, do not duplicate active work, and use stable \`clientRequestId\` values when retrying tools that accept them.
3030

‎packages/contracts/src/orchestratorMcp.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const OrchestratorMcpPrompt = TrimmedNonEmptyString.check(Schema.isMaxLength(120
4444
const OrchestratorMcpTitle = TrimmedNonEmptyString.check(Schema.isMaxLength(512)).annotate({
4545
description: "Optional concise display title.",
4646
});
47-
const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(
47+
export const OrchestratorMcpClientRequestId = TrimmedNonEmptyString.check(
4848
Schema.isMaxLength(256),
4949
).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });
5050

0 commit comments

Comments
 (0)