feat(mcp): defer thread organization safely#8683

closed10
agents/mcp-thread-state/organization
agents/mcp-thread-state/deferred
openedAug 29, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 4d ago

An active agent cannot safely settle or archive its own thread because ordinary organization commands correctly reject active work.

This dependent layer adds durable run-bound organization intents and t3_thread_defer_organization operations to schedule settlement or archival, inspect the current intent, and cancel it. The terminal path applies an intent only after its bound run finishes successfully and no newer, queued, active, pending-request, or title-regeneration work exists. Unsafe or stale intents clear without consuming an internal apply command receipt.

Stable receipts make retries deterministic. Projection rebuilds preserve intent state, terminal apply delegates to ordinary serialized settle/archive behavior, queued-run promotion survives typed apply failures, and startup recovery retries one transient typed plan failure while preserving interruption and isolating persistent failures.

Focused validation:

  • 76 focused tests across contracts, MCP registration/toolkit, runtime wiring, real deferred recovery, receipt replay, and settlement behavior
  • vp run --filter t3 typecheck
  • vp run --filter @t3tools/contracts typecheck
  • vp run --filter @t3tools/shared typecheck
  • Targeted vp lint, vp fmt --check, and git diff --check

Depends on #8676 for thread organization discovery and lifecycle mutations. Native stack: #8676 → #8683, rooted on t3code/codex-turn-mapping at 415ed0f73b97f1655b6282492f81d0b2bba3a9cc.

Implemented by GPT-5.6-Sol via Codex in T3 Code.

Note

Add t3_thread_defer_organization MCP tool and server-side apply recovery

  • Adds a new MCP tool that lets an agent schedule, read, or cancel a deferred settle/archive intent bound to its current run. The server applies the intent only after successful run completion, discarding it when newer, queued, approval-blocked, or unresolved-request work exists.
  • Introduces dispatchDeferredOrganization in Orchestrator.ts handling defer, cancel, and apply commands. Terminal run events now trigger locked deferred-organization application; startup recovery scans persisted thread shells and applies eligible intents whose bound run is missing, superseded, or terminal.
  • Adds contract schemas in orchestrationV2.ts and orchestratorMcp.ts for the deferred organization intent, commands, and MCP input/result. The server-only apply command is excluded from the client-dispatchable RPC schema via OrchestrationV2ClientCommand.
  • Wires recoverDeferredOrganization into the live orchestrator service and the server startup recovery phase in serverRuntimeStartup.ts, running it after provider runtime recovery succeeds.
  • Risk: thread mutations now accept a clearDeferredOrganization flag that nulls deferredOrganization on the committed thread payload; the emitted event uses the post-clear thread value, so any consumer reading the event payload for deferred state will see null after cleanup.

Macroscope summarized 6ff0395.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 167dccd2-887e-4e48-91bb-7812cf6d2956

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

Your free Security trial is over. An organization admin can activate Security or dismiss this notice.


Comment @coderabbitai help to get the list of available commands.

apps/server/src/orchestration-v2/Orchestrator.tsline 2111Outdated · 271e82bOpen thread
……@@ -1983,6 +1986,168 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2108+ });
2109+ return;
2110+ }
2111+ if (intent.action === "settle") {
MmacroscopeappBoton line 2111Aug 29, 2026

🟠 High orchestration-v2/Orchestrator.ts:2111

Deferred thread.organization.defer.apply leaves provider sessions and background work alive after applying settle or archive, and deferred archives retain MCP credentials. These branches emit terminal thread events directly instead of using the teardown logic in dispatchThreadMutation, so they omit provider-session.detached, provider-session.detach, revokeMcpCredential, and terminal.cleanup effects. Route deferred application through the same teardown path as ordinary archive/settle, or emit the equivalent events and effects.

✅ Resolved in d76789f408aaf8c8ee7ef2d2f801528f2f2565db

Fixed in acb2b17f3c. Deferred apply now delegates to the ordinary serialized thread settle/archive mutation with an atomic clear-intent option, inheriting provider detach, archive credential revocation, terminal cleanup, and delegated-completion disposal. Focused coverage opens a real managed provider session, drains the effect worker, verifies terminal effect settlement, and proves receipt replay enqueues no second cleanup.

apps/server/src/orchestration-v2/Orchestrator.tslines 7247–7268Outdated · 271e82bOpen thread
……@@ -7052,6 +7238,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7265+ yield* applyDeferredOrganization(thread.id, intent.runId);
7266+ }
7267+ }),
7268+ ),
MmacroscopeappBoton lines 7247–7268Aug 29, 2026

