From 7683b214699e89b32c7fc37e8fd969140e781d54 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 19:55:18 +0000 Subject: [PATCH 1/4] feat(server): agents can turn a thread's auto-settle off and on over MCP After a thread's PR merges, an orchestrating agent that keeps waking it for follow-up work sees it auto-settle again at the end of every run, and `unsettle` does not hold. The merge rule intentionally ignores agent-sent messages, so the fix is to let agents use the existing per-thread switch. `t3_thread_organize` gains `disable_auto_settle` and `enable_auto_settle`, which dispatch the existing `thread.auto-settle.set` command under the same write checks as the other organize actions. Fixes #26097 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/core.test.ts | 82 +++++++++++++++++++ .../src/mcp/toolkits/thread/handlers.ts | 8 ++ apps/server/src/mcp/toolkits/thread/tools.ts | 4 +- docs/user/thread-sidebar.md | 3 +- evals/mcp-tool-use/cases/orchestration.ndjson | 2 + 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 2e2bb4e7c76c0..6400bba90e2b9 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -10,6 +10,7 @@ import { NodeId, OrchestratorMcpThreadReadResult, ProviderDriverKind, + ProjectId, ProviderInstanceId, RunId, RuntimeRequestId, @@ -35,6 +36,10 @@ import { } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; +import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; +import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; +import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; @@ -621,6 +626,83 @@ it.effect("a read-only client reads threads and is refused every write before it ); const dispatched: Array = []; +const layerPersistedThreads = (() => { + const layerDatabase = SqlitePersistence.layerMemory; + const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( + { name: "mcp-core-auto-settle" }, + ProviderAdapterRegistry.layerFromAdapters([]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ); + return Layer.merge( + layerOrchestrator, + ThreadManagement.layer.pipe( + Layer.provide(layerOrchestrator), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ), + ); +})(); + +it.effect("an agent turns auto-settle off and back on for only the thread it targets", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threads = yield* ThreadManagement.ThreadManagementService; + const targetId = ThreadId.make("auto-settle-target"); + const siblingId = ThreadId.make("auto-settle-sibling"); + for (const id of [targetId, siblingId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`create-${id}`), + threadId: id, + projectId: ProjectId.make("project:mcp-core"), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + } + const organize = ( + action: string, + invocation: McpInvocationContext.McpInvocationScope = clientScope("full-access"), + ) => + server + .callTool({ name: "t3_thread_organize", arguments: { action, threadId: targetId } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const autoSettleDisabled = (id: ThreadId) => + threads.getThreadShell(id).pipe(Effect.map((shell) => shell?.autoSettleDisabledAt != null)); + + const disabled = yield* organize("disable_auto_settle"); + expect(disabled.isError).toBe(false); + expect(yield* autoSettleDisabled(targetId)).toBe(true); + expect(yield* autoSettleDisabled(siblingId)).toBe(false); + expect((yield* threads.getThreadShell(targetId))?.settledOverride).toBeNull(); + + const readOnly = yield* organize("enable_auto_settle", clientScope("read-only")); + expect(declaredFailure(readOnly)).toMatchObject({ code: "capability_denied" }); + expect(yield* autoSettleDisabled(targetId)).toBe(true); + + const enabled = yield* organize("enable_auto_settle"); + expect(enabled.isError).toBe(false); + expect(yield* autoSettleDisabled(targetId)).toBe(false); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provideMerge(layerPersistedThreads), + ), + ), + ), +); + it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index b1d34c91be8af..93e3484a0d662 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -354,6 +354,14 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { case "mark_unread": command = { ...common, type: "thread.mark-unread" }; break; + case "disable_auto_settle": + case "enable_auto_settle": + command = { + ...common, + type: "thread.auto-settle.set", + enabled: input.action === "enable_auto_settle", + }; + break; default: command = { ...common, type: `thread.${input.action}` }; } diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 018acac5c715f..6a9d143c274a7 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -31,7 +31,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts"; const ThreadOrganizeTool = Tool.make("t3_thread_organize", { description: - "Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active.", + "Pin, snooze, settle, archive, or mark a thread unread. Omit `threadId` for this thread. `snooze` needs `snoozedUntil`. Settling this thread applies at turn end. A failed turn or queued message leaves it active. `disable_auto_settle` stops the thread settling itself, such as after its PR merges, until `enable_auto_settle`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId), @@ -45,6 +45,8 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", { "archive", "unarchive", "mark_unread", + "disable_auto_settle", + "enable_auto_settle", ]), snoozedUntil: Schema.optional(IsoDateTime), }), diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index a90dabf06d8c8..60c0276b0f645 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -191,7 +191,8 @@ finished background work or a pull request watch starts on its own does not. To keep one thread out of the settled shelf no matter how long it sits idle, open its menu, choose **Auto-settle behavior**, and pick **Disabled**. The current option is checked. Pick **Enabled** to return to the usual rules. Manual settle, snooze, and archive still work while it -is disabled. +is disabled. An agent can turn this off and on too, for example to keep a thread whose pull request +merged open while it finishes follow-up work. Change these rules in **Settings → Threads**. They continue to run when your apps are closed. On web and desktop, choose an environment at the diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index dc79eb774df7e..e7996848f2c46 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -34,3 +34,5 @@ {"id": "two-children-same-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for two Sol xhigh subagents, an Astra parent started one and refused the second, citing 'One child per named model' (#24967).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}}, "prompt": "Launch two Sol xhigh subagents. Give each a trivial task that only replies OK.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "target.options.reasoningEffort": "xhigh"}}], "minCalls": {"delegate_task": 2}, "callsMustNot": [{"tool": "delegate_task", "args": {"target.model": "~muse"}}, {"tool": "delegate_task", "args": {"target.model": "~claude"}}]}, "notes": "From #24967. One child plus the parent doing the work fails, and so does a second model in place of Sol."} {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} +{"id": "merged-thread-followup-disables-auto-settle", "area": "orchestration", "kind": "bad", "pattern": "t3_thread_organize unsettle used to hold a thread open for post-merge work", "experience": "After a thread's PR merged, an orchestrating agent kept waking it for production checks. Each run ended with the thread auto-settled again, and unsettle did not hold, so it settled 5 times in a day.", "rule": "t3_thread_organize: '`disable_auto_settle` stops the thread settling itself, such as after its PR merges, until `enable_auto_settle`.'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Start a new thread to fix the CSV export timeout on large workspaces and have it open a PR."}, {"role": "assistant", "text": "Started [Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555). It opened https://github.com/acme/app/pull/418.", "toolCalls": [{"tool": "t3_thread_launch", "input": {"title": "Fix CSV export timeout", "message": "The CSV export times out on workspaces with more than 50k rows. Fix it and open a PR."}, "result": {"threadId": "55555555-5555-4555-8555-555555555555", "projectId": "22222222-2222-4222-8222-222222222222", "status": "starting", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)"}}]}], "mocks": {"t3_thread_send": {"threadId": "55555555-5555-4555-8555-555555555555", "messageId": "msg-9", "runId": "run-9", "queued": false}, "t3_thread_organize": {"sequence": 57}, "t3_thread_read": {"thread": {"threadId": "55555555-5555-4555-8555-555555555555", "title": "Fix CSV export timeout", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)", "status": "idle"}, "items": [{"position": 30, "createdBy": "assistant", "text": "PR https://github.com/acme/app/pull/418 merged. The export now streams rows in pages of 5,000."}], "nextPosition": 31, "hasMore": false}}, "shellMocks": [{"match": ".*", "output": ""}]}, "prompt": "418 merged. That thread still has to check the export in production after tonight's deploy, but it keeps settling itself every time one of its runs ends. Tell it to do the production check, and keep it from settling until that's done.", "expect": {"calls": [{"tool": "t3_thread_organize", "args": {"threadId": "55555555-5555-4555-8555-555555555555", "action": "disable_auto_settle"}}, {"tool": "t3_thread_send", "args": {"threadId": "55555555-5555-4555-8555-555555555555"}}]}, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/26097"}, "notes": "Before the fix, t3_thread_organize had no per-thread auto-settle switch, so the agent could only unsettle, which the next run's activity overrides."} +{"id": "control-merged-thread-followup-leaves-auto-settle", "area": "orchestration", "kind": "control", "pattern": "plain follow-up to a merged thread", "experience": "Ordinary follow-up to a thread whose PR merged. Nothing asks to keep it open, so the agent should only send the message.", "rule": "Keeps disable_auto_settle from becoming a reflex on every post-merge message.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Start a new thread to fix the CSV export timeout on large workspaces and have it open a PR."}, {"role": "assistant", "text": "Started [Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555). It opened https://github.com/acme/app/pull/418.", "toolCalls": [{"tool": "t3_thread_launch", "input": {"title": "Fix CSV export timeout", "message": "The CSV export times out on workspaces with more than 50k rows. Fix it and open a PR."}, "result": {"threadId": "55555555-5555-4555-8555-555555555555", "projectId": "22222222-2222-4222-8222-222222222222", "status": "starting", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)"}}]}], "mocks": {"t3_thread_send": {"threadId": "55555555-5555-4555-8555-555555555555", "messageId": "msg-9", "runId": "run-9", "queued": false}, "t3_thread_organize": {"sequence": 57}, "t3_thread_read": {"thread": {"threadId": "55555555-5555-4555-8555-555555555555", "title": "Fix CSV export timeout", "link": "[Fix CSV export timeout](t3-thread://v1/55555555-5555-4555-8555-555555555555)", "status": "idle"}, "items": [{"position": 30, "createdBy": "assistant", "text": "PR https://github.com/acme/app/pull/418 merged. The export now streams rows in pages of 5,000."}], "nextPosition": 31, "hasMore": false}}, "shellMocks": [{"match": ".*", "output": ""}]}, "prompt": "418 merged. Ask that thread to delete its feature branch.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "55555555-5555-4555-8555-555555555555"}}], "callsMustNot": [{"tool": "t3_thread_organize", "args": {"action": "disable_auto_settle"}}]}, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/26097"}} From 4d1ef4789a1fd82559a95e23000b5054e776cf7c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 20:12:28 +0000 Subject: [PATCH 2/4] fix(server): record the MCP auto-settle context size Update the measured MCP tool-list budget for the two auto-settle actions and their description. The intentional 156-byte increase uses the agent-context-increase workflow label. Co-authored-by: GPT-6.1 Sol --- apps/server/src/provider/agentContextBudget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 07830f90d0567..5cfb2f632a49d 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5129, "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46109 + "mcpToolListJsonBytes": 46265 } From f13436a14e7da9c7ea3d9b3966363ae282685dac Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Tue, 27 Oct 2026 20:25:19 +0000 Subject: [PATCH 3/4] fix(server): honor auto-settle opt-out during an in-flight sweep Check the live auto-settle flag before applying a sweep snapshot, including when disabling and snapshotting happen in the same millisecond. Cover rejection, re-enabling, and preservation of a manual override through MCP toggles. Co-authored-by: GPT-6.1 Sol --- apps/server/src/mcp/toolkits/core.test.ts | 11 +++- .../Orchestrator.control-reads.test.ts | 55 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 1 + 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 6400bba90e2b9..36c9fe660b4e5 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -666,6 +666,14 @@ it.effect("an agent turns auto-settle off and back on for only the thread it tar creationSource: "web", }); } + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("manual-auto-settle-override"), + threadId: targetId, + reason: "user", + }); + const manualOverride = (yield* threads.getThreadShell(targetId))?.settledOverride; + expect(manualOverride).toBe("active"); const organize = ( action: string, invocation: McpInvocationContext.McpInvocationScope = clientScope("full-access"), @@ -683,7 +691,7 @@ it.effect("an agent turns auto-settle off and back on for only the thread it tar expect(disabled.isError).toBe(false); expect(yield* autoSettleDisabled(targetId)).toBe(true); expect(yield* autoSettleDisabled(siblingId)).toBe(false); - expect((yield* threads.getThreadShell(targetId))?.settledOverride).toBeNull(); + expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride); const readOnly = yield* organize("enable_auto_settle", clientScope("read-only")); expect(declaredFailure(readOnly)).toMatchObject({ code: "capability_denied" }); @@ -692,6 +700,7 @@ it.effect("an agent turns auto-settle off and back on for only the thread it tar const enabled = yield* organize("enable_auto_settle"); expect(enabled.isError).toBe(false); expect(yield* autoSettleDisabled(targetId)).toBe(false); + expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride); }).pipe( Effect.provide( layerThreadToolkit.pipe( diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 61515a053ac36..4c5af3aa50d29 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -62,6 +62,61 @@ const layerTest = Layer.mergeAll( ), ); +it.effect("a same-millisecond auto-settle disable rejects an in-flight settlement", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:auto-settle-disable-race"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-auto-settle-disable-race"), + threadId, + projectId: ProjectId.make("project:auto-settle-disable-race"), + title: "Follow-up work", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const snapshotAt = (yield* projections.getThread(threadId)).updatedAt; + yield* orchestrator.dispatch({ + type: "thread.auto-settle.set", + commandId: CommandId.make("disable-auto-settle-race"), + threadId, + enabled: false, + }); + const disabled = yield* projections.getThread(threadId); + assert.equal(DateTime.toEpochMillis(disabled.updatedAt), DateTime.toEpochMillis(snapshotAt)); + const settlement = yield* Effect.exit( + orchestrator.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("in-flight-auto-settle-race"), + threadId, + snapshotAt, + }), + ); + assert.equal(settlement._tag, "Failure"); + assert.equal((yield* projections.getThread(threadId)).settledOverride, null); + + yield* orchestrator.dispatch({ + type: "thread.auto-settle.set", + commandId: CommandId.make("enable-auto-settle-race"), + threadId, + enabled: true, + }); + yield* orchestrator.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make("enabled-auto-settle-race"), + threadId, + snapshotAt, + }); + assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled"); + }).pipe(Effect.provide(layerTest)), +); + it.effect( "records approval feedback and targets only its runtime request without dispatching a parent turn", () => diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0a5e56d98d639..230d4ce7aacfc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -12873,6 +12873,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if ( thread.settledOverride !== null || + thread.autoSettleDisabledAt != null || DateTime.toEpochMillis(thread.updatedAt) > DateTime.toEpochMillis(command.snapshotAt) ) { return yield* new OrchestratorDispatchError({ From 55e0c5ba3f2fa60687646933c40954292fe80357 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Sat, 31 Oct 2026 00:08:22 +0000 Subject: [PATCH 4/4] test(server): trim auto-settle MCP tests to what fails on main Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/core.test.ts | 107 +++++------------- .../Orchestrator.control-reads.test.ts | 14 --- 2 files changed, 28 insertions(+), 93 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 36c9fe660b4e5..9d924dfaac56d 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -10,7 +10,6 @@ import { NodeId, OrchestratorMcpThreadReadResult, ProviderDriverKind, - ProjectId, ProviderInstanceId, RunId, RuntimeRequestId, @@ -36,10 +35,6 @@ import { } from "../../orchestration-v2/Orchestrator.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as CommandReceiptStore from "../../orchestration-v2/CommandReceiptStore.ts"; -import * as Orchestrator from "../../orchestration-v2/Orchestrator.ts"; -import * as ProviderReplayHarness from "../../orchestration-v2/testkit/ProviderReplayHarness.ts"; -import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import * as ServerConfig from "../../config.ts"; import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapterRegistry.ts"; import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; @@ -626,91 +621,45 @@ it.effect("a read-only client reads threads and is refused every write before it ); const dispatched: Array = []; -const layerPersistedThreads = (() => { - const layerDatabase = SqlitePersistence.layerMemory; - const layerOrchestrator = ProviderReplayHarness.layerWithRegistry( - { name: "mcp-core-auto-settle" }, - ProviderAdapterRegistry.layerFromAdapters([]), - { databaseLayer: layerDatabase, runEffectWorker: false }, - ); - return Layer.merge( - layerOrchestrator, - ThreadManagement.layer.pipe( - Layer.provide(layerOrchestrator), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), - ), - ); -})(); - -it.effect("an agent turns auto-settle off and back on for only the thread it targets", () => - Effect.gen(function* () { +it.effect("an agent turns a thread's auto-settle off and back on", () => { + const commands: Array = []; + return Effect.gen(function* () { const server = yield* McpServer.McpServer; - const orchestrator = yield* Orchestrator.OrchestratorV2; - const threads = yield* ThreadManagement.ThreadManagementService; - const targetId = ThreadId.make("auto-settle-target"); - const siblingId = ThreadId.make("auto-settle-sibling"); - for (const id of [targetId, siblingId]) { - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make(`create-${id}`), - threadId: id, - projectId: ProjectId.make("project:mcp-core"), - title: id, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdBy: "user", - creationSource: "web", - }); - } - yield* orchestrator.dispatch({ - type: "thread.unsettle", - commandId: CommandId.make("manual-auto-settle-override"), - threadId: targetId, - reason: "user", - }); - const manualOverride = (yield* threads.getThreadShell(targetId))?.settledOverride; - expect(manualOverride).toBe("active"); - const organize = ( - action: string, - invocation: McpInvocationContext.McpInvocationScope = clientScope("full-access"), - ) => - server - .callTool({ name: "t3_thread_organize", arguments: { action, threadId: targetId } }) + for (const action of ["disable_auto_settle", "enable_auto_settle"]) { + const result = yield* server + .callTool({ name: "t3_thread_organize", arguments: { action } }) .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), Effect.provideService(McpSchema.McpServerClient, client), ); - const autoSettleDisabled = (id: ThreadId) => - threads.getThreadShell(id).pipe(Effect.map((shell) => shell?.autoSettleDisabledAt != null)); - - const disabled = yield* organize("disable_auto_settle"); - expect(disabled.isError).toBe(false); - expect(yield* autoSettleDisabled(targetId)).toBe(true); - expect(yield* autoSettleDisabled(siblingId)).toBe(false); - expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride); - - const readOnly = yield* organize("enable_auto_settle", clientScope("read-only")); - expect(declaredFailure(readOnly)).toMatchObject({ code: "capability_denied" }); - expect(yield* autoSettleDisabled(targetId)).toBe(true); - - const enabled = yield* organize("enable_auto_settle"); - expect(enabled.isError).toBe(false); - expect(yield* autoSettleDisabled(targetId)).toBe(false); - expect((yield* threads.getThreadShell(targetId))?.settledOverride).toEqual(manualOverride); + expect(result.isError).toBe(false); + } + expect(commands).toMatchObject([ + { type: "thread.auto-settle.set", threadId, enabled: false }, + { type: "thread.auto-settle.set", threadId, enabled: true }, + ]); }).pipe( Effect.provide( layerThreadToolkit.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provide(NodeCrypto.layer), - Layer.provideMerge(layerPersistedThreads), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: ({ threadId: id }) => + Effect.succeed( + McpToolAccessTestkit.idleThreadProjection(McpToolAccessTestkit.liveThreadShell(id)), + ), + dispatch: (command) => { + commands.push(command); + return Effect.succeed({ sequence: 1 } as never); + }, + }), + ), ), ), - ), -); + ); +}); it.effect("refuses act-as-caller tools to a client caller", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 4c5af3aa50d29..78db2d1bddf04 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -100,20 +100,6 @@ it.effect("a same-millisecond auto-settle disable rejects an in-flight settlemen ); assert.equal(settlement._tag, "Failure"); assert.equal((yield* projections.getThread(threadId)).settledOverride, null); - - yield* orchestrator.dispatch({ - type: "thread.auto-settle.set", - commandId: CommandId.make("enable-auto-settle-race"), - threadId, - enabled: true, - }); - yield* orchestrator.dispatch({ - type: "thread.auto-settle", - commandId: CommandId.make("enabled-auto-settle-race"), - threadId, - snapshotAt, - }); - assert.equal((yield* projections.getThread(threadId)).settledOverride, "settled"); }).pipe(Effect.provide(layerTest)), );