Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -3167,6 +3167,59 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => {
}),
);

it.effect("settles a child without reading all of the parent's nodes", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const now = yield* DateTime.now;
const threadId = ThreadId.make("thread:bounded-finalize-parent");
const projectId = ProjectId.make("project:bounded-finalize");
const runId = RunId.make("run:bounded-finalize-parent");
const rootNodeId = NodeId.make("node:bounded-finalize-parent");
yield* seedParentWithTerminalTask({
threadId,
projectId,
runId,
rootNodeId,
taskId: NodeId.make("node:bounded-finalize-seed"),
deliveryState: "acknowledged",
now,
});
const child = yield* seedRestartCancelledChild({
parentThreadId: threadId,
projectId,
parentRunId: runId,
rootNodeId,
name: "bounded-finalize-child",
completionWake: "always",
continuationPending: false,
runStatus: "completed",
now,
});
const statements: string[] = [];
const tracer = Tracer.make({
span(options) {
const span = new Tracer.NativeSpan(options);
const end = span.end.bind(span);
span.end = (time, exit) => {
end(time, exit);
const query = span.attributes.get("db.query.text");
if (typeof query === "string") statements.push(query);
};
return span;
},
});
yield* orchestrator.recoverDelegatedTasks.pipe(Effect.withTracer(tracer));
const settled = yield* orchestrator.getThreadProjection(threadId);
assert.equal(settled.subagents.find((task) => task.id === child.taskId)?.status, "completed");
// A dispatcher parent holds thousands of nodes; settling one child names its own.
const nodeReads = statements.filter((query) =>
query.includes("FROM orchestration_v2_projection_nodes"),
);
assert.isNotEmpty(nodeReads);
assert.isTrue(nodeReads.every((query) => query.includes("node_id IN")));
}),
);

it.effect("holds a restart-cancelled child for its continuation's result", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
Expand Down
121 changes: 91 additions & 30 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});

