Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
59 changes: 59 additions & 0 deletions apps/server/src/mcp/McpToolAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai";

import { DispatchModeLimit } from "../orchestration-v2/DispatchModeLimit.ts";
import { OrchestratorProjectionError } from "../orchestration-v2/Orchestrator.ts";
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
Expand All @@ -31,6 +32,22 @@ const backgroundThreadId = ThreadId.make("thread:background");
const oldProviderThreadId = ThreadId.make("thread:old-provider-background");
const archivedThreadId = ThreadId.make("thread:archived-background");
const unknownOwnerThreadId = ThreadId.make("thread:unknown-background-owner");
const ownChildThreadId = ThreadId.make("thread:own-child");
const forgedChildThreadId = ThreadId.make("thread:forged-child");
const planChildThreadId = ThreadId.make("thread:plan-child");

const childShell = (threadId: ThreadId, parentThreadId: ThreadId) => ({
...liveThreadShell(threadId),
lineage: {
rootThreadId: parentThreadId,
parentThreadId,
relationshipToParent: "subagent" as const,
},
});
const appOwnedChildren = new Map([
[supervisedThreadId, [ownChildThreadId]],
[planThreadId, [planChildThreadId]],
]);

const backgroundShell = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => ({
...liveThreadShell(threadId, { activeRunId: null }),
Expand Down Expand Up @@ -63,6 +80,10 @@ const shells = new Map([
pendingBackgroundTasks: [{ taskId: "task:unknown", kind: "subagent" as const }],
},
],
[ownChildThreadId, childShell(ownChildThreadId, supervisedThreadId)],
// Says it is the supervised thread's child, but that thread never delegated it.
[forgedChildThreadId, childShell(forgedChildThreadId, supervisedThreadId)],
[planChildThreadId, childShell(planChildThreadId, planThreadId)],
]);

