Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a59eacc
fix(server): let parents recover their delegated children across acco…
shivamhwp Nov 3, 2026
040451a
fix(server): preserve fresh limits when resuming delegated children
shivamhwp Nov 3, 2026
2961543
fix(server): clarify when automatic thread sends steer
shivamhwp Nov 4, 2026
fcf60e9
fix(server): refresh caller limits before sending child attachments
shivamhwp Nov 5, 2026
cbf55ec
Merge remote-tracking branch 'github/main' into issue-watch/30408
maria-rcks Nov 5, 2026
e71cac3
chore: merge main into delegated child recovery
shivamhwp Nov 6, 2026
a5a8c34
Merge remote-tracking branch 'github/main' into issue-watch/30408
maria-rcks Nov 6, 2026
890d560
fix(server): refresh caller limits before configuring threads
shivamhwp Nov 6, 2026
6b2cb36
chore: merge main into delegated child recovery
shivamhwp Nov 6, 2026
d747dbd
fix(server): recheck parent interaction mode under dispatch locks
shivamhwp Nov 6, 2026
2266f85
chore: merge main into delegated-child recovery fix
shivamhwp Nov 6, 2026
52bcd8d
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 7, 2026
0fa5929
fix(server): keep the owned-child exception to the runtime limit
shivamhwp Nov 7, 2026
9f94e03
test(server): provide the own-child test layers once; drop the eval g…
shivamhwp Nov 7, 2026
9d216c0
fix(server): keep the send descriptions within main's context budget
shivamhwp Nov 7, 2026
9c61be0
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 7, 2026
5e125db
Merge remote-tracking branch 'github/main' into issue-watch/30408
maria-rcks Nov 10, 2026
9bee723
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 10, 2026
7a5a23d
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 10, 2026
c7df00e
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 10, 2026
b386e12
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 10, 2026
f2b003f
Merge remote-tracking branch 'origin/main' into issue-watch/30408
juliusmarminge Nov 11, 2026
c562ab7
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 11, 2026
870399c
Merge origin/main into issue-watch/30408
shivamhwp Nov 12, 2026
76ab2da
fix(server): parents keep acting on their own children through t3_thr…
shivamhwp Nov 12, 2026
5a7f5cf
Merge main 59e64eef881 into issue-watch/30408
shivamhwp Nov 13, 2026
c1e102a
Merge main eb7e63a8220 into issue-watch/30408
shivamhwp Nov 13, 2026
8b74cba
Merge main 4582a3bc682d into issue-watch/30408
shivamhwp Nov 13, 2026
da77738
Merge main aab388ddb1f7 into issue-watch/30408
shivamhwp Nov 13, 2026
3219bad
Merge main bc7fe11002aa into issue-watch/30408
shivamhwp Nov 13, 2026
dfe0a43
Merge main 42057b82d097 into issue-watch/30408
shivamhwp Nov 14, 2026
39fa369
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 14, 2026
62d5047
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 14, 2026
333bf2f
Merge remote-tracking branch 'origin/main' into issue-watch/30408
shivamhwp Nov 14, 2026
e7f9ac5
Merge main 5393882 into issue-watch/30408
shivamhwp Nov 14, 2026
010c784
Merge main f7b0b3c into issue-watch/30408
shivamhwp Nov 15, 2026
0febf7f
Merge main 9504f65 into issue-watch/30408
shivamhwp Nov 15, 2026
07fceba
Merge main ffd11ff into issue-watch/30408
shivamhwp Nov 15, 2026
b168553
Merge main 4117bd4 into issue-watch/30408
shivamhwp Nov 15, 2026
310aaf6
Merge remote-tracking branch 'origin/main' into t3/30457-smaller-reco…
shivamhwp Nov 15, 2026
0f74b78
fix(server): shrink the own-child recovery to one shared limit check
shivamhwp Nov 15, 2026
21ab5c3
Merge commit 'dcaedc980034008cdf99fe18340cd3018869f154' into t3/30457…
shivamhwp Nov 15, 2026
7a75744
test(server): provide the peer and secret services the own-child tabl…
shivamhwp Nov 15, 2026
07b354e
Merge commit '2becf31b640695216df4156f48239ddf35a7983c' into t3/30457…
shivamhwp Nov 15, 2026
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
18 changes: 16 additions & 2 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
assertLiveCaller,
assertTargetWithinLimits,
type Caller,
limitsForOwnChild,
loadCaller,
readCaller,
unavailable,
Expand Down Expand Up @@ -199,25 +200,38 @@ export const writesWithCapability = <P, A, E, R>(handle: (params: P) => Effect.E
export const writesThreads = <P, A, E, R>(
threads: (params: P) => ReadonlyArray<ThreadId | undefined>,
handle: (params: P) => Effect.Effect<A, E, R>,
/** `ownChildren` lets a thread reach a child it delegated that runs above its runtime mode. */
options?: { readonly ownChildren?: "at-their-runtime-mode" },
) =>
declare(
(params: P) =>
Effect.gen(function* () {
const caller = yield* writingCaller;
let dispatchLimits = caller.limits;
for (const threadId of threads(params)) {
if (threadId === undefined || threadId === caller.scope.thread?.threadId) continue;
const target = yield* caller.threads
.getThreadShell(threadId)
.pipe(Effect.mapError(unavailable));
if (target !== null && target.deletedAt === null) {
yield* assertTargetWithinLimits(caller.limits, target);
const limits =
options?.ownChildren === undefined
? caller.limits
: yield* limitsForOwnChild(
caller.threads,
caller.caller?.id,
caller.limits,
target,
);
yield* assertTargetWithinLimits(limits, target);
if (limits !== caller.limits) dispatchLimits = limits;
yield* assertSameLink(caller, target);
}
}
// Modes can rise after this check; the orchestrator rechecks under lock and records refusal before handlers wrap errors.
const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
return yield* handle(params).pipe(
Effect.provideService(DispatchModeLimit, { ...caller.limits, refused }),
Effect.provideService(DispatchModeLimit, { ...dispatchLimits, refused }),
Effect.catch((error) =>
Effect.flatMap(Ref.get(refused), (refusal) =>
Effect.fail<E | OrchestratorMcpFailure>(
Expand Down
12 changes: 10 additions & 2 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,12 @@ import {
runtimeModeRank,
taskPrompt,
} from "./delegatedTaskModes.ts";
import { assertTargetWithinLimits, isLiveCallerShell, threadNotFound } from "./threadAccess.ts";
import {
assertTargetWithinLimits,
isLiveCallerShell,
limitsForOwnChild,
threadNotFound,
} from "./threadAccess.ts";
import * as SecretRequests from "../secrets/SecretRequests.ts";
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
Expand Down Expand Up @@ -3389,7 +3394,10 @@ const make = Effect.gen(function* () {
sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) {
const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]);
yield* assertLiveCallerForOtherThread(scope, parent, target);
yield* assertTargetWithinLimits(limits, target.thread);
yield* assertTargetWithinLimits(
yield* limitsForOwnChild(threadManagement, parent?.thread.id, limits, target.thread),
target.thread,
);

const mode = input.mode ?? "auto";
const key = yield* requestKey(input.clientRequestId);
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/mcp/threadAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from "../orchestration-v2/ProjectionStore.ts";
import {
CommandId,
isRuntimeModeAbove,
OrchestratorMcpFailure,
type ProjectId,
type ProviderInstanceId,
Expand Down Expand Up @@ -158,6 +159,29 @@ export const assertTargetWithinLimits = (
),
);

/**
* A thread acts on a child it delegated at the runtime mode that child already has, so it can
* move the child to another account and resume it after its own mode was lowered.
*/
export const limitsForOwnChild = Effect.fn("mcp.limitsForOwnChild")(function* (
threads: Pick<ThreadManagement.ThreadManagementService["Service"], "listAppOwnedChildThreadIds">,
callerThreadId: ThreadId | undefined,
limits: CallerLimits,
target: Pick<OrchestrationV2ThreadShell, "id" | "runtimeMode" | "lineage">,
) {
if (
callerThreadId === undefined ||
!isRuntimeModeAbove(target.runtimeMode, limits.runtimeMode) ||
target.lineage.parentThreadId !== callerThreadId ||
target.lineage.relationshipToParent !== "subagent"
)
return limits;
const delegated = yield* threads
.listAppOwnedChildThreadIds(callerThreadId)
.pipe(Effect.mapError(unavailable));
return delegated.includes(target.id) ? { ...limits, runtimeMode: target.runtimeMode } : limits;
});

/** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */
export const isLiveCallerShell = (
shell: Pick<
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/attachment/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const layer = McpToolAccess.toLayer(
status: result.run.status,
};
}),
{ ownChildren: "at-their-runtime-mode" },
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
),
};
}),
Expand Down
109 changes: 109 additions & 0 deletions apps/server/src/mcp/toolkits/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {

import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
import * as ServerConfig from "../../config.ts";
import { DispatchModeLimit } from "../../orchestration-v2/DispatchModeLimit.ts";
import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts";
import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts";
Expand Down Expand Up @@ -1262,6 +1263,114 @@ it.effect.each(["t3_thread_interrupt", "t3_workflow_stop"])(
),
);

it.effect.each([
["t3_thread_configure", "default", true, "full-access/default"],
["t3_thread_send", "default", true, "full-access/default"],
["t3_thread_send_attachments", "default", true, "full-access/default"],
["t3_thread_send", "default", false, "runtime_mode_escalation_denied"],
["t3_thread_configure", "plan", true, "interaction_mode_escalation_denied"],
] as const)(
"%s from an Auto/%s parent to its Full-access child (delegated: %s) gives %s",
([toolName, interactionMode, delegated, expected]) => {
const childId = ThreadId.make("mcp-core-own-child");
const parent = McpToolAccessTestkit.liveThreadShell(threadId, {
runtimeMode: "auto",
interactionMode,
});
const child = {
...McpToolAccessTestkit.liveThreadShell(childId),
lineage: {
rootThreadId: threadId,
parentThreadId: threadId,
relationshipToParent: "subagent" as const,
},
};
const sent = { type: "image", id: "sent", name: "a.png", mimeType: "image/png", sizeBytes: 1 };
// The limit the orchestrator rechecks the child against under its lock.
const dispatchLimit = DispatchModeLimit.pipe(
Effect.map((limit) => `${limit?.runtimeMode}/${limit?.interactionMode}`),
);
let limit: string | undefined;
return Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const result = yield* server
.callTool({
name: toolName,
arguments: {
threadId: childId,
...(toolName === "t3_thread_configure"
? { modelSelection: { instanceId: "codex-b", model: "gpt-5" } }
: toolName === "t3_thread_send"
? { message: "Go." }
: { attachments: [sent] }),
},
})
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(declaredFailure(result)?.code ?? limit).toBe(expected);
}).pipe(
Effect.provide(
Layer.mergeAll(
layerThreadToolkit,
layerOrchestratorToolkit,
McpHttpServer.layerAttachmentToolkit,
).pipe(
Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
Layer.provide(
Layer.mergeAll(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: (id) => Effect.succeed(id === childId ? child : parent),
getThreadRecords: () =>
Effect.succeed(McpToolAccessTestkit.idleThreadProjection(parent) as never),
getProjectThreadRecords: () =>
Effect.succeed({
...McpToolAccessTestkit.idleThreadProjection(child),
messages: [{ attachments: [sent] }],
} as never),
listAppOwnedChildThreadIds: () => Effect.succeed(delegated ? [childId] : []),
dispatch: () =>
dispatchLimit.pipe(
Effect.map((seen) => {
limit = seen;
return { sequence: 1, storedEvents: [] };
}),
),
sendToThread: () =>
dispatchLimit.pipe(
Effect.map((seen) => {
limit = seen;
return {
run: { id: RunId.make("run:own-child"), status: "running" },
message: { attachments: [] },
delivery: "started",
} as never;
}),
),
}),
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({}),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
Layer.mock(PeerForwarding.PeerForwarding)({}),
Layer.mock(PeerLinkRequests.PeerLinkRequests)({}),
Layer.mock(PeerLinks.PeerLinks)({}),
Layer.mock(DocumentText.DocumentText)({}),
AssetSigner.layer.pipe(Layer.provideMerge(ServerSecretStore.layer)),
),
),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-own-child-" })),
Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
),
),
);
},
);

