Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
580 changes: 576 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Large diffs are not rendered by default.

248 changes: 234 additions & 14 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/ProviderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,12 @@ export interface ProviderAdapterV2SessionRuntime {
* Adapter runtimes may omit this and expose only their single-consumer event stream.
*/
readonly subscribeEvents?: Effect.Effect<ProviderAdapterV2EventSubscription>;
/**
* Adapters whose native runtime can hold pending work outside an active
* turn (for example Claude background tasks and their wake turns) report it
* here so the session manager defers idle release while it is pending.
*/
readonly hasPendingBackgroundWork?: Effect.Effect<boolean>;
readonly ensureThread: (
input: ProviderAdapterV2EnsureThreadInput,
) => Effect.Effect<OrchestrationV2ProviderThread, ProviderAdapterV2Error>;
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/orchestration-v2/ProviderContinuationRequests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ProviderDriverKind, ProviderThreadId, ThreadId } from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Queue from "effect/Queue";

export interface ProviderContinuationRequest {
readonly threadId: ThreadId;
readonly providerThreadId: ProviderThreadId;
readonly driver: ProviderDriverKind;
readonly detail: string | null;
}

/**
* Adapters offer a continuation request when provider-native work completes
* outside an active turn (for example a Claude background task wake turn) so
* the orchestrator can start a run that ingests it. The default reference
* drops requests, keeping adapter construction dependency-free in tests; the
* live layer must be shared with the ProviderContinuationService worker that
* drains it.
*/
export class ProviderContinuationRequests extends Context.Reference<{
readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>;
readonly take: Effect.Effect<ProviderContinuationRequest>;
}>("t3/orchestration-v2/ProviderContinuationRequests", {
defaultValue: () => ({ offer: () => Effect.void, take: Effect.never }),
}) {}

export const layer = Layer.effect(
ProviderContinuationRequests,
Effect.gen(function* () {
const queue = yield* Queue.unbounded<ProviderContinuationRequest>();
return {
offer: (request: ProviderContinuationRequest) =>
Queue.offer(queue, request).pipe(Effect.asVoid),
take: Queue.take(queue),
};
}),
);
72 changes: 72 additions & 0 deletions apps/server/src/orchestration-v2/ProviderContinuationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { CommandId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { IdAllocatorV2 } from "./IdAllocator.ts";
import {
type ProviderContinuationRequest,
ProviderContinuationRequests,
} from "./ProviderContinuationRequests.ts";
import { ThreadManagementService } from "./ThreadManagementService.ts";

const CONTINUATION_MESSAGE_TEXT = "Background task completed.";

/**
* Drains ProviderContinuationRequests and dispatches an internal
* message.dispatch per request so the wake turn buffered by the adapter is
* ingested as a normal run. Dispatches queue_after_active, so a continuation
* racing a user run simply queues behind it and drains the wake buffer once
* that run finishes.
*/
export const workerLive = Layer.effectDiscard(
Effect.gen(function* () {
const ids = yield* IdAllocatorV2;
const requests = yield* ProviderContinuationRequests;
const threads = yield* ThreadManagementService;

const dispatchContinuation = Effect.fn("ProviderContinuationService.dispatchContinuation")(
function* (request: ProviderContinuationRequest) {
const projection = yield* threads.getThreadProjection(request.threadId);
if (projection.thread.archivedAt !== null) {
yield* Effect.logInfo("orchestration-v2.provider-continuation.thread-archived", {
threadId: request.threadId,
providerThreadId: request.providerThreadId,
});
return;
}
const messageId = yield* ids.allocate.message({
threadId: request.threadId,
ordinal: projection.messages.length + 1,
});
const commandId = CommandId.make(`provider-continuation:${messageId}`);
yield* threads.dispatch({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration-v2/ProviderContinuationService.ts:42

dispatchContinuation discards the providerThreadId and driver from the ProviderContinuationRequest, dispatching a generic message.dispatch against the thread's current routing. If the thread's active provider changes before the worker drains the request (e.g. after a provider switch or handoff), the synthetic wake turn starts on the wrong provider thread while the buffered provider-native wake messages remain attached to the original provider thread and are never ingested. Consider passing request.providerThreadId and request.driver through to threads.dispatch so the continuation targets the correct provider.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ProviderContinuationService.ts around line 42:

`dispatchContinuation` discards the `providerThreadId` and `driver` from the `ProviderContinuationRequest`, dispatching a generic `message.dispatch` against the thread's current routing. If the thread's active provider changes before the worker drains the request (e.g. after a provider switch or handoff), the synthetic wake turn starts on the wrong provider thread while the buffered provider-native wake messages remain attached to the original provider thread and are never ingested. Consider passing `request.providerThreadId` and `request.driver` through to `threads.dispatch` so the continuation targets the correct provider.

type: "message.dispatch",
commandId,
threadId: request.threadId,
messageId,
text: request.detail ?? CONTINUATION_MESSAGE_TEXT,
attachments: [],
dispatchMode: { type: "queue_after_active" },
createdBy: "agent",
creationSource: "provider",
});
},
);

yield* requests.take.pipe(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration-v2/ProviderContinuationService.ts:56

The worker loop permanently drops a continuation request whenever dispatchContinuation fails. requests.take removes the item from the queue, but the Effect.catchCause handler only logs the failure and never retries or re-enqueues it — so the buffered provider wake event is lost and never ingested as a run. This is reachable via the queue_after_active dispatch path, which rejects dispatch while a thread has pending merge-back transfers. Consider re-enqueuing the request (or retrying with backoff) before logging so the wake event survives a transient rejection.

Also found in 1 other location(s)

apps/server/src/orchestration-v2/ProviderSessionManager.ts:44

Using the new RELEASE_SCOPE_CLOSE_TIMEOUT_MS causes releaseEntry/close() to report success after 30s even when Scope.close(entry.scope, Exit.void) later fails. Once Effect.timeoutOption(...) returns None, the code only logs the eventual Exit in a detached observer and never re-raises it as ProviderSessionReleaseError/ProviderSessionCloseError, so callers cannot detect adapter cleanup failures anymore on any close path that finishes after the timeout.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ProviderContinuationService.ts around line 56:

The worker loop permanently drops a continuation request whenever `dispatchContinuation` fails. `requests.take` removes the item from the queue, but the `Effect.catchCause` handler only logs the failure and never retries or re-enqueues it — so the buffered provider wake event is lost and never ingested as a run. This is reachable via the `queue_after_active` dispatch path, which rejects dispatch while a thread has pending merge-back transfers. Consider re-enqueuing the request (or retrying with backoff) before logging so the wake event survives a transient rejection.

Also found in 1 other location(s):
- apps/server/src/orchestration-v2/ProviderSessionManager.ts:44 -- Using the new `RELEASE_SCOPE_CLOSE_TIMEOUT_MS` causes `releaseEntry`/`close()` to report success after 30s even when `Scope.close(entry.scope, Exit.void)` later fails. Once `Effect.timeoutOption(...)` returns `None`, the code only logs the eventual `Exit` in a detached observer and never re-raises it as `ProviderSessionReleaseError`/`ProviderSessionCloseError`, so callers cannot detect adapter cleanup failures anymore on any close path that finishes after the timeout.

Effect.flatMap((request) =>
dispatchContinuation(request).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("orchestration-v2.provider-continuation.dispatch-failed", {
threadId: request.threadId,
providerThreadId: request.providerThreadId,
cause,
}),
),
),
),
Effect.forever,
Effect.forkScoped,
);
}),
);
Loading
Loading