The new deferred-organization startup recovery only catches at the outer Effect.forEach, so a single thread whose projection load or dispatch fails aborts the whole pass and leaves later threads' intents unrecovered. Consider isolating the failure per entity, as the neighbouring subagent and delegated-completion recovery passes do (each wraps the per-thread withLock in its own Effect.catchCause before the outer catch).

suggestion
1 (thread) =>
2 threadDispatch
3 .withLock(
4 thread.id,
5 Effect.gen(function* () {
6 const projection = yield* projectionStore.getThreadProjection(thread.id);
7 const intent = projection.thread.deferredOrganization;
8 if (intent == null) return;
9 const boundRun = projection.runs.find((run) => run.id === intent.runId);
10 const hasNewerRun =
11 boundRun !== undefined &&
12 projection.runs.some((run) => run.ordinal > boundRun.ordinal);
13 if (
14 boundRun === undefined ||
15 hasNewerRun ||
16 ["completed", "failed", "cancelled", "interrupted", "rolled_back"].includes(
17 boundRun.status,
18 )
19 ) {
20 yield* applyDeferredOrganization(thread.id, intent.runId);
21 }
22 }),
23 )
24 .pipe(
25 Effect.catchCause((cause) =>
26 Effect.logWarning("Failed to recover deferred organization for thread", {
27 threadId: thread.id,
28 cause,
29 }),
30 ),
31 ),

A focused test that one persistently failing thread does not prevent a second thread's intent from being applied would lock this in.

Posted via Macroscope — Effect Service Conventions

Fixed in acb2b17f3c. Deferred recovery is now a typed post-provider-reconciliation startup hook, and each thread lock/projection/apply path catches independently. The persistent-failure integration corrupts one thread projection and verifies the next deferred intent is still recovered.

Synced from GitHub

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 6ff0395.

This comment will update automatically after the next completed run.

MmacroscopeappBotreviewed · 1 inline threadAug 29, 2026
Synced from GitHub

Effect service conventions review: one finding in apps/server/src/orchestration-v2/Orchestrator.ts (startup reconciliation isolation/retry). Everything else in the changed scope follows the surrounding service, layer, and import conventions.

Posted via Macroscope — Effect Service Conventions

apps/server/src/orchestration-v2/Orchestrator.tslines 7247–7274Outdated · 858c15aOpen thread
……@@ -7052,6 +7238,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7271+ ),
7272+ Effect.catchCause((cause) =>
7273+ Effect.logWarning("Failed to recover deferred thread organization", { cause }),
7274+ ),
MmacroscopeappBoton lines 7247–7274Aug 29, 2026

orchestration-v2/Orchestrator.ts:7274

This startup reconciliation repairs many independent threads, but a per-thread failure is not isolated: Effect.forEach fails fast (and interrupts the other in-flight fibers at concurrency: 8), so one bad thread aborts recovery for every remaining deferred intent, and the outer catchCause then reduces it — including interruption — to a single warning. The two neighbouring recovery loops below (app-owned subagents, delegated completion delivery) already wrap the per-thread effect in its own catchCause. Consider adding a transient retry plus per-thread isolation here as well, and covering both the retry-success and persistent-failure-continuation paths in DeferredOrganizationRecovery.integration.test.ts (it currently only asserts the stale-discard path).

diff
1 (thread) =>
2- threadDispatch.withLock(
3- thread.id,
4- Effect.gen(function* () {
5- // …existing intent inspection…
6- }),
7- ),
8+ threadDispatch
9+ .withLock(
10+ thread.id,
11+ Effect.gen(function* () {
12+ // …existing intent inspection…
13+ }),
14+ )
15+ .pipe(
16+ Effect.retry({ times: 2 }),
17+ Effect.catchCause((cause) =>
18+ Effect.logWarning("Failed to recover deferred thread organization", {
19+ threadId: thread.id,
20+ cause,
21+ }),
22+ ),
23+ ),

No longer relevant as of acb2b17f3cc66a207d81763e9e70c7b2926f7978

Fixed in acb2b17f3c with per-thread isolation and focused persistent-failure continuation coverage. I did not add a broad retry: a durable unreadable projection is not made recoverable by immediate retries, while independent intents can continue safely.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This change adds a durable MCP-driven workflow that can automatically settle or archive threads after run completion, with startup recovery and provider-session/cleanup side effects. The orchestration and lifecycle changes span multiple production layers and are broader than a small self-contained addition.

You can add or adjust custom eligibility rules. Learn more.

MmacroscopeappBotreviewed · 1 inline threadAug 30, 2026
Synced from GitHub

One finding on the new deferred-organization startup recovery loop in apps/server/src/orchestration-v2/Orchestrator.ts. Per-thread isolation is now in place; the remaining gap is interruption handling and transient retry before readiness.