const threadCaller = (threadId: ThreadId): McpInvocationContext.McpInvocationScope => ({
Expand Down Expand Up @@ -121,6 +142,10 @@ const ProbeToolkit = Toolkit.make(
...probe,
parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
}),
Tool.make("writes_own_children", {
...probe,
parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
}),
Tool.make("starts_threads", {
...probe,
parameters: Schema.Struct({
Expand All @@ -141,6 +166,16 @@ const probeHandlers: McpToolAccess.Handlers<typeof ProbeToolkit.tools> = {
(input) => [input.threadId],
() => ran,
),
// Answers with the modes the orchestrator rechecks under its lock.
writes_own_children: McpToolAccess.writesThreads(
(input) => [input.threadId],
() =>
Effect.gen(function* () {
const limit = yield* DispatchModeLimit;
return { ran: `${limit?.runtimeMode}/${limit?.interactionMode}` };
}),
{ ownChildren: "at-their-runtime-mode" },
),
starts_threads: McpToolAccess.startsThreads(
(input) => input,
(_, modes) => Effect.succeed({ ran: `${modes.runtimeMode}/${modes.interactionMode}` }),
Expand Down Expand Up @@ -184,6 +219,7 @@ export const refusedAtCompileTime = () => {
writes: unchecked,
writes_with_capability: unchecked,
writes_threads: unchecked,
writes_own_children: unchecked,
starts_threads: unchecked,
writes_environment: unchecked,
}),
Expand Down Expand Up @@ -300,6 +336,27 @@ it.effect.each([
],
["writes_threads", supervised, { threadId: "thread:missing" }, "ran"],

// A thread moves and resumes its own delegated children at the runtime mode they already have.
["writes_own_children", supervised, { threadId: ownChildThreadId }, "full-access/default"],
[
"writes_own_children",
plan,
{ threadId: planChildThreadId },
"interaction_mode_escalation_denied",
],
[
"writes_own_children",
supervised,
{ threadId: forgedChildThreadId },
"runtime_mode_escalation_denied",
],
[
"writes_own_children",
supervisedClient,
{ threadId: ownChildThreadId },
"runtime_mode_escalation_denied",
],

["starts_threads", supervised, {}, "approval-required/default"],
["starts_threads", supervised, { runtimeMode: "full-access" }, "runtime_mode_escalation_denied"],
["starts_threads", plan, {}, "approval-required/plan"],
Expand All @@ -322,6 +379,8 @@ it.effect.each([
probeServer(
Layer.mock(ThreadManagement.ThreadManagementService)({
getThreadShell: (threadId) => Effect.succeed(shells.get(threadId) ?? null),
listAppOwnedChildThreadIds: (threadId) =>
Effect.succeed(appOwnedChildren.get(threadId) ?? []),
}),
),
),
Expand Down
36 changes: 33 additions & 3 deletions apps/server/src/mcp/McpToolAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ import {
import * as PeerForwarding from "../peer/PeerForwarding.ts";
import { assertNotLinked, assertSameLink, callerLinkOrigin } from "./linkOrigin.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
import { resolveInteractionMode, resolveRuntimeMode } from "./delegatedTaskModes.ts";
import {
resolveInteractionMode,
resolveRuntimeMode,
runtimeModeRank,
} from "./delegatedTaskModes.ts";
import {
assertFullAccess,
assertLiveCaller,
assertTargetWithinLimits,
type Caller,
isOwnDelegatedChild,
loadCaller,
readCaller,
unavailable,
Expand Down Expand Up @@ -197,25 +202,50 @@ 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>,
options?: {
/**
* Whether the caller may act on its own delegated children whose runtime
* mode is above its own, at that mode, as moving one to another account
* and resuming it need. Interaction modes stay within the caller's.
*/
readonly ownChildren?: "at-their-runtime-mode";
},
) =>
declare(
(params: P) =>
Effect.gen(function* () {
const caller = yield* writingCaller;
let runtimeMode = caller.limits.runtimeMode;
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 ownChild =
options?.ownChildren === "at-their-runtime-mode" &&
runtimeModeRank(target.runtimeMode) > runtimeModeRank(caller.limits.runtimeMode) &&
(yield* isOwnDelegatedChild(caller.threads, caller.caller?.id, target));
if (ownChild && runtimeModeRank(target.runtimeMode) > runtimeModeRank(runtimeMode))
runtimeMode = target.runtimeMode;
yield* assertTargetWithinLimits(
ownChild ? { ...caller.limits, runtimeMode } : caller.limits,
target,
);
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, {
...caller.limits,
runtimeMode,
...(runtimeMode === caller.limits.runtimeMode
? {}
: { ownChildRuntimeMode: runtimeMode }),
refused,
}),
Effect.catch((error) =>
Effect.flatMap(Ref.get(refused), (refusal) =>
Effect.fail<E | OrchestratorMcpFailure>(
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3192,8 +3192,18 @@ const make = Effect.gen(function* () {
} satisfies OrchestratorMcpThreadReadResult;
}),
sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) {
const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]);
const {
parent,
limits: callerLimits,
target,
} = yield* loadScopedThread(scope, input.threadId, ["runs"]);
yield* assertLiveCallerForOtherThread(scope, parent, target);
// The declaration lifts only the runtime limit, and only to its own child's preflight mode.
const ownChildRuntimeMode = (yield* DispatchModeLimit)?.ownChildRuntimeMode;
const limits =
ownChildRuntimeMode === undefined
? callerLimits
: { ...callerLimits, runtimeMode: ownChildRuntimeMode };
yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);

Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/mcp/threadAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,24 @@ export const assertTargetWithinLimits = (
Effect.asVoid,
);

/** A thread the calling thread itself delegated here, as `delegate_task` records it on the caller. */
export const isOwnDelegatedChild = Effect.fn("mcp.isOwnDelegatedChild")(function* (
threads: Pick<ThreadManagement.ThreadManagementService["Service"], "listAppOwnedChildThreadIds">,
callerThreadId: ThreadId | undefined,
target: Pick<OrchestrationV2ThreadShell, "id" | "lineage">,
) {
if (
callerThreadId === undefined ||
target.lineage.parentThreadId !== callerThreadId ||
target.lineage.relationshipToParent !== "subagent"
)
return false;
const children = yield* threads
.listAppOwnedChildThreadIds(callerThreadId)
.pipe(Effect.mapError(unavailable));
return children.includes(target.id);
});

/** 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 @@ -125,6 +125,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
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/attachment/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const AttachmentSendTool = Tool.make("t3_thread_send_attachments", {
...shared,
dependencies: [...shared.dependencies, DocumentText.DocumentText],
description:
"Send uploaded attachments as a new message to a thread. No retry key. Target modes cannot exceed yours. Success does not mean the provider read them or finished.",
"Send uploaded attachments to a thread within your modes, or your child at its runtime mode. No retry key. Success does not mean the provider read them or finished.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
message: Schema.optional(Schema.String.check(Schema.isMaxLength(120000))),
Expand Down
Loading
Loading