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
42 changes: 41 additions & 1 deletion apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProviderTurnId,
RunAttemptId,
RunId,
RuntimeRequestId,
ThreadId,
VcsProcessExitError,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -83,6 +84,7 @@ function layerExecutorFor(input: {
readonly continueAfterRestart?: boolean;
readonly interrupt?: ProviderTurnControlService.ProviderTurnControlServiceV2Shape["interrupt"];
readonly rollback?: CheckpointRollbackService.CheckpointRollbackServiceV2Shape["execute"];
readonly respond?: RuntimeRequestService.RuntimeRequestServiceV2["Service"]["respond"];
}) {
const record = (event: string) => Ref.update(input.events, (events) => [...events, event]);
const layerDependencies = Layer.mergeAll(
Expand Down Expand Up @@ -142,7 +144,9 @@ function layerExecutorFor(input: {
),
Layer.succeed(
RuntimeRequestService.RuntimeRequestServiceV2,
RuntimeRequestService.RuntimeRequestServiceV2.of({ respond: () => Effect.void }),
RuntimeRequestService.RuntimeRequestServiceV2.of({
respond: input.respond ?? (() => Effect.void),
}),
),
Layer.succeed(
ThreadTitleRegenerationService.ThreadTitleRegenerationService,
Expand Down Expand Up @@ -269,6 +273,42 @@ it.effect.each([
}),
);

it.effect("does not report an answer whose last delivery attempt is interrupted", () =>
Effect.gen(function* () {
const now = yield* DateTime.now;
const reported = yield* Ref.make(0);
const layer = layerExecutorFor({
events: yield* Ref.make<ReadonlyArray<string>>([]),
respond: () => Effect.interrupt,
threads: {
dispatch: (command) =>
Ref.update(reported, (count) =>
command.type === "runtime-request.delivery.fail" ? count + 1 : count,
).pipe(Effect.as({ sequence: 1, storedEvents: [] })),
},
});
const exit = yield* Effect.gen(function* () {
const executor = yield* EffectWorker.OrchestrationEffectExecutorV2;
return yield* Effect.exit(
executor.execute(
{
...restartEffect(now, { type: "detach" }),
request: {
type: "runtime-request.respond",
providerSessionId: oldSessionId,
requestId: RuntimeRequestId.make("runtime-request:effect-worker-interrupted"),
answers: { next: "Ship it" },
},
},
{ willRetry: false },
),
);
}).pipe(Effect.provide(layer));
assert.isTrue(Exit.hasInterrupts(exit));
assert.equal(yield* Ref.get(reported), 0);
}),
);

it.effect("requeues a claim when a pre-execution worker check fails", () =>
Effect.gen(function* () {
const now = DateTime.formatIso(yield* DateTime.now);
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/orchestration-v2/EffectWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,8 @@ export const layerExecutor: Layer.Layer<
}),
),
);
case "runtime-request.respond":
case "runtime-request.respond": {
const requestId = effect.request.requestId;
return runtimeRequests
.respond({
threadId: effect.threadId,
Expand All @@ -392,6 +393,25 @@ export const layerExecutor: Layer.Layer<
: { answers: effect.request.answers }),
})
.pipe(
Effect.tapCause((cause) =>
willRetry || Cause.hasInterruptsOnly(cause)
? Effect.void
: threads
.dispatch({
type: "runtime-request.delivery.fail",
commandId: CommandId.make(`${effect.commandId}:delivery-failed`),
threadId: effect.threadId,
requestId,
})
.pipe(
Effect.catchCause((recordCause) =>
Effect.logWarning("Failed to record answer delivery failure", {
effectId: effect.id,
cause: recordCause,
}),
),
),
),
Effect.mapError(
(cause) =>
new OrchestrationEffectExecutionError({
Expand All @@ -401,6 +421,7 @@ export const layerExecutor: Layer.Layer<
}),
),
);
}
case "provider-thread.rollback":
return checkpointRollback
.execute({
Expand Down
67 changes: 66 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
RuntimeMode,
ThreadLinkedPullRequest,
ThreadId,
type TurnItemId,
TurnItemId,
} from "@t3tools/contracts";
import { modelSelectionsEqual } from "@t3tools/shared/model";
import { recordThreadOpen } from "@t3tools/shared/paletteSearch";
Expand Down Expand Up @@ -490,6 +490,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
case "queued-run.cancel":
case "queued-run.edit":
case "runtime-request.respond":
case "runtime-request.delivery.fail":
case "thread.user-input.dismiss":
case "checkpoint.rollback":
case "checkpoint.rollback.fail":
Expand Down Expand Up @@ -555,6 +556,8 @@ function delegatedTasksStopEffect(command: {
}

const WORKSPACE_PREPARATION_INPUT = "Preparing workspace";
const RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE =
"T3 Code could not confirm that your answer reached the agent. If the run stays stuck, stop it and try again.";

function withoutPreparationResult(
item: Extract<OrchestrationV2TurnItem, { readonly type: "command_execution" }>,
Expand Down Expand Up @@ -8005,6 +8008,65 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
},
);

/**
* Respond resolves the request before delivery, and a failed delivery cannot
* tell whether the provider is still waiting, so the answer stays recorded and
* the timeline shows the failure instead of a run spinning with no question.
*/
const dispatchRuntimeRequestDeliveryFail = Effect.fn(
"Orchestrator.dispatchRuntimeRequestDeliveryFail",
)(function* (
command: Extract<
OrchestrationV2InternalCommand,
{ readonly type: "runtime-request.delivery.fail" }
>,
events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
) {
const thread = yield* projectionStore
.getThread(command.threadId)
.pipe(mapDispatchError(command));
if (thread.deletedAt !== null) return;
const node = (yield* projectionStore
.getRuntimeResponseContext(command.threadId, command.requestId)
.pipe(mapDispatchError(command))).node;
if (node === undefined) return;
const now = yield* DateTime.now;
yield* emit(
events,
command,
)({
type: "turn-item.updated",
threadId: command.threadId,
...(node.runId === null ? {} : { runId: node.runId }),
nodeId: node.id,
providerInstanceId: thread.providerInstanceId,
occurredAt: now,
payload: {
id: TurnItemId.make(`turn-item:runtime-request-delivery-failure:${command.requestId}`),
threadId: command.threadId,
runId: node.runId,
nodeId: node.id,
providerThreadId: node.providerThreadId,
providerTurnId: node.providerTurnId,
nativeItemRef: null,
parentItemId: null,
ordinal: yield* nextTurnItemOrdinal({ thread }),
status: "failed",
title: "Answer delivery not confirmed",
startedAt: now,
completedAt: now,
updatedAt: now,
type: "error",
failure: {
class: "transport_error",
message: RUNTIME_REQUEST_DELIVERY_FAILED_MESSAGE,
code: null,
retryable: null,
},
},
});
});

const dispatchThreadUserInputDismiss = Effect.fn("Orchestrator.dispatchThreadUserInputDismiss")(
function* (
command: Extract<OrchestrationV2Command, { readonly type: "thread.user-input.dismiss" }>,
Expand Down Expand Up @@ -11697,6 +11759,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "checkpoint.rollback.fail":
yield* dispatchCheckpointRollbackFail(command, events);
break;
case "runtime-request.delivery.fail":
yield* dispatchRuntimeRequestDeliveryFail(command, events);
break;
case "thread.background-work.settle":
yield* dispatchBackgroundWorkSettle(command, events, effects);
break;
Expand Down
130 changes: 130 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,136 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => {
}),
);

it.effect("reports an answer that never reaches its provider without reopening it", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const eventSink = yield* EventSink.EventSinkV2;
const outbox = yield* EffectOutbox.EffectOutboxV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const now = yield* DateTime.now;
const threadId = ThreadId.make("runtime-lost-answer");
// Projected as attached, but no live session holds it, so every delivery fails.
const providerSessionId = ProviderSessionId.make("runtime-lost-answer-session");
const requestId = RuntimeRequestId.make("runtime-lost-answer-request");
const nodeId = NodeId.make("runtime-lost-answer-node");
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("runtime-lost-answer-create"),
createdBy: "user",
creationSource: "web",
threadId,
projectId: ProjectId.make("runtime-lost-answer-project"),
title: "Lost answer",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: process.cwd(),
});
yield* eventSink.write({
commandId: CommandId.make("runtime-lost-answer-seed"),
events: [
{
id: EventId.make("runtime-lost-answer-session-event"),
type: "provider-session.attached",
threadId,
driver,
occurredAt: now,
payload: {
id: providerSessionId,
driver,
providerInstanceId: modelSelection.instanceId,
status: "running",
cwd: process.cwd(),
model: modelSelection.model,
capabilities: CodexProviderCapabilitiesV2,
createdAt: now,
updatedAt: now,
lastError: null,
},
},
{
id: EventId.make("runtime-lost-answer-node-event"),
type: "node.updated",
threadId,
nodeId,
occurredAt: now,
payload: {
id: nodeId,
threadId,
runId: null,
parentNodeId: null,
rootNodeId: nodeId,
kind: "user_input_request",
status: "waiting",
countsForRun: false,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
runtimeRequestId: requestId,
checkpointScopeId: null,
startedAt: now,
completedAt: null,
},
},
{
id: EventId.make("runtime-lost-answer-request-event"),
type: "runtime-request.updated",
threadId,
nodeId,
occurredAt: now,
payload: {
id: requestId,
nodeId,
providerTurnId: null,
nativeRequestRef: null,
kind: "user_input",
status: "pending",
responseCapability: { type: "live", providerSessionId },
createdAt: now,
resolvedAt: null,
},
},
],
});
const commandId = CommandId.make("runtime-lost-answer-answer");
const answers = { color: "Blue" };
yield* orchestrator.dispatch({
type: "runtime-request.respond",
commandId,
threadId,
requestId,
answers,
});
const errors = orchestrator
.getThreadProjection(threadId)
.pipe(
Effect.map((projection) => projection.turnItems.filter((item) => item.type === "error")),
);

// A delivery that can still be retried reports nothing.
yield* worker.drain();
assert.deepEqual(yield* errors, []);
for (let attempt = 1; attempt < 5; attempt++) {
yield* TestClock.adjust("30 seconds");
yield* worker.drain();
}

const [deliveryEffect] = yield* outbox.listByCommandId(commandId);
assert.equal(deliveryEffect?.request.type, "runtime-request.respond");
assert.equal(deliveryEffect?.status, "failed");
const failed = yield* orchestrator.getThreadProjection(threadId);
const reported = failed.turnItems.filter((item) => item.type === "error");
assert.equal(reported.length, 1);
assert.equal(reported[0]?.status, "failed");
assert.equal(reported[0]?.nodeId, nodeId);
// The answer stays recorded; the user is not asked again.
const request = failed.runtimeRequests.find((row) => row.id === requestId);
assert.equal(request?.status, "resolved");
assert.deepEqual(request?.answers, answers);
}),
);

it.effect("dismisses message-capable questions directly and while settling", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/orchestrationV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3125,6 +3125,13 @@ const OrchestrationV2InternalCommand = Schema.Union([
requestId: CommandId,
message: TrimmedNonEmptyString,
}),
/** Delivering the answer to request `requestId` failed on its last attempt. */
Schema.Struct({
type: Schema.Literal("runtime-request.delivery.fail"),
commandId: CommandId,
threadId: ThreadId,
requestId: RuntimeRequestId,
}),
Schema.Struct({
type: Schema.Literal("thread.background-work.settle"),
commandId: CommandId,
Expand Down
Loading