it.effect("discovers workflow stop targets and requires orchestration capability", () => {
const threadId = ThreadId.make("workflow-parent");
const shell = McpToolAccessTestkit.liveThreadShell(threadId);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/orchestrator/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ const handlers = {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
return yield* service.sendToThread(scope, input);
}),
{ ownChildren: "at-their-runtime-mode" },
),
// There the thread's parent is here, so only this side can make a follow-up a task.
(input, environmentId, sent) =>
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/mcp/toolkits/thread/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ const required = <A>(value: A | undefined, name: string) =>
const writesThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
handle: (params: P) => Effect.Effect<A, E, R>,
) => McpToolAccess.writesThreads((params: P) => [params.threadId], handle);
const writesOwnChildThread = <P extends { readonly threadId?: ThreadId | undefined }, A, E, R>(
handle: (params: P) => Effect.Effect<A, E, R>,
) =>
McpToolAccess.writesThreads((params: P) => [params.threadId], handle, {
ownChildren: "at-their-runtime-mode",
});

export const layer = McpToolAccess.toLayer(ThreadToolkit, {
run_scheduled_task_now: McpToolAccess.writesEnvironment((input) =>
Expand Down Expand Up @@ -229,7 +235,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
};
}),
),
writesThread((input) =>
writesOwnChildThread((input) =>
Effect.gen(function* () {
const modelSelection = yield* required(input.modelSelection, "modelSelection");
const {
Expand Down
Loading