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
86 changes: 73 additions & 13 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,20 @@ function delegatedTaskTerminalStatus(
}
}

/** Failed worktree setup keeps the queue held so queued messages cannot start in the project checkout. */
function worktreeSetupPending(
projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
): boolean {
return (
projection.thread.worktreePath === null &&
projection.runs.some(
(run) =>
(run.status === "failed" || run.status === "interrupted") &&
run.workspacePreparation?.type === "worktree",
)
);
}

function nextQueuedRun(
projection: Pick<OrchestrationV2ThreadProjection, "runs" | "messages">,
): OrchestrationV2Run | undefined {
Expand Down Expand Up @@ -2195,6 +2209,52 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
),
);

// A hold waits for a person to review what they queued. Nobody reviews messages from agents,
// the server or scheduled tasks, so a later completed run releases those unless it was stopped.
const releaseUnattendedHolds = Effect.fn("Orchestrator.releaseUnattendedHolds")(function* (
threadId: ThreadId,
completedRunId: RunId,
) {
const held = yield* projectionStore.getHeldQueuedRuns(threadId);
if (held.length === 0) return;
const projection = yield* projectionStore.getThreadRecords(
threadId,
["runs", "messages", "turnItems"],
{
messageIds: held.map((run) => run.userMessageId),
turnItemTypes: ["run_interrupt_request"],
turnItemRunIds: [completedRunId],
},
);
// Only the newest run completing shows the thread moved past the hold; a late or
// bookkeeping update to an older run, overtaken by a failure or Stop, does not.
if (
latestExecutedRun(projection.runs)?.id !== completedRunId ||
projection.turnItems.length > 0 ||
worktreeSetupPending(projection)
)
return;
const { messages } = projection;
const unattended = new Set(
messages
.filter((message) => message.createdBy !== "user" || message.scheduledTaskId !== undefined)
.map((message) => message.id),
);
const now = yield* DateTime.now;
const released = held.filter((run) => unattended.has(run.userMessageId));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (released.length === 0) return;
yield* writeSystemEvents(
released.map((run) => ({
type: "run.updated" as const,
threadId,
runId: run.id,
providerInstanceId: run.providerInstanceId,
occurredAt: now,
payload: { ...run, queueHeld: false },
})),
);
});

const resumeQueuedRuns = Effect.gen(function* () {
const threadIds = yield* projectionStore.getRecoveryThreadIds("queued-runs");
let resumed = 0;
Expand Down Expand Up @@ -5539,15 +5599,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
dispatchMode.type === "queue_after_active";
const shouldQueue = queueBehindRun !== undefined && startsOrQueuesRun;
// Client messages resume held queues; server continuations and scheduled tasks do not.
// Provider failure or usage limits re-hold the queue. Failed worktree setup keeps it held
// until a worktree exists, so queued messages cannot start in the project checkout.
const worktreeSetupUnresolved =
projection.thread.worktreePath === null &&
projection.runs.some(
(run) =>
(run.status === "failed" || run.status === "interrupted") &&
run.workspacePreparation?.type === "worktree",
);
// Provider failure or usage limits re-hold the queue.
const worktreeSetupUnresolved = worktreeSetupPending(projection);
const sentByUserFromClient =
command.createdBy === "user" &&
(command.creationSource === "web" || command.creationSource === "mobile") &&
Expand Down Expand Up @@ -13083,10 +13136,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
yield* threadDispatch
.withLock(
threadId,
startNextQueuedRun(threadId, {
endedRunId: run.id,
...(run.status === "failed" ? { failedRunId: run.id } : {}),
}),
(run.status === "completed"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
? releaseUnattendedHolds(threadId, run.id)
: Effect.void
).pipe(
Effect.andThen(
startNextQueuedRun(threadId, {
endedRunId: run.id,
...(run.status === "failed" ? { failedRunId: run.id } : {}),
}),
),
),
)
.pipe(
Effect.catchCause((cause) =>
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,9 @@ export interface ProjectionStoreV2Shape {
readonly canStartQueuedRun: (
threadId: ThreadId,
) => Effect.Effect<boolean, ProjectionStoreV2Error>;
readonly getHeldQueuedRuns: (
threadId: ThreadId,
) => Effect.Effect<ReadonlyArray<OrchestrationV2Run>, ProjectionStoreV2Error>;
readonly getRecoveryThreadIds: (
kind: ProjectionRecoveryKind,
) => Effect.Effect<ReadonlyArray<ThreadId>, ProjectionStoreV2Error>;
Expand Down Expand Up @@ -5183,6 +5186,17 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
)
.pipe(Effect.mapError(controlReadError(threadId)));

const getHeldQueuedRuns: ProjectionStoreV2Shape["getHeldQueuedRuns"] = (threadId) =>
sql<PayloadRow>`
SELECT payload_json FROM orchestration_v2_projection_runs
WHERE thread_id = ${threadId} AND status = 'queued'
AND json_extract(payload_json, '$.queueHeld') IS 1
ORDER BY ordinal ASC
`.pipe(
Effect.flatMap(decodeRows(decodeRunPayload, threadId)),
Effect.mapError(controlReadError(threadId)),
);

const getMessageCount: ProjectionStoreV2Shape["getMessageCount"] = (threadId) =>
sql<{
count: number;
Expand Down Expand Up @@ -6899,6 +6913,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
getCheckpointRollbackContext: readOnReader(getCheckpointRollbackContext),
getRunMessage: readOnReader(getRunMessage),
canStartQueuedRun: readOnReader(canStartQueuedRun),
getHeldQueuedRuns: readOnReader(getHeldQueuedRuns),
getPendingNativeUserInputs: readOnReader(getPendingNativeUserInputs),
hasUnpairedRunInterruptRequest: readOnReader(hasUnpairedRunInterruptRequest),
isNodeStored: readOnReader(isNodeStored),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ it.effect(
getRunMessage: () => Effect.die("not used"),
isNodeStored: () => Effect.die("not used"),
canStartQueuedRun: () => Effect.die("not used"),
getHeldQueuedRuns: () => Effect.die("not used"),
getRecoveryThreadIds: () => Effect.die("unused getRecoveryThreadIds"),
getUnreadableThreadIds: () => Effect.die("unused getUnreadableThreadIds"),
getThreadSnapshot: () => Effect.die("unused getThreadSnapshot"),
Expand Down
Loading
Loading