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:
- An administrative settlement rewrites an old thread's
updatedAt to "now."
- Its existing completed/failed awareness state appears freshly terminal.
- The two-minute guard passes.
- 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
- Use a build before
0.0.38-nightly.20260901.1246.
- Link an iOS device with completion notifications enabled.
- Accumulate many nonarchived, unsettled threads inactive for more than three days.
- Update to
0.0.38-nightly.20260901.1246.
- Launch Desktop.
- Observe the immediate automatic-settlement sweep.
- 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.
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
1246introduced server-owned automatic thread settlement in PR #8600:#8600
On startup, the new
ThreadSettlementReactorimmediately sweeps eligible threads:t3code/apps/server/src/orchestration/ThreadSettlementReactor.ts
Lines 154 to 166 in b883fc0
The default inactivity threshold is three days:
t3code/packages/contracts/src/settings.ts
Lines 58 to 67 in b883fc0
On this machine, first startup of
1246emitted 44thread.settledevents for the existing backlog within 2.4 seconds. All selected threads had been inactive longer than three days.Processing
thread.settledupdates the thread's generalupdatedAttimestamp to the settlement event time:t3code/apps/server/src/orchestration/Layers/ProjectionPipeline.ts
Lines 691 to 704 in b883fc0
The agent-awareness relay publishes
thread.settledbecause unlisted event types default to publishable:t3code/apps/server/src/relay/AgentAwarenessRelay.ts
Lines 69 to 94 in b883fc0
The notification relay suppresses terminal notifications older than two minutes, but uses that same general thread
updatedAtvalue:t3code/infra/relay/src/agentActivity/ApnsDeliveries.ts
Lines 329 to 362 in b883fc0
Therefore:
updatedAtto "now."This explains both selectivity:
settledOverrideis 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
0.0.38-nightly.20260901.1246.0.0.38-nightly.20260901.1246.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 CLI0.0.37.Environment
Darwin arm64
27.0.0; Nodev24.18.0; T3 Code Nightly desktop app; linked iOS mobile app, mobile version unknown.Evidence
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:
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 threadupdatedAt.Filed by
Codex (GPT-5) via
t3 triage.