Posted via Macroscope — Effect Service Conventions

apps/server/src/orchestration-v2/Orchestrator.tslines 7274–7281Outdated · 4bf9020Open thread
……@@ -7069,6 +7242,54 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7278+ cause,
7279+ }),
7280+ ),
7281+ ),
MmacroscopeappBoton lines 7274–7281Aug 30, 2026

orchestration-v2/Orchestrator.ts:7281

Per-thread isolation is fixed now, but this reconciliation still reduces interruption to a warning and never retries a transient repair before readiness. Effect.catchCause catches interrupt causes too, so a shutdown mid-recovery keeps sweeping the remaining threads instead of unwinding; and a one-off projection/dispatch failure permanently skips that thread's intent until the next restart. Consider retrying briefly, then re-failing interrupt-only causes before falling back to the warning, and covering the retry-success path next to the existing persistent-failure continuation test.

diff
1 .pipe(
2+ Effect.retry({ times: 2, schedule: Schedule.spaced("50 millis") }),
3 Effect.catchCause((cause) =>
4- Effect.logWarning("Failed to recover deferred thread organization", {
5- threadId: thread.id,
6- cause,
7- }),
8+ Cause.hasInterruptsOnly(cause)
9+ ? Effect.failCause(cause)
10+ : Effect.logWarning("Failed to recover deferred thread organization", {
11+ threadId: thread.id,
12+ cause,
13+ }),
14 ),
15 ),

Needs import * as Cause from "effect/Cause" and import * as Schedule from "effect/Schedule".

No longer relevant as of afd33efd2d8cd684b11cc34b7c8edcee3e80e083

Fixed in afd33efd2d8cd684b11cc34b7c8edcee3e80e083. The recovery wrapper retries only ProjectionStoreReadError and OrchestratorDispatchError once, immediately. It does not retry rejected receipts, defects, or other causes. Cause.hasInterruptsOnly now propagates so shutdown unwinds; persistent non-interrupt failures keep the prior warning-and-continue behavior. The focused test proves exactly two attempts for one transient typed read failure and separately observes an interrupt-only exit, without timers or polling.

