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
64 changes: 63 additions & 1 deletion apps/server/src/orchestration/decider.import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ it.layer(NodeServices.layer)("thread history import", (it) => {
metadata: {},
payload: { threadId, turnCount: 0 },
});
// The command model keeps only user messages.
expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([
"Fix the bug",
"Fixed",
]);
}),
);
Expand Down Expand Up @@ -395,6 +395,68 @@ it.layer(NodeServices.layer)("thread history import", (it) => {
);
}

it.effect("rejects a second import of history with no user message", () =>
Effect.gen(function* () {
const createdAt = "2026-08-24T10:00:00.000Z";
const threadId = ThreadId.make("import:codex:assistant-only");
const readModel = yield* projectEvent(createEmptyReadModel(createdAt), {
sequence: 1,
eventId: EventId.make("event-assistant-only-thread-created"),
aggregateKind: "thread",
aggregateId: threadId,
type: "thread.created",
occurredAt: createdAt,
commandId: CommandId.make("command-assistant-only-thread-created"),
causationEventId: null,
correlationId: CommandId.make("command-assistant-only-thread-created"),
metadata: {},
payload: {
threadId,
projectId: ProjectId.make("project-1"),
title: "Imported thread",
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdAt,
updatedAt: createdAt,
},
});
const importCommand = (commandId: string) => ({
type: "thread.history.import" as const,
commandId: CommandId.make(commandId),
threadId,
messages: [
{
messageId: MessageId.make(`${threadId}:000000`),
role: "assistant" as const,
text: "Done",
createdAt,
},
],
});

const events = yield* decideOrchestrationCommand({
command: importCommand("command-first-import"),
readModel,
});
let projected = readModel;
for (const [index, event] of (Array.isArray(events) ? events : [events]).entries()) {
projected = yield* projectEvent(projected, { ...event, sequence: index + 2 });
}
const error = yield* Effect.flip(
decideOrchestrationCommand({
command: importCommand("command-second-import"),
readModel: projected,
}),
);

expect(error._tag).toBe("OrchestrationCommandInvariantError");
expect(error.message).toContain("must be active and empty");
}),
);

it.effect("rejects a live user message in the imported-session namespace", () =>
Effect.gen(function* () {
const createdAt = "2026-08-24T10:00:00.000Z";
Expand Down
59 changes: 7 additions & 52 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
requireThreadAbsent,
requireThreadNotArchived,
} from "./commandInvariants.ts";
import { projectEvent } from "./projector.ts";
import { openRequests, projectEvent } from "./projector.ts";
import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts";

const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
Expand All @@ -55,57 +55,6 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload);
const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest));

/**
* Blocked-on-you work derived from the thread's retained activities: an
* approval or user-input request with no later resolution for the same
* requestId. The server-side twin of the shell's hasPendingApprovals /
* hasPendingUserInput flags, which the decider read model does not carry.
* The clearing rules MUST match ProjectionPipeline's pending accounting —
* resolved activities always clear, respond.failed clears only when the
* failure detail marks the request stale/unknown — or settle would be
* rejected on threads whose shell flags read as clear.
*/
function isStaleRequestFailureDetail(payload: Record<string, unknown> | null): boolean {
const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
if (detail === null) return false;
return (
detail.includes("stale pending approval request") ||
detail.includes("unknown pending approval request") ||
detail.includes("unknown pending permission request") ||
detail.includes("stale pending user-input request") ||
detail.includes("unknown pending user-input request") ||
detail.includes("unknown pending user input request") ||
detail.includes("unknown pending codex user input request")
);
}

// Scans the read model's activities, which the projector caps at the most
// recent 500 plus pending async questions. Async questions remain actionable
// while the agent works, so they must not expire with the activity window.
function openRequests(thread: Pick<OrchestrationThread, "activities">) {
const requests = new Map<string, OrchestrationThreadActivity>();
for (const activity of thread.activities) {
const payload =
typeof activity.payload === "object" && activity.payload !== null
? (activity.payload as Record<string, unknown>)
: null;
const requestId = typeof payload?.requestId === "string" ? payload.requestId : null;
if (requestId === null) continue;
if (activity.kind === "approval.requested" || activity.kind === "user-input.requested") {
requests.set(requestId, activity);
} else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") {
requests.delete(requestId);
} else if (
(activity.kind === "provider.approval.respond.failed" ||
activity.kind === "provider.user-input.respond.failed") &&
isStaleRequestFailureDetail(payload)
) {
requests.delete(requestId);
}
}
return requests;
}

/** Apply the shared shell-level rule to the detailed command read model. */
function hasQueuedTurnStartForThread(
thread: Pick<OrchestrationThread, "messages" | "latestTurn" | "session">,
Expand Down Expand Up @@ -208,6 +157,12 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({
return plannedEvents;
});

/**
* Decides the events for one command. `readModel` is the slim command model
* that projectEvent in projector.ts builds. It has no assistant text, tool
* activity payloads, or checkpoint file lists, so a new rule that reads them
* must first make projectEvent keep them.
*/
export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand")(function* ({
command,
readModel,
Expand Down
Loading
Loading