Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
98b3cb3
fix(antigravity): start fresh after a lost thinking signature
Nov 1, 2026
833ae1c
fix(server): keep broken history invalid after sign-out
Nov 1, 2026
af46368
fix(antigravity): preserve broken history across dispatch paths
maria-rcks Nov 3, 2026
13c48d4
fix(antigravity): start fresh after a lost thinking signature
Nov 1, 2026
16956f9
fix(server): keep broken history invalid after sign-out
Nov 1, 2026
77851ea
fix(antigravity): preserve broken history across dispatch paths
maria-rcks Nov 3, 2026
8e52820
fix(antigravity): retain broken history when stop races outcome
maria-rcks Nov 3, 2026
4eae6f6
chore: merge main into issue-watch/28870
maria-rcks Nov 3, 2026
57868a9
test(antigravity): keep stop coverage in the existing outcome case
maria-rcks Nov 3, 2026
a0c408d
chore: integrate updated issue-watch/28870 head
maria-rcks Nov 3, 2026
1d84b5d
chore: merge main into issue-watch/28870
maria-rcks Nov 4, 2026
330a74d
chore: merge main into issue-watch/28870
shivamhwp Nov 4, 2026
891a00e
test(antigravity): drop the quoted-reply row that passes on main
shivamhwp Nov 4, 2026
387b9e9
fix(acp): tag broken native history errors
shivamhwp Nov 4, 2026
c74af76
Merge remote-tracking branch 'origin/main' into issue-watch/28870
shivamhwp Nov 4, 2026
b9e23a1
Merge remote-tracking branch 'origin/main' into issue-watch/28870
shivamhwp Nov 5, 2026
f1762d8
docs(acp): clarify recovery after broken native history
shivamhwp Nov 5, 2026
fc24779
Merge remote-tracking branch 'origin/main' into issue-watch/28870
shivamhwp Nov 5, 2026
1a70c97
Merge remote-tracking branch 'origin/main' into issue-watch/28870
shivamhwp Nov 5, 2026
7a1af72
fix(server): resume-fallback recovery no longer reintroduces context …
shivamhwp Nov 5, 2026
9b3f640
Merge remote-tracking branch 'origin/main' into issue-watch/28870
shivamhwp Nov 5, 2026
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
105 changes: 97 additions & 8 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,10 @@ describe("AcpAdapterV2", () => {
it.live("fails an end_turn the outcome check rejects and restarts before the next turn", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const checking = yield* Deferred.make<void>();
const releaseCheck = yield* Deferred.make<void>();
const cancelled = yield* Deferred.make<void>();
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const instanceId = ProviderInstanceId.make("acp-test-prompt-outcome");
const threadId = ThreadId.make("thread-acp-prompt-outcome");
const quota = {
Expand All @@ -894,6 +898,12 @@ describe("AcpAdapterV2", () => {
retryable: true,
resetAt: "2030-01-01T00:00:00.000Z",
} as const;
const poisoned = {
class: "provider_error",
message: "Saved history is unusable.",
code: "INVALID_THINKING_SIGNATURE",
retryable: true,
} as const;
// The runtime ordinal of each prompt, and the session each check watched.
const promptRuntimes: Array<number> = [];
const watchedSessions: Array<string> = [];
Expand All @@ -906,8 +916,20 @@ describe("AcpAdapterV2", () => {
watchPromptOutcome: (sessionId) =>
Effect.sync(() => {
watchedSessions.push(sessionId);
const failed = watchedSessions.length === 1;
return () => Effect.succeed(failed ? quota : undefined);
const outcome =
watchedSessions.length === 1
? { failure: quota, threadDisposition: "reusable" as const }
: watchedSessions.length >= 3
? { failure: poisoned, threadDisposition: "broken" as const }
: undefined;
const interrupted = watchedSessions.length === 4;
return () =>
interrupted
? Deferred.succeed(checking, undefined).pipe(
Effect.andThen(Deferred.await(releaseCheck)),
Effect.as(outcome),
)
: Effect.succeed(outcome);
}),
makeRuntime: makeMockRuntime({
childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner,
Expand All @@ -916,6 +938,7 @@ describe("AcpAdapterV2", () => {
),
wrapRuntime: (runtime, runtimeOrdinal) => ({
...runtime,
cancel: Deferred.succeed(cancelled, undefined).pipe(Effect.asVoid),
prompt: () =>
Effect.sync(() => {
promptRuntimes.push(runtimeOrdinal);
Expand Down Expand Up @@ -954,23 +977,89 @@ describe("AcpAdapterV2", () => {
ordinal,
}),
);
const events = yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
return Array.from(
yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
),
);
return events.at(-1);
});

const failed = yield* runTurn(1);
const failed = (yield* runTurn(1)).at(-1);
if (failed?.type !== "turn.terminal" || failed.status !== "failed") {
return assert.fail("expected a failed terminal");
}
assert.deepEqual(failed.failure, quota);
const completed = yield* runTurn(2);
assert.equal(failed.threadDisposition, "reusable");
const completed = (yield* runTurn(2)).at(-1);
assert.equal(completed?.type === "turn.terminal" ? completed.status : null, "completed");
assert.deepEqual(promptRuntimes, [1, 2], "the next turn runs on a fresh runtime");
assert.equal(watchedSessions.length, 2);
assert.equal(watchedSessions[0], watchedSessions[1]);

// Poisoned native history marks the thread broken; its persisted error row is never resumed.
const poisonedEvents = yield* runTurn(3);
const broken = poisonedEvents.at(-1);
if (broken?.type !== "turn.terminal" || broken.status !== "failed") {
return assert.fail("expected a failed terminal");
}
assert.deepEqual(broken.failure, poisoned);
assert.equal(broken.threadDisposition, "broken");
const brokenThread = poisonedEvents.findLast(
(event) => event.type === "provider_thread.updated",
);
assert.equal(
brokenThread?.type === "provider_thread.updated"
? brokenThread.providerThread.status
: null,
"error",
);
const resumed = yield* Effect.result(
runtime.resumeThread({
threadId,
providerThread: { ...providerThread, status: "error" },
modelSelection,
runtimePolicy,
}),
);
assert.equal(
resumed._tag === "Failure" ? resumed.failure._tag : null,
"ProviderAdapterResumeThreadError",
);

const turn = yield* Effect.forkChild(runTurn(4));
yield* Deferred.await(checking);
const stop = yield* Effect.forkChild(
runtime.interruptTurn({
providerThread,
providerTurnId: idAllocator.derive.providerTurn({
driver: ACP_TEST_DRIVER,
nativeTurnId: acpScopedNativeId(instanceId, `${watchedSessions[3]}:turn:4`),
}),
}),
);
yield* Deferred.await(cancelled);
yield* Deferred.succeed(releaseCheck, undefined);
const interruptedEvents = yield* Fiber.join(turn);
yield* Fiber.join(stop);
const interrupted = interruptedEvents.at(-1);
assert.equal(
interrupted?.type === "turn.terminal" ? interrupted.status : null,
"interrupted",
);
assert.equal(
interrupted?.type === "turn.terminal" ? interrupted.threadDisposition : null,
"broken",
);
const interruptedThread = interruptedEvents.findLast(
(event) => event.type === "provider_thread.updated",
);
assert.equal(
interruptedThread?.type === "provider_thread.updated"
? interruptedThread.providerThread.status
: null,
"error",
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);
it.live("retains returned usage when Stop finalizes before the prompt callback", () =>
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/orchestration-v2/ContextHandoffService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import { createOrchestrationV2TurnItemVisibility } from "@t3tools/shared/orchestrationV2Timeline";
import type { ProjectionStoreV2Shape } from "./ProjectionStore.ts";
import { cancelledBeforeStart } from "./RestartBackgroundNote.ts";

export class ContextHandoffPrepareError extends Schema.TaggedError<ContextHandoffPrepareError>()(
"ContextHandoffPrepareError",
Expand Down Expand Up @@ -114,6 +115,29 @@ function compactText(text: string, maxLength = 240): string {
return `${compacted.slice(0, last >= 0xd800 && last <= 0xdbff ? end - 1 : end)}...`;
}

/** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */
export function contextBoundaryOrdinal(
runs: ReadonlyArray<
Pick<
OrchestrationV2Run,
"ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId"
>
>,
): number {
let boundary = 0;
for (const run of runs) {
if (
run.freshContext === true &&
run.status !== "queued" &&
!cancelledBeforeStart(run) &&
run.ordinal > boundary
) {
boundary = run.ordinal;
}
}
return boundary;
}

export function visibleDeltaRunOrdinals(
projection: { readonly runs: ReadonlyArray<Pick<OrchestrationV2Run, "id" | "ordinal">> },
items: ReadonlyArray<Pick<OrchestrationV2TurnItem, "runId">>,
Expand Down
28 changes: 3 additions & 25 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import {
import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts";
import {
ContextHandoffServiceV2,
contextBoundaryOrdinal,
readForkHistoryItems,
readInheritedHandoffItems,
visibleConversationItems,
Expand Down Expand Up @@ -999,29 +1000,6 @@ function appendContextHandoffId(
return handoffId === null ? handoffIds : Array.from(new Set([...handoffIds, handoffId]));
}

/** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */
function contextBoundaryOrdinal(
runs: ReadonlyArray<
Pick<
OrchestrationV2Run,
"ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId"
>
>,
): number {
let boundary = 0;
for (const run of runs) {
if (
run.freshContext === true &&
run.status !== "queued" &&
!cancelledBeforeStart(run) &&
run.ordinal > boundary
) {
boundary = run.ordinal;
}
}
return boundary;
}

/** Threads with no started run (imported, or reserved by another queued run) never cross it. */
function providerThreadPredatesBoundary(
providerThread: Pick<OrchestrationV2ProviderThread, "firstRunOrdinal">,
Expand Down Expand Up @@ -2185,7 +2163,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const providerThread: OrchestrationV2ProviderThread = {
...deliveryProviderThread,
providerSessionId,
status: "not_loaded",
status: deliveryProviderThread.status === "error" ? "error" : "not_loaded",
firstRunOrdinal: queuedProviderThread.firstRunOrdinal ?? ordinal,
lastRunOrdinal: ordinal,
handoffIds: appendContextHandoffId(
Expand Down Expand Up @@ -7264,7 +7242,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
: null;
const providerThread: OrchestrationV2ProviderThread = {
...ensuredProviderThread,
status: "active",
status: ensuredProviderThread.status === "error" ? "error" : "active",
firstRunOrdinal: ensuredProviderThread.firstRunOrdinal ?? ordinal,
lastRunOrdinal: ordinal,
handoffIds: [
Expand Down
30 changes: 27 additions & 3 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1791,11 +1791,32 @@ effectIt.effect("does not mistake a failed state read for a superseded run", ()
}),
);

effectIt.effect(
"signs out the existing native provider before opening the newly selected provider",
() =>
effectIt.effect.each(["not_loaded", "error"] as const)(
"signs out the existing native provider before opening the newly selected provider with status %s",
(providerThreadStatus) =>
Effect.gen(function* () {
const harness = makeLocalCommandHarness({ text: "/logout", previousNativeSession: true });
const initial = harness.projection();
const target = initial.providerThreads.find((thread) => thread.id === "new-provider-thread")!;
harness.setProjection({
...initial,
providerThreads: initial.providerThreads.map((thread) =>
thread.id === target.id
? {
...thread,
status: providerThreadStatus,
nativeThreadRef:
providerThreadStatus === "error"
? {
driver: thread.driver,
nativeId: "poisoned-native-history",
strength: "strong" as const,
}
: thread.nativeThreadRef,
}
: thread,
),
});

yield* harness.start;
yield* harness.start;
Expand All @@ -1821,6 +1842,9 @@ effectIt.effect(
]);
expect(projection.providerTurns).toEqual([]);
expect(projection.checkpoints).toEqual([]);
expect(projection.providerThreads.find((thread) => thread.id === target.id)?.status).toBe(
providerThreadStatus,
);
}),
);

Expand Down
26 changes: 18 additions & 8 deletions apps/server/src/orchestration-v2/ProviderTurnStartService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,12 @@ export const layer: Layer.Layer<
},
providerThreadUpdate: {
...providerThread,
status: providerThread.nativeThreadRef === null ? "not_loaded" : "idle",
status:
providerThread.status === "error"
? "error"
: providerThread.nativeThreadRef === null
? "not_loaded"
: "idle",
updatedAt: now,
},
});
Expand Down Expand Up @@ -1074,6 +1079,8 @@ export const layer: Layer.Layer<
type: "provider_resume_fallback",
});
const createdAt = yield* DateTime.now;
const boundary = ContextHandoffService.contextBoundaryOrdinal(projection.runs);
const from = Math.max(1, boundary);
const handoff = yield* contextHandoffService.prepareProviderHandoff({
threadId: projection.thread.id,
targetRunId: run.id,
Expand All @@ -1082,15 +1089,18 @@ export const layer: Layer.Layer<
toProviderThreadId: providerThread.id,
fromProviderInstanceId: providerThread.providerInstanceId,
toProviderInstanceId: run.providerInstanceId,
coveredRunOrdinals: { from: 1, to: Math.max(1, run.ordinal - 1) },
coveredRunOrdinals: { from, to: Math.max(from, run.ordinal - 1) },
strategy: "full_thread_summary",
runs: projection.runs,
items: (yield* projectionStore.getTurnStartHistory(input.threadId)).filter(
(item) =>
item.runId === null ||
projection.runs.some(
(source) => source.id === item.runId && source.ordinal < run.ordinal,
),
items: (yield* projectionStore.getTurnStartHistory(input.threadId)).filter((item) =>
item.runId === null
? boundary === 0
: projection.runs.some(
(source) =>
source.id === item.runId &&
source.ordinal >= from &&
source.ordinal < run.ordinal,
),
),
createdAt,
});
Expand Down
Loading
Loading