apps/server/src/orchestration-v2/Orchestrator.tsline 107Outdated · a887160Open thread
……@@ -92,6 +94,29 @@ export class OrchestratorProjectionError extends Schema.TaggedErrorClass<Orchest
104+ repair.pipe(
105+ Effect.retry({
106+ times: 1,
107+ while: (error) => isProjectionStoreReadError(error) || isOrchestratorDispatchError(error),
MmacroscopeappBoton line 107Aug 30, 2026

🟡 Medium orchestration-v2/Orchestrator.ts:107

A transient projection read failure during deferred apply is recorded as a rejected deterministic command, and later recovery replays that rejection instead of applying or clearing the persisted deferred intent. dispatchDeferredOrganization maps the read failure to OrchestratorProjectionError, which is excluded from the while predicate; include that error type in the retry condition so the apply is retried before its receipt is permanently rejected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration-v2/Orchestrator.ts around line 107:
2
3A transient projection read failure during deferred apply is recorded as a rejected deterministic command, and later recovery replays that rejection instead of applying or clearing the persisted deferred intent. `dispatchDeferredOrganization` maps the read failure to `OrchestratorProjectionError`, which is excluded from the `while` predicate; include that error type in the retry condition so the apply is retried before its receipt is permanently rejected.

Fixed in 284b9dca6933303a56fe7ce10748847444cfe2e7 at the earlier boundary. thread.organization.defer.apply retries one typed projection failure around the pure plan, before commitRejectedCommand. The real receipt-store test fails that planner read once, then proves the deterministic receipt is accepted and the intent clears.

docs/user/thread-sidebar.mdlines 30–31Outdated · a887160Open thread
……@@ -25,6 +25,11 @@ Agents connected through T3 Code can organize threads too. They can pin, snooze,
28+An agent can also ask T3 Code to settle or archive its own thread after the current run finishes.
29+The request is saved by the server, but it is applied only when that run completes successfully
30+and no newer, queued, or approval-blocked work remains. A new message or unresolved request keeps
31+the thread visible and discards the stale organization request.
MmacroscopeappBoton lines 30–31Aug 30, 2026

🟡 Medium user/thread-sidebar.md:30

A message sent while the provider supports active steering does not keep the thread visible: the client selects steer_active, dispatchSteerIntoRun appends it to the current run, and dispatchDeferredOrganization later settles or archives that same thread because no newer run exists. The documentation should limit this behavior to messages that create a newer run.

suggestion
1A new message that creates a newer run, or an unresolved request, keeps
2the thread visible and discards the stale organization request.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @docs/user/thread-sidebar.md around lines 30-31:
2
3A message sent while the provider supports active steering does not keep the thread visible: the client selects `steer_active`, `dispatchSteerIntoRun` appends it to the current run, and `dispatchDeferredOrganization` later settles or archives that same thread because no newer run exists. The documentation should limit this behavior to messages that create a newer run.

Fixed in 284b9dca6933303a56fe7ce10748847444cfe2e7. The documentation now limits invalidation to a message that creates a newer run; steer_active remains part of the current run.

packages/contracts/src/orchestrationV2.tsline 2172Outdated · cf20d21Open thread
……@@ -2131,6 +2156,24 @@ export const OrchestrationV2Command = Schema.Union([
2169+ threadId: ThreadId,
2170+ }),
2171+ Schema.Struct({
2172+ type: Schema.Literal("thread.organization.defer.apply"),
MmacroscopeappBoton line 2172Sep 5, 2026

🟡 Medium src/orchestrationV2.ts:2172

Any connected client can submit thread.organization.defer.apply and prematurely clear deferredOrganization while the bound run is still active, cancelling a pending settle/archive request before its terminal path. Because this variant is part of the public OrchestrationV2Command union, dispatchCommand forwards it to dispatchDeferredOrganization, which reaches !canApply; keep this command in a server-internal schema rather than the client-facing union.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/contracts/src/orchestrationV2.ts around line 2172:
2
3Any connected client can submit `thread.organization.defer.apply` and prematurely clear `deferredOrganization` while the bound run is still active, cancelling a pending settle/archive request before its terminal path. Because this variant is part of the public `OrchestrationV2Command` union, `dispatchCommand` forwards it to `dispatchDeferredOrganization`, which reaches `!canApply`; keep this command in a server-internal schema rather than the client-facing union.

Fixed in 71650ee5adf1ac035cd4f30d607ea5bf77bbba21. The public orchestration.dispatchCommand schema rejects only thread.organization.defer.apply; the full internal command schema and server dispatch path still accept it, and defer.cancel remains public. Contract coverage decodes those three boundaries explicitly.

CcursorBotreviewed · 1 inline threadSep 5, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit cf20d21001108280d379a5db99b02d5c6d87d704. Configure here.

apps/server/src/orchestration-v2/Orchestrator.tsline 7422Outdated · cf20d21Open thread
……@@ -7212,6 +7416,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7419+ yield* threadDispatch.withLock(
7420+ threadId,
7421+ applyDeferredOrganization(threadId, stored.event.payload.id),
7422+ );
CcursorBoton line 7422Sep 5, 2026

Apply failure skips queued run promotion

Medium Severity

applyDeferredOrganization now runs on the same terminal-run fiber as startNextQueuedRun. A failed projection read or apply dispatch fails that fiber, so the shared handler only logs and never promotes the next queued run. Follow-up work on that thread then sits queued until a later restart.

Reviewed by Cursor Bugbot for commit cf20d21001108280d379a5db99b02d5c6d87d704. Configure here.

Fixed in 71650ee5adf1ac035cd4f30d607ea5bf77bbba21. Terminal handling now isolates a typed deferred-apply failure before startNextQueuedRun, while interrupt-only causes still propagate. The real SQLite/EventSink/ProjectionStore test fails the apply read, observes the queued run become starting, and confirms the stable apply receipt ID was not consumed.

Added 10 commits

  • fix(orchestration): harden deferred organization recovery097b413
  • test(mcp): cover deferred organization recovery31a279b
  • test(orchestration): provide replay recovery dependencies6328969
  • fix(orchestration): clean up deferred terminal state72ca280
  • fix(orchestration): preserve recovery interruption7624440
  • feat(mcp): defer thread organization safely859a559
  • fix(orchestration): retry deferred projection reads923a2d1
  • fix(orchestration): preserve v2 thread deletion planning04bbc33
  • fix(orchestration): retry deferred apply reads before receipts4b24547
  • fix(orchestration): protect deferred organization lifecycle6ff0395
Synced from GitHub

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Superseded as the active MCP proposal by #10554 for immediate organization only in the rebuilt MCP stack, following the maintainer request to review thin service wrappers separately from service hardening.

This is not a full feature-parity replacement. Durable deferred organization is parked as separate service/feature work. It is not included in the thin MCP wrapper.

Closing this mixed proposal without merging. The remote branch agents/mcp-thread-state/deferred and exact head 6ff0395a3cf73f8b756c352b492b3d5a205c0959 are retained so the service changes can be extracted for separate review later.