Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,42 @@ describe("AcpRegistryAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

// regression: #26695 cancelling a Copilot prompt kills its background agents
it.effect("never steers Copilot by cancelling its prompt", () =>
Effect.gen(function* () {
const steering = Effect.fn("steering")(function* (config: Record<string, unknown>) {
const instanceId = ProviderInstanceId.make("acp-registry-steering");
const adapter = yield* makeAcpRegistryAdapterV2({
selfInvocation: yield* resolveSelfInvocation(),
instanceId,
settings: yield* decodeAcpRegistryAdapterSettings(config),
environment: {},
}).pipe(Effect.provide(layerInjectedRuntimeCatalog));
const { turns } = yield* adapter.getCapabilities();
return {
supportsInterrupt: turns.supportsInterrupt,
supportsActiveSteering: turns.supportsActiveSteering,
supportsSteeringByInterruptRestart: turns.supportsSteeringByInterruptRestart,
};
});
const copilot = {
supportsInterrupt: true,
supportsActiveSteering: false,
supportsSteeringByInterruptRestart: false,
};

assert.deepEqual(yield* steering({ agentId: "github-copilot-cli" }), copilot);
assert.deepEqual(
yield* steering({ source: "local", commandPath: "C:\\tools\\copilot.cmd" }),
copilot,
);
assert.deepEqual(yield* steering({ agentId: "gemini" }), {
...copilot,
supportsSteeringByInterruptRestart: true,
});
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("settles Claude's accepted foreground reply while retaining child output", () =>
Effect.gen(function* () {
const gate = makeProviderReplayGate(["accepted", "child-output", "wake"]);
Expand Down
20 changes: 14 additions & 6 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,8 +823,11 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
assert.isFunction(canUseTool);

const requestEvent = yield* runtime.events.pipe(
Stream.filter((event) => event.type === "runtime_request.updated"),
Stream.runHead,
Stream.takeUntil(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
),
Stream.runCollect,
Effect.forkScoped,
);
const command = { command: "node -e 'console.log(42)'" };
Expand All @@ -833,6 +836,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
signal: new AbortController().signal,
toolUseID: "tool-bash-accept-edits",
requestId: "request-bash-accept-edits",
description: "Check the Node runtime",
}),
).pipe(Effect.forkScoped);
const first = yield* Effect.raceFirst(
Expand All @@ -845,12 +849,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
);
assert.equal(first.type, "request", "the command ran without asking");
if (first.type !== "request") return;
const event = first.event;
if (Option.isNone(event) || event.value.type !== "runtime_request.updated") return;
assert.equal(event.value.runtimeRequest.kind, "command");
const event = first.event.find((event) => event.type === "runtime_request.updated");
assert.equal(event?.runtimeRequest.kind, "command");
const approval = first.event.findLast((event) => event.type === "turn_item.updated");
assert.equal(
approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined,
command.command,
);

yield* runtime.respondToRuntimeRequest({
requestId: event.value.runtimeRequest.id,
requestId: event!.runtimeRequest.id,
decision: "accept",
});
assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9141,7 +9141,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}

const requestKind = providerRequestKindFromClaudeTool(toolName);
// The user approves the command itself; Claude's title and description only summarize it.
const command = inputRecordValue(nativeToolInput, "command");
const prompt =
(typeof command === "string" && command.trim() !== "" ? command : undefined) ??
callbackOptions.title ??
callbackOptions.description ??
callbackOptions.decisionReason ??
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/orchestration-v2/CommandPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,53 @@ it.each([
);
});

// regression: #26695 an interrupt-restart steer cancels Copilot's background agents
it("queues a steer for a provider that cannot steer either way", () => {
const projection = dispatchProjection(
capabilities((current) => ({
...current,
turns: {
...current.turns,
supportsActiveSteering: false,
supportsSteeringByInterruptRestart: false,
supportsQueuedMessages: true,
},
})),
);
for (const deliveryIntent of ["auto", "steer"] as const) {
assert.deepEqual(
CommandPolicy.resolveMessageDispatchIntent(
projection,
{ type: "start_immediately" },
deliveryIntent,
),
{ type: "queue_after_active" },
);
}
assert.deepEqual(
CommandPolicy.resolveMessageDispatchIntent(projection, {
type: "steer_active",
targetRunId: activeRunId,
}),
{ type: "queue_after_active" },
);
assert.deepEqual(
CommandPolicy.resolveMessageDispatchIntent(
projection,
{ type: "start_immediately" },
"restart",
),
{ type: "restart_active", targetRunId: activeRunId },
);
assert.deepEqual(
CommandPolicy.resolveMessageDispatchIntent(projection, {
type: "steer_active",
targetRunId: RunId.make("command-policy-missing-run"),
}),
{ type: "steer_active", targetRunId: RunId.make("command-policy-missing-run") },
);
});

it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) => {
const projection = dispatchProjection(baseCapabilities);
const maintenance = {
Expand Down
57 changes: 39 additions & 18 deletions apps/server/src/orchestration-v2/CommandPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,25 +139,55 @@ export function resolveMessageDispatchIntent(
// while one runs waits in the queue like any other follow-up.
const target = projection.runs.find((run) => run.id === mode.targetRunId);
const targetMessage = projection.messages.find((message) => message.id === target?.userMessageId);
return targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)
if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) {
return { type: "queue_after_active" };
}
// A provider that can neither steer nor restart its turn takes the message after the turn.
const turns =
target === undefined || !isActiveRun(target)
? undefined
: providerTurnCapabilities(projection, target);
return turns !== undefined &&
!turns.supportsActiveSteering &&
!(turns.supportsInterrupt && turns.supportsSteeringByInterruptRestart) &&
turns.supportsQueuedMessages
? { type: "queue_after_active" }
: mode;
}

function isActiveRun(run: OrchestrationV2Run): boolean {
return (
run.status === "preparing" ||
run.status === "starting" ||
run.status === "running" ||
run.status === "waiting"
);
}

function providerTurnCapabilities(
projection: OrchestrationV2ThreadProjection,
run: OrchestrationV2Run,
) {
const providerThread = projection.providerThreads.find(
(candidate) => candidate.id === run.providerThreadId,
);
const providerSession =
providerThread?.providerSessionId == null
? undefined
: projection.providerSessions.find(
(candidate) => candidate.id === providerThread.providerSessionId,
);
return providerSession?.capabilities.turns;
}

function resolveDeliveryIntent(
projection: OrchestrationV2ThreadProjection,
requestedMode: MessageDispatchMode,
deliveryIntent?: "auto" | "steer" | "restart",
): MessageDispatchMode {
if (deliveryIntent === undefined) return requestedMode;

const activeRun = projection.runs.findLast(
(run) =>
run.status === "preparing" ||
run.status === "starting" ||
run.status === "running" ||
run.status === "waiting",
);
const activeRun = projection.runs.findLast(isActiveRun);
if (activeRun === undefined) return { type: "start_immediately" };
if (deliveryIntent === "steer") {
return { type: "steer_active", targetRunId: activeRun.id };
Expand All @@ -177,16 +207,7 @@ function resolveDeliveryIntent(
return { type: "queue_after_active" };
}

const providerThread = projection.providerThreads.find(
(candidate) => candidate.id === activeRun.providerThreadId,
);
const providerSession =
providerThread?.providerSessionId == null
? undefined
: projection.providerSessions.find(
(candidate) => candidate.id === providerThread.providerSessionId,
);
const capabilities = providerSession?.capabilities.turns;
const capabilities = providerTurnCapabilities(projection, activeRun);
if (capabilities?.supportsActiveSteering === true) {
return { type: "steer_active", targetRunId: activeRun.id };
}
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,29 @@ export const layerExecutor: Layer.Layer<
}),
);

// A running effect blocks later work on its thread and its lease is never reclaimed in-process,
// so a stop that hangs must give up and fall through to the normal retry/fail path.
const DELEGATED_TASKS_STOP_TIMEOUT = Duration.seconds(30);

const boundExecution =
(effect: EffectOutbox.OrchestrationEffectV2) =>
(execution: Effect.Effect<void, OrchestrationEffectExecutionError>) =>
effect.request.type === "delegated-tasks.stop"
? execution.pipe(
Effect.timeoutOrElse({
duration: DELEGATED_TASKS_STOP_TIMEOUT,
orElse: () =>
Effect.fail(
new OrchestrationEffectExecutionError({
effectId: effect.id,
effectType: effect.request.type,
cause: `Timed out after ${Duration.format(DELEGATED_TASKS_STOP_TIMEOUT)}.`,
}),
),
}),
)
: execution;

export class OrchestrationEffectWorkerError extends Schema.TaggedError<OrchestrationEffectWorkerError>()(
"OrchestrationEffectWorkerError",
{
Expand Down Expand Up @@ -734,7 +757,7 @@ export const layerWithOptions = (

const execution = executor
.execute(effect, { willRetry: effect.attemptCount < maxAttempts })
.pipe(Effect.as("executed" as const));
.pipe(boundExecution(effect), Effect.as("executed" as const));
const exit = yield* Effect.exit(Effect.raceFirst(execution, cancellation)).pipe(
Effect.ensuring(outbox.clearCancellation(effect.id)),
);
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/orchestration-v2/FoundationPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import * as Ref from "effect/Ref";
import * as Scheduler from "effect/Scheduler";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";
import * as Tracer from "effect/Tracer";
import * as SqlClient from "effect/sql/SqlClient";
import * as Statement from "effect/sql/Statement";
Expand Down Expand Up @@ -2405,6 +2406,94 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => {
}),
);

it.effect.each([{ replayed: false }, { replayed: true }] as const)(
"starts later thread work behind a delegated-task stop that never returns (replayed: $replayed)",
({ replayed }) =>
Effect.gen(function* () {
const outbox = yield* EffectOutbox.EffectOutboxV2;
const key = `foundation-hung-delegated-stop:${replayed}`;
const threadId = ThreadId.make(`thread:${key}`);
const stopEffectId = `effect:${key}:stop`;
const stopStarted = yield* Deferred.make<void>();
const turnStarted = yield* Deferred.make<void>();
yield* outbox.enqueue([
{
id: stopEffectId,
commandId: CommandId.make(`command:${key}:stop`),
threadId,
request: { type: "delegated-tasks.stop" },
},
]);
if (replayed) {
assert.isTrue(
Option.isSome(
yield* outbox.claimNext({ workerId: "crashed-worker", leaseDurationMs: 30_000 }),
),
);
assert.equal((yield* outbox.reconcileAfterProcessLoss).requeued, 1);
}

const layerExecutor = Layer.succeed(
EffectWorker.OrchestrationEffectExecutorV2,
EffectWorker.OrchestrationEffectExecutorV2.of({
execute: (effect) =>
effect.request.type === "delegated-tasks.stop"
? Deferred.succeed(stopStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(turnStarted, undefined),
}),
);
const layerWorker = EffectWorker.layerWithOptions({
workerId: "hung-delegated-stop-worker",
}).pipe(
Layer.provide(
Layer.merge(Layer.succeed(EffectOutbox.EffectOutboxV2, outbox), layerExecutor),
),
);

yield* Effect.gen(function* () {
yield* EffectWorker.runDaemonWithOptions({ concurrency: 2 }).pipe(Effect.forkScoped);
yield* Deferred.await(stopStarted);
yield* outbox.enqueue([
{
id: `effect:${key}:turn`,
commandId: CommandId.make(`command:${key}:turn`),
threadId,
request: { type: "provider-turn.start", runId: RunId.make(`run:${key}`) },
},
]);
yield* outbox.notifyAvailable();

const turn = yield* Deferred.await(turnStarted).pipe(
Effect.timeoutOption("1 hour"),
Effect.forkChild,
);
yield* TestClock.adjust("1 hour");
assert.isTrue(
Option.isSome(yield* Fiber.join(turn)),
"provider-turn.start never ran behind the hung delegated-tasks.stop",
);
const stop = yield* outbox.get(stopEffectId);
assert.isTrue(Option.isSome(stop));
if (Option.isSome(stop)) {
assert.equal(stop.value.status, "failed");
assert.include(stop.value.lastError ?? "", "Timed out");
}
}).pipe(
Effect.provide(layerWorker),
Effect.scoped,
Effect.ensuring(
outbox
.cancelUnsettled({
threadId,
effectTypes: ["delegated-tasks.stop", "provider-turn.start"],
reason: "fixture complete",
})
.pipe(Effect.orDie),
),
);
}),
);

it.effect(
"persists shutdown continuation intent through the real event sink without domain events",
() =>
Expand Down
Loading
Loading