Skip to content

Auto-settlement marks old threads fresh and sends completion pushes on first launch #9057

Description

@eimexdev

What happened

After updating T3 Code Nightly on macOS, the linked iOS app immediately received roughly 50+ thread-completion notifications. They appeared concentrated on very old threads, not newly completed work.

Expected: automatically settling old threads should not send completion notifications.

Actual: the first launch after updating treated old threads as freshly completed.

Diagnosis

Nightly 1246 introduced server-owned automatic thread settlement in PR #8600:

#8600

On startup, the new ThreadSettlementReactor immediately sweeps eligible threads:

const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn(
"ThreadSettlementReactor.start",
)(function* () {
const settingsChanges = yield* settingsService.subscribeChanges;
const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
yield* forkParked(
Effect.gen(function* () {
yield* worker.enqueue(undefined);
yield* worker.drain;
}).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
);

The default inactivity threshold is three days:

export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1;
export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90;
export const SidebarAutoSettleAfterDays = Schema.Number.check(
Schema.isBetween({
minimum: MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
maximum: MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
}),
);
export type SidebarAutoSettleAfterDays = typeof SidebarAutoSettleAfterDays.Type;
export const DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS: SidebarAutoSettleAfterDays = 3;

On this machine, first startup of 1246 emitted 44 thread.settled events for the existing backlog within 2.4 seconds. All selected threads had been inactive longer than three days.

Processing thread.settled updates the thread's general updatedAt timestamp to the settlement event time:

case "thread.settled": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
settledOverride: "settled",
settledAt: event.payload.settledAt,
unsettledAt: null,
updatedAt: event.payload.updatedAt,
});

The agent-awareness relay publishes thread.settled because unlisted event types default to publishable:

export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean {
switch (event.type) {
case "thread.message-sent":
case "thread.turn-start-requested":
// These events express intent to start work, but the shell still contains
// the previous turn's terminal state until the provider acknowledges the
// new turn. Publishing that snapshot can queue a fresh "Done" alert just
// before the real running state arrives. Provider lifecycle events publish
// the authoritative starting/running state instead.
return false;
case "thread.proposed-plan-upserted":
case "thread.runtime-mode-set":
case "thread.interaction-mode-set":
return false;
case "thread.activity-appended":
return (
event.payload.activity.kind === "approval.requested" ||
event.payload.activity.kind === "approval.resolved" ||
event.payload.activity.kind === "provider.approval.respond.failed" ||
event.payload.activity.kind === "user-input.requested" ||
event.payload.activity.kind === "user-input.resolved" ||
event.payload.activity.kind === "runtime.error"
);
default:
return true;
}

The notification relay suppresses terminal notifications older than two minutes, but uses that same general thread updatedAt value:

// Completions replayed long after the fact (server restarts republish every
// recently-finished thread) must not ring the device again.
const TERMINAL_NOTIFICATION_FRESHNESS_MS = 2 * 60 * 1_000;
function notificationForAggregate(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
readonly nowMs: number;
}): ApnsNotificationPayload | null {
if (!input.target.push_token || input.aggregate === null) {
return null;
}
const preferences = parsePreferences(input.target.preferences_json);
if (!preferences?.notificationsEnabled) {
return null;
}
const activity = input.aggregate.activities[0];
if (!activity) {
return null;
}
if (activity.phase === "completed" || activity.phase === "failed") {
const updatedAtMs = Option.match(DateTime.make(activity.updatedAt), {
onNone: () => null,
onSome: (dt) => dt.epochMilliseconds,
});
if (updatedAtMs === null || input.nowMs - updatedAtMs > TERMINAL_NOTIFICATION_FRESHNESS_MS) {
return null;
}
}
const enabled =
(activity.phase === "waiting_for_approval" && preferences.notifyOnApproval) ||
(activity.phase === "waiting_for_input" && preferences.notifyOnInput) ||
(activity.phase === "completed" && preferences.notifyOnCompletion) ||
(activity.phase === "failed" && preferences.notifyOnFailure);

Therefore:

  1. An administrative settlement rewrites an old thread's updatedAt to "now."
  2. Its existing completed/failed awareness state appears freshly terminal.
  3. The two-minute guard passes.
  4. APNs queues a completion/failure notification.

This explains both selectivity:

  • Only old threads: the new sweep targeted the three-day inactivity backlog.
  • Only this update: after settlement, settledOverride is non-null, excluding those threads from future sweeps.

The exact APNs delivery count is not persisted locally. The user observed 50+ notifications; local evidence proves a 44-thread settlement batch. Confirming duplicates or additional deliveries requires relay-side delivery logs.

Steps to reproduce

  1. Use a build before 0.0.38-nightly.20260901.1246.
  2. Link an iOS device with completion notifications enabled.
  3. Accumulate many nonarchived, unsettled threads inactive for more than three days.
  4. Update to 0.0.38-nightly.20260901.1246.
  5. Launch Desktop.
  6. Observe the immediate automatic-settlement sweep.
  7. Observe stale completion notifications on iOS.

Do not repeat on production user data merely to confirm; it may trigger another notification flood.

Version

Desktop 0.0.38-nightly.20260901.1246 (b883fc066ea5c9bebbe1c3e9b4bc2471aab3685f). Triage CLI 0.0.37.

Environment

Darwin arm64 27.0.0; Node v24.18.0; T3 Code Nightly desktop app; linked iOS mobile app, mobile version unknown.

Evidence

Desktop started: 2026-09-01 10:11:22 America/Chicago
Server started:  2026-09-01 10:11:24 America/Chicago

Events from 10:11:25.802–10:11:28.216:

thread.settled                 44
thread.session-stop-requested  20
thread.session-set             20

Pre-settlement session status for those 44 threads:

stopped  24
ready    19
error     1

All 44:
- previously unsettled
- inactive beyond the default three-day threshold
- now have settledOverride = "settled"
- will be excluded from subsequent automatic-settlement sweeps

Related issues

Fix applied or workaround

No workaround applied.

The backlog sweep has completed and should not repeat for these threads. Temporary prevention for other environments:

npx t3 connect publish --disable

This disables both push notifications and Live Activities.

Potential code fix: do not publish agent awareness for thread.settled, or base terminal notification freshness on the actual turn/session terminal-transition timestamp rather than general thread updatedAt.

Filed by

Codex (GPT-5) via t3 triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions