diff --git a/apps/server/src/t3x/autoResume/Reactor.test.ts b/apps/server/src/t3x/autoResume/Reactor.test.ts index 21f0f1b778a..8c284448c3f 100644 --- a/apps/server/src/t3x/autoResume/Reactor.test.ts +++ b/apps/server/src/t3x/autoResume/Reactor.test.ts @@ -34,6 +34,9 @@ const readModel = (o: { messages?: Array<{ id: string; role: string }>; status?: string; latestTurnId?: string; + /** Explicit latestTurn override; pass null to model an idle thread whose + * projection row has no latest_turn_id (see radroid/t3code#6). */ + latestTurn?: { turnId: string; state: string } | null; }): OrchestrationReadModel => ({ snapshotSequence: 1, @@ -51,7 +54,10 @@ const readModel = (o: { settledOverride: null, messages: o.messages ?? [{ id: "u1", role: "user" }], activities: [], - latestTurn: { turnId: o.latestTurnId ?? "turn-1", state: "completed" }, + latestTurn: + o.latestTurn !== undefined + ? o.latestTurn + : { turnId: o.latestTurnId ?? "turn-1", state: "completed" }, session: { status: o.status ?? "ready", providerName: "claudeAgent" }, }, ], @@ -201,6 +207,40 @@ describe("AutoResumeReactor (integration)", () => { }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), ); + // Regression for radroid/t3code#6 — the incident shape observed in production: + // the limit lands while the turn is RUNNING (baseline captures its id); by wake + // time the turn has settled and the projection row has no latest_turn_id, so the + // snapshot reports latestTurn: null. That must NOT read as "thread-advanced". + it.effect("resumes when the limited turn has settled away by wake time (latestTurn null)", () => + Effect.gen(function* () { + const { dispatched, modelRef, deps } = yield* harness( + readModel({ status: "running", latestTurn: { turnId: "turn-1", state: "running" } }), + [rejectedEvent(100)], + ); + + yield* Effect.gen(function* () { + yield* settle; // detection schedules; baseline.latestTurnId === "turn-1" + + // The limited turn settles and the session stops during the wait — the + // projection's latest_turn_id empties out, so the snapshot's latestTurn is null. + yield* Ref.set(modelRef, readModel({ status: "stopped", latestTurn: null })); + + yield* advancePastResume; + + const commands = yield* Ref.get(dispatched); + const turnStarts = commands.filter((c) => c.type === "thread.turn.start"); + assert.strictEqual(turnStarts.length, 1, "the settled thread must resume, not cancel"); + const summaries = commands + .filter((c) => c.type === "thread.activity.append") + .map((c) => (c as unknown as { activity: { summary: string } }).activity.summary); + assert.isFalse( + summaries.some((s) => s.includes("thread-advanced")), + "no thread-advanced cancellation may be posted", + ); + }).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps)))); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), + ); + it.effect("does NOT resume when the user takes over before the window reopens", () => Effect.gen(function* () { const { dispatched, modelRef, deps } = yield* harness(readModel({}), [rejectedEvent(100)]); diff --git a/apps/server/src/t3x/autoResume/guards.test.ts b/apps/server/src/t3x/autoResume/guards.test.ts index 66af1f0725f..ec61da0f68b 100644 --- a/apps/server/src/t3x/autoResume/guards.test.ts +++ b/apps/server/src/t3x/autoResume/guards.test.ts @@ -166,6 +166,25 @@ describe("cancelReason", () => { expect(cancelReason(thread, baseline())).toBe("thread-advanced"); }); + // Regression for radroid/t3code#6: the projection populates latest_turn_id only while + // a turn is active, so a limit captured mid-turn (baseline has the running turn's id) + // always sees latestTurn: null once that turn settles. Null is "no active turn", not + // advancement — cancelling here killed every real-world resume. + it("does NOT treat a settled-away turn (latestTurn null at fire) as advancement", () => { + const thread = makeThread({ + messages: [{ id: "u1", role: "user" }], + status: "stopped", + }); + expect(thread.latestTurn).toBeNull(); + expect(cancelReason(thread, baseline())).toBeNull(); + }); + + it("still detects advancement when a turn exists but the baseline had none", () => { + const noTurnBaseline = captureBaseline(makeThread({ messages: [{ id: "u1", role: "user" }] })); + const thread = makeThread({ messages: [{ id: "u1", role: "user" }], latestTurnId: "turn-9" }); + expect(cancelReason(thread, noTurnBaseline)).toBe("thread-advanced"); + }); + it("blocks when awaiting input", () => { const thread = makeThread({ messages: [{ id: "u1", role: "user" }], diff --git a/apps/server/src/t3x/autoResume/guards.ts b/apps/server/src/t3x/autoResume/guards.ts index c68d8801558..6ba150b7202 100644 --- a/apps/server/src/t3x/autoResume/guards.ts +++ b/apps/server/src/t3x/autoResume/guards.ts @@ -130,6 +130,17 @@ export function cancelReason( if (threadIsProgressing(thread)) return "progressing"; if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input"; if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over"; - if ((thread.latestTurn?.turnId ?? null) !== baseline.latestTurnId) return "thread-advanced"; + // Advancement needs POSITIVE evidence: a different, non-null turn id. The snapshot's + // `latestTurn` is joined on `projection_threads.latest_turn_id`, which is populated + // only while a turn is active — so a usage limit that lands mid-turn captures the + // running turn's id in the baseline, and by fire time (turn settled, session idle) + // the snapshot reports `latestTurn: null`. That null means "no active turn", not + // "the thread moved on"; treating it as advancement cancelled every real resume + // (radroid/t3code#6). A genuine user takeover is caught above via the newest user + // message; active work is caught by `progressing`. + const currentTurnId = thread.latestTurn?.turnId ?? null; + if (currentTurnId !== null && currentTurnId !== baseline.latestTurnId) { + return "thread-advanced"; + } return null; }