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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
57 changes: 53 additions & 4 deletions apps/server/src/t3x/autoResume/Reactor.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -309,7 +309,10 @@ describe("AutoResumeReactor (integration)", () => {
}).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", () =>
// Regression for radroid/t3code#39 — the reported shape: the user types "keep going"
// while the resume is pending, that message is itself rejected by a limit so it starts
// nothing, and the arm used to be destroyed as `user-took-over`. It must survive.
it.effect("still resumes when the user posted a message while the resume was pending", () =>
Effect.gen(function* () {
const { dispatched, modelRef, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100),
Expand All@@ -318,7 +321,7 @@ describe("AutoResumeReactor (integration)", () => {
yield* Effect.gen(function* () {
yield* settleUntil(scheduledOne(store), "detection to schedule from the pre-loaded event");

// User sends a new message before the resume is due -> guard must cancel.
// A new user message lands, and goes nowhere: the thread is still idle at wake time.
yield* Ref.set(
modelRef,
readModel({
Expand All@@ -329,10 +332,56 @@ describe("AutoResumeReactor (integration)", () => {
}),
);

yield* advancePastResume;
yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");

const commands = yield* Ref.get(dispatched);
assert.notInclude(types(commands), "thread.turn.start");
assert.strictEqual(
commands.filter((c) => c.type === "thread.turn.start").length,
1,
"the resume must fire despite the newer user message",
);
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("cancelled")),
"no cancellation may be posted",
);
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);

// The other half of #39: a second, longer limit arriving while a resume is armed used
// to be dropped as `already-pending`, so the arm fired into a window still shut.
it.effect("moves a pending resume out when a longer limit supersedes it", () =>
Effect.gen(function* () {
const { dispatched, deps, store } = yield* harness(readModel({}), [
rejectedEvent(100), // due at 100_000 + 60_000 margin
rejectedEvent(1000), // due at 1_000_000 + 60_000 margin
]);

yield* Effect.gen(function* () {
yield* settleUntil(
store.listPending.pipe(Effect.map((p) => p[0]?.resumeAtMs === 1_060_000)),
"the second rejection to supersede the first arm",
);

assert.strictEqual(
(yield* store.listPending).length,
1,
"superseding replaces the arm, it does not add a second one",
);

const kinds = (yield* Ref.get(dispatched))
.filter((c) => c.type === "thread.activity.append")
.map((c) => (c as unknown as { activity: { kind: string } }).activity.kind);
assert.deepStrictEqual(kinds, ["t3x.auto-resume.scheduled", "t3x.auto-resume.rescheduled"]);

// The original 160_000 due time passes without firing: that window is still shut.
yield* advancePastResume; // 8 x 30s = 240_000ms
assert.notInclude(types(yield* Ref.get(dispatched)), "thread.turn.start");

yield* advanceUntil(dispatchedIncludes(dispatched, "thread.turn.start"), "the resume turn");
}).pipe(Effect.provide(AutoResumeReactorLive.pipe(Layer.provideMerge(deps))));
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))),
);
Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/t3x/autoResume/Reactor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -142,7 +142,7 @@ const makeSupervisor = Effect.gen(function* () {

const plan = planSchedule({
verdict,
hasPending: record.pending !== null,
pendingResumeAtMs: record.pending?.resumeAtMs ?? null,
nowMs,
firedRecently,
firedInCapWindow,
Expand All@@ -156,6 +156,11 @@ const makeSupervisor = Effect.gen(function* () {
const thread = snapshot.threads.find((t) => t.id === threadId);
if (!thread || threadIsGone(thread) || !isClaudeThread(thread)) return;

// Replacing an existing arm (a later window superseded it — see decide.ts). The
// fresh `captureBaseline` below is what re-baselines the resume onto whatever the
// thread looks like now, so a supersede is also the re-arm path for #39.
const superseded = record.pending !== null;

yield* store.schedule({
threadId,
resumeAtMs: plan.resumeAtMs,
Expand All@@ -165,11 +170,14 @@ const makeSupervisor = Effect.gen(function* () {
});

const waitMinutes = Math.max(0, Math.round((plan.resumeAtMs - nowMs) / 60_000));
const limitType = verdict.rateLimitType ?? "window";
yield* appendActivity(
threadId,
"info",
"t3x.auto-resume.scheduled",
`Usage limit reached (${verdict.rateLimitType ?? "window"}). Auto-resume scheduled in ~${waitMinutes} min.`,
superseded ? "t3x.auto-resume.rescheduled" : "t3x.auto-resume.scheduled",
superseded
? `Usage limit window pushed back (${limitType}). Auto-resume rescheduled to ~${waitMinutes} min from now.`
: `Usage limit reached (${limitType}). Auto-resume scheduled in ~${waitMinutes} min.`,
);
});

Expand Down
35 changes: 33 additions & 2 deletions apps/server/src/t3x/autoResume/decide.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,10 +14,12 @@ const rejected = (o: Partial<RateLimitVerdict> = {}): RateLimitVerdict => ({
});

// Base input = a fresh rejection, nothing pending, no prior fires, not capped.
// With the default verdict (resetsAtMs 1_000_000) and nowMs 0 the computed resume is
// 1_000_000 + 60_000 margin = 1_060_000; the pending-comparison tests are anchored on that.
const plan = (o: Partial<PlanScheduleInput> = {}) =>
planSchedule({
verdict: rejected(),
hasPending: false,
pendingResumeAtMs: null,
nowMs: 0,
firedRecently: 0,
firedInCapWindow: 0,
Expand All@@ -41,7 +43,36 @@ describe("planSchedule", () => {
});

it("skips when a resume is already pending (dedupes telemetry re-emits)", () => {
expect(plan({ hasPending: true })).toEqual({ kind: "skip", reason: "already-pending" });
// Same window re-emitted: computed 1_060_000 is not later than the arm, so nothing moves.
expect(plan({ pendingResumeAtMs: 1_060_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// radroid/t3code#39: a second, longer limit landing on top of an armed shorter one used
// to be dropped, so the arm fired into a window that was still shut.
it("re-schedules when a concrete later reset window supersedes the pending arm", () => {
const p = plan({ pendingResumeAtMs: 500_000 });
expect(p.kind).toBe("schedule");
if (p.kind !== "schedule") return;
expect(p.resumeAtMs).toBe(1_000_000 + config.safetyMarginMs);
});

it("keeps the existing arm when the new window opens earlier", () => {
expect(plan({ pendingResumeAtMs: 5_000_000 })).toEqual({
kind: "skip",
reason: "already-pending",
});
});

// The churn guard. A ladder-derived time is `nowMs + delay`, so it is later on every
// re-emit; if those superseded, a persistent limit would push the arm out forever and
// post a reschedule note each time.
it("never lets a backoff-ladder re-emit push out a pending arm", () => {
expect(
plan({ verdict: rejected({ resetsAtMs: null }), nowMs: 100_000, pendingResumeAtMs: 50_000 }),
).toEqual({ kind: "skip", reason: "already-pending" });
});

it("skips when the thread has hit the 24h cap (stops re-scheduling + misleading notes)", () => {
Expand Down
34 changes: 25 additions & 9 deletions apps/server/src/t3x/autoResume/decide.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,8 +23,11 @@ export interface SkipPlan {

export interface PlanScheduleInput {
readonly verdict: RateLimitVerdict;
/** Whether this thread already has a resume pending (one-per-thread invariant). */
readonly hasPending: boolean;
/**
* `resumeAtMs` of this thread's pending resume, or null when nothing is armed.
* Still one-pending-per-thread — a new plan replaces the arm rather than adding one.
*/
readonly pendingResumeAtMs: number | null;
readonly nowMs: number;
/** Fires for this thread within the recent backoff window — drives the ladder. */
readonly firedRecently: number;
Expand All@@ -36,10 +39,19 @@ export interface PlanScheduleInput {
/**
* Decide whether/when to schedule a resume for a rejection.
*
* Dedup is purely "one pending per thread": while a resume is pending we skip every
* telemetry re-emit (no churn). Once a resume fires, its pending is cleared, so the next
* rejection re-arms naturally — and because `firedRecently` has incremented, its resume
* is spaced out on the backoff ladder rather than tight-looping.
* Dedup is "one pending per thread": while a resume is pending we skip every telemetry
* re-emit (no churn). Once a resume fires, its pending is cleared, so the next rejection
* re-arms naturally — and because `firedRecently` has incremented, its resume is spaced
* out on the backoff ladder rather than tight-looping.
*
* One narrow exception (radroid/t3code#39): a rejection that names a CONCRETE reset time
* LATER than the pending one supersedes it. A `seven_day` limit landing on top of an
* armed `five_hour` used to be dropped outright, so the arm fired into a window that was
* still shut and burned an attempt. The exception is deliberately restricted to
* `windowOpensInFuture` — a ladder-derived time is `nowMs + delay`, which grows with
* every re-emit, so allowing those to supersede would push the arm out forever and flood
* the timeline with reschedule notes. An earlier reset time never supersedes either: the
* existing arm is already the conservative choice.
*
* The 24h cap is checked HERE (at schedule time) as well as at fire time. Checking it at
* schedule time stops a capped-out thread from re-scheduling — and re-posting a misleading
Expand All@@ -56,16 +68,20 @@ export interface PlanScheduleInput {
* skip) or blocks re-arming a persistent limit. One-pending + backoff avoids both.
*/
export function planSchedule(input: PlanScheduleInput): SchedulePlan | SkipPlan {
const { verdict, hasPending, nowMs, firedRecently, firedInCapWindow, config } = input;
const { verdict, pendingResumeAtMs, nowMs, firedRecently, firedInCapWindow, config } = input;

if (!verdict.rejected) return { kind: "skip", reason: "not-rejected" };
if (hasPending) return { kind: "skip", reason: "already-pending" };
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

const windowOpensInFuture = verdict.resetsAtMs !== null && verdict.resetsAtMs > nowMs;
const resumeAtMs = windowOpensInFuture
? verdict.resetsAtMs! + config.safetyMarginMs
: nowMs + backoffDelayMs(config.backoffLadderMs, firedRecently);

if (pendingResumeAtMs !== null) {
const supersedes = windowOpensInFuture && resumeAtMs > pendingResumeAtMs;
if (!supersedes) return { kind: "skip", reason: "already-pending" };
}
if (firedInCapWindow >= config.maxResumesPer24h) return { kind: "skip", reason: "capped" };

return { kind: "schedule", resumeAtMs };
}
27 changes: 25 additions & 2 deletions apps/server/src/t3x/autoResume/guards.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,15 +160,38 @@ describe("cancelReason", () => {
expect(cancelReason(base(), baseline())).toBeNull();
});

it("detects a new user message", () => {
// Regression for radroid/t3code#39. The removed `user-took-over` branch cancelled on
// any newer user message. The message that trips it is typically "keep going" typed at
// the usage-limit banner — and is itself rejected by the same limit, so it starts
// nothing and the thread is left with no pending resume at all.
it("does NOT cancel when a new user message arrived while the resume was pending", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
latestTurnId: "turn-1",
});
expect(cancelReason(thread, baseline())).toBe("user-took-over");
expect(cancelReason(thread, baseline())).toBeNull();
});

// The baseline still records it — it is persisted with the pending resume and is what
// makes a stranded arm diagnosable from the state file.
it("still captures the newest user message id in the baseline", () => {
expect(baseline().newestUserMessageId).toBe("u1");
});

// What the removed branch was actually reaching for: a user who is driving right now.
// That is `progressing`, and it still cancels.
it("cancels a new user message that is actually being worked on", () => {
const thread = makeThread({
messages: [
{ id: "u1", role: "user" },
{ id: "u2", role: "user" },
],
status: "running",
});
expect(cancelReason(thread, baseline())).toBe("progressing");
});

it("detects a new turn since scheduling", () => {
Expand Down
26 changes: 23 additions & 3 deletions apps/server/src/t3x/autoResume/guards.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,7 +12,12 @@ import type { OrchestrationThread } from "@t3tools/contracts";

/**
* Baseline captured when a resume is scheduled, re-checked immediately before dispatch
* to detect that the thread moved on (user took over, a new turn ran, etc.).
* to detect that the thread moved on.
*
* `newestUserMessageId` is recorded but is deliberately NOT a cancel condition — see the
* block in `cancelReason` (radroid/t3code#39). It stays in the shape because it is part
* of the persisted pending-resume record (`state.ts`), it is re-captured on every
* (re)schedule, and it is what makes a stranded arm diagnosable from the state file.
*/
export interface GuardBaseline {
readonly newestUserMessageId: string | null;
Expand DownExpand Up@@ -114,7 +119,6 @@ export type CancelReason =
| "not-claude"
| "progressing"
| "awaiting-input"
| "user-took-over"
| "thread-advanced";

/**
Expand All@@ -129,7 +133,23 @@ export function cancelReason(
if (!isClaudeThread(thread)) return "not-claude";
if (threadIsProgressing(thread)) return "progressing";
if (hasOpenBlockingRequest(thread.activities)) return "awaiting-input";
if (newestUserMessageId(thread) !== baseline.newestUserMessageId) return "user-took-over";
// A new user message does NOT cancel (radroid/t3code#39). This branch used to read
// `newestUserMessageId(thread) !== baseline.newestUserMessageId` and return
// "user-took-over", which is the same negative-evidence mistake #6 fixed one line
// below: "a message exists that wasn't there when we armed" is not evidence that the
// human took the wheel. In practice it is the opposite — the message that trips it is
// typed the moment the usage-limit banner appears, which is exactly when someone is
// stepping away ("keep going through the night"). That message is then usually rejected
// by the same limit, so it starts nothing, and the wake tick destroys the only pending
// resume. Measured on this install: 4 of 17 armed resumes (~24%) lost this way.
//
// Everything the branch was reaching for is still covered:
// * the user is actively driving right now -> `progressing`
// * the thread is blocked on a prompt -> `awaiting-input`
// * a different turn is live at fire time -> `thread-advanced`
// * the user wants no resume at all -> the per-thread switch, honoured
// in `Reactor.fireOne`.
//
// 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
Expand Down
8 changes: 8 additions & 0 deletions docs/t3x/loop/DESIGN.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,14 @@ is PubSub-backed, so a second subscriber does not steal auto-resume's events. Be
that is rejected by a limit produces no `updatedAt` movement, so it takes a strike and the thread stops
after two.

> **Update 2026-08-11 (#39).** The `user-took-over` branch quoted above is **gone** — a newer user
> message no longer cancels a pending resume, because the same "keep going" message that tripped it is
> typically the user stepping away, and it was destroying ~24% of armed resumes. So a loop nudge landing
> mid-wait no longer destroys rate-limit recovery. **Guard #9 still stands**, for the other reason:
> nudging a thread that is sitting inside a usage-limit window is pointless work. What changes is that
> #9 is now a politeness rule rather than the only thing standing between a nudge and a stranded thread.
> The rest of §6 — the second fiber, `rateLimitedUntilMs`, the strike interlock — is unaffected.

---

## 7. Budget visibility & settings
Expand Down
Loading