// Runs in every command transaction; avoid reading thousands of finished leaf nodes.
const readCommandProjection = (threadId: ThreadId) =>
const readCommandProjection = (
threadId: ThreadId,
recent?: { keepRunIds: ReadonlyArray<RunId> },
) =>
projectionStore
.getThreadRecords(
threadId,
Expand All @@ -1323,6 +1326,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
// Commands here read only undelivered or active tasks; a dispatcher thread keeps
// thousands of delivered ones, each carrying its full prompt.
omitSettledSubagents: true,
...(recent === undefined ? {} : { sinceLastCompaction: recent }),
},
)
.pipe(
Expand Down Expand Up @@ -1411,9 +1415,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
Effect.fnUntraced(function* (
threadId: ThreadId,
events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
recent?: { keepRunIds: ReadonlyArray<RunId> },
) {
const pending = (yield* Ref.get(events)).filter((event) => event.threadId === threadId);
const stored = yield* Effect.option(readCommandProjection(threadId));
const stored = yield* Effect.option(readCommandProjection(threadId, recent));
let projection: OrchestrationV2ThreadProjection;
if (Option.isSome(stored)) {
projection = stored.value;
Expand Down Expand Up @@ -5845,7 +5850,31 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
events: Ref.Ref<Array<OrchestrationV2DomainEvent>>,
effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>,
) {
let projection = yield* getProjectionWithPendingEvents(command.threadId, events);
// A compacted thread needs only its recent runs, plus any older run the command names.
let projection = yield* getProjectionWithPendingEvents(command.threadId, events, {

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/Orchestrator.ts:5624

After consecutive /compact runs, the truncated projection drops the last ordinary prompt run, so the next prompt with a different model gets no modelSwitch even though the full history records a model change from that prompt. modelSwitchSince excludes compaction messages, leaving no baseline in the retained runs; preserve the last timeline-visible run as well as the compaction predecessor.

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

After consecutive `/compact` runs, the truncated projection drops the last ordinary prompt run, so the next prompt with a different model gets no `modelSwitch` even though the full history records a model change from that prompt. `modelSwitchSince` excludes compaction messages, leaving no baseline in the retained runs; preserve the last timeline-visible run as well as the compaction predecessor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed: with a /compact run directly before the compaction, the window had no run whose prompt the timeline shows, so modelSwitchSince lost its baseline. Fixed in 7836410: the window now starts at the last prompt before the latest compaction that is not itself a /compact run (and not queued or rolled back). The compacted-thread case in ProjectionStore.test.ts now has a second compaction behind a /compact run and expects the window to start at the prompt before it.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

keepRunIds: [
command.manualContinuationOfRunId,
command.restartContinuationOfRunId,
command.usageLimitContinuationOfRunId,
command.delegatedCompletion?.parentRunId,
...(command.dispatchMode.type === "steer_active" ||
command.dispatchMode.type === "restart_active"
? [command.dispatchMode.targetRunId]
: []),
].flatMap((runId) => (runId === undefined ? [] : [runId])),
});
Comment on lines +5856 to +5865

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.

🟠 High orchestration-v2/Orchestrator.ts:5626

An explicit steer or restart targeting a run older than the compaction window now fails as “not found” instead of starting a new turn: keepRunIds drops dispatchMode.targetRunId, so the completed-target fallback cannot see the run and dispatchSteerIntoRun rejects it. Include the target ID for steer_active and restart_active in keepRunIds.

Suggested change
command.manualContinuationOfRunId,
command.restartContinuationOfRunId,
command.usageLimitContinuationOfRunId,
command.delegatedCompletion?.parentRunId,
].flatMap((runId) => (runId === undefined ? [] : [runId])),
});
keepRunIds: [
command.manualContinuationOfRunId,
command.restartContinuationOfRunId,
command.usageLimitContinuationOfRunId,
command.delegatedCompletion?.parentRunId,
...(command.dispatchMode.type === "steer_active" ||
command.dispatchMode.type === "restart_active"
? [command.dispatchMode.targetRunId]
: []),
].flatMap((runId) => (runId === undefined ? [] : [runId])),
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/Orchestrator.ts around lines 5626-5631:

An explicit steer or restart targeting a run older than the compaction window now fails as “not found” instead of starting a new turn: `keepRunIds` drops `dispatchMode.targetRunId`, so the completed-target fallback cannot see the run and `dispatchSteerIntoRun` rejects it. Include the target ID for `steer_active` and `restart_active` in `keepRunIds`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed: an explicit steer_active or restart_active names its own target run, and a finished target older than the window was missing, so the command failed with "Target run ... was not found" instead of starting a new turn. Fixed in 7836410: the target is added to keepRunIds. SteeringCompletion.integration.test.ts gains a case with three finished turns and a compaction on the third, then a steer aimed at the first; it fails with that exact error without the change.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

const activeProviderInstanceId = projection.providerThreads.find(
(candidate) => candidate.id === projection.thread.activeProviderThreadId,
)?.providerInstanceId;
// A fresh session or another provider is handed the whole history, so it reads all of it.
if (
command.freshContext === true ||
(activeProviderInstanceId !== undefined &&
activeProviderInstanceId !==
(command.modelSelection ?? projection.thread.modelSelection).instanceId)
) {
projection = yield* getProjectionWithPendingEvents(command.threadId, events);
}
if (command.manualContinuationOfRunId !== undefined) {
const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId);
const limited = latestRootProviderFailure(source ?? null, projection.turnItems);
Expand Down Expand Up @@ -6018,16 +6047,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
projection = applyToProjection(projection, event);
}
const userMessages = projection.messages.filter((message) => message.role === "user");
const onlyMaintenanceHistory =
userMessages.length > 0 && userMessages.every(isNativeMaintenanceCommand);
if (
!isNativeMaintenanceCommand(command) &&
((command.titleSeed !== undefined &&
(yield* projectionStore
.getMessageCount(command.threadId)
.pipe(mapDispatchError(command))) === 0) ||
(command.createdBy === "user" && onlyMaintenanceHistory))
(command.createdBy === "user" &&
(yield* projectionStore
.hasOnlyMaintenanceUserMessages(command.threadId)
.pipe(mapDispatchError(command)))))
) {
const now = yield* DateTime.now;
const thread: OrchestrationV2AppThread = {
Expand Down Expand Up @@ -13008,21 +13037,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
turnItems: childResult.turnItems,
};
const parentThreadId = childControls.thread.lineage.parentThreadId;
const parentProjection = yield* projectionStore.getThreadRecords(
// A dispatcher parent holds thousands of tasks, runs and prompts, so read only this task's
// records and the delivery cohort the completion plan joins.
const taskRecords = yield* projectionStore.getThreadRecords(
parentThreadId,
[
"runs",
"messages",
"subagents",
"providerThreads",
"providerTurns",
"nodes",
"turnItems",
"contextTransfers",
],
{ turnItemTypes: ["subagent"], messageRoles: ["user"] },
["subagents", "nodes", "turnItems"],
{
subagentIds: [options.task.id],
nodeIds: [options.task.id],
turnItemNodeIds: [options.task.id],
turnItemTypes: ["subagent"],
},
);
const task = parentProjection.subagents.find(
const task = taskRecords.subagents.find(
(candidate) =>
candidate.id === options.task.id &&
candidate.origin === "app_owned" &&
Expand All @@ -13031,7 +13058,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
if (task === undefined) {
return;
}
const existingResultTransfer = parentProjection.contextTransfers.find(
// The result transfer runs from the child, so the child's side lists it too.
const { contextTransfers } = yield* projectionStore.getThreadRecords(childThreadId, [
"contextTransfers",
]);
const existingResultTransfer = contextTransfers.find(
(transfer) =>
transfer.type === "subagent_result" &&
transfer.sourceThreadId === childThreadId &&
Expand All @@ -13045,12 +13076,37 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio

const now = yield* DateTime.now;
const result = subagentResultForRun(childProjection, { ...childRun, status: outcome });
const parentRun =
task.runId === null
const taskRunRecords = yield* projectionStore.getThreadRecords(parentThreadId, ["runs"], {
runIds: task.runId === null ? [] : [task.runId],
});
const parentRun = taskRunRecords.runs.find((candidate) => candidate.id === task.runId);
const delivery = parentRun?.delegatedCompletion?.delivery;
const deliveryRecords =
delivery == null
? undefined
: yield* projectionStore.getThreadRecords(parentThreadId, ["messages", "subagents"], {
messageIds: [delivery.messageId],
subagentIds: [...new Set([...delivery.taskIds, task.id])],
});
const deliveryRunId = deliveryRecords?.messages[0]?.runId ?? null;
const deliveryRun =
deliveryRunId === null || deliveryRunId === parentRun?.id
? undefined
: parentProjection.runs.find((candidate) => candidate.id === task.runId);
const parentNode = parentProjection.nodes.find((candidate) => candidate.id === task.id);
const parentTurnItem = parentProjection.turnItems.find(
: (yield* projectionStore.getThreadRecords(parentThreadId, ["runs"], {
runIds: [deliveryRunId],
})).runs[0];
const parentProjection = {
thread: taskRecords.thread,
runs: [
...(parentRun === undefined ? [] : [parentRun]),
...(deliveryRun ? [deliveryRun] : []),
],
messages: deliveryRecords?.messages ?? [],
subagents: deliveryRecords?.subagents ?? [task],
providerTurns: [],
};
const parentNode = taskRecords.nodes.find((candidate) => candidate.id === task.id);
const parentTurnItem = taskRecords.turnItems.find(
(candidate) => candidate.type === "subagent" && candidate.subagentId === task.id,
);
const updatedTask: OrchestrationV2Subagent = {
Expand Down Expand Up @@ -13183,8 +13239,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
)
return;
const progress = delegatedTaskProgress(childControls);
const parent = yield* projectionStore.getThreadRecords(parentThreadId, [
"subagents",
// A dispatcher parent holds thousands of tasks and transfers; this child owns a few of each.
const parent = yield* projectionStore.getThreadRecords(parentThreadId, ["subagents"], {
subagentChildThreadId: childThreadId,
});
const { contextTransfers } = yield* projectionStore.getThreadRecords(childThreadId, [
"contextTransfers",
]);
for (const task of parent.subagents) {
Expand All @@ -13194,9 +13253,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
(task.id === childControls.thread.forkedFrom.nodeId &&
progress.state !== "result_available") ||
(task.result !== null &&
parent.contextTransfers.some(
contextTransfers.some(
(transfer) =>
transfer.type === "subagent_result" && transfer.sourceThreadId === childThreadId,
transfer.type === "subagent_result" &&
transfer.sourceThreadId === childThreadId &&
transfer.targetThreadId === parentThreadId,
))
)
continue;
Expand Down
Loading
Loading