fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24
, '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

fix(server): cap background-work deferral and report session-teardown losses - #5711

Open
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening
Open

fix(server): cap background-work deferral and report session-teardown losses#5711
gfsaaser24 wants to merge 1 commit into
pingdotgg:mainfrom
gfsaaser24:t3code/upstream-reaper-hardening

Conversation

@gfsaaser24

@gfsaaser24gfsaaser24 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

What Changed

Completes #5677. The background-liveness skip gets a wedge cap (backgroundWorkMaxIdleMs, default 4h) so a task that never reaches a terminal state cannot pin its session and child process forever. The binding's lastSeenAt now refreshes on provider-initiated turn/task lifecycle events, not just user sendTurn. session.exited flowing through the event pump marks the persisted binding stopped — guarded against stale exits from a replaced session, including replacements on a different provider instance. Claude session teardown drains still-live task ids with terminal task.completed(stopped) events, and a stream death with live tasks emits a runtime.error naming the lost agents plus a structured log.

Why

#5677 correctly stopped the reaper from killing sessions with live background work, but the edges around it are still lossy or unbounded. The deferral is unconditional, so the failure mode just flips: a wedged task now pins its session forever instead of being killed too early. lastSeenAt still only moves on user-initiated turns, so a thread doing autonomous work reads as idle from the user's last keystroke — background liveness defers the reap, but the idle clock itself stays dishonest for anything else keyed to it. Adapter-internal exits (stream end, session replace, stopAll) never pass through stopSession and leave ghost running rows the sweep re-visits forever. And when teardown does kill live agents, the user finds out on their next message, potentially 40+ minutes later.

This was diagnosed from a production day with 13 session stops across three threads, each landing 30.1–39.7 minutes after the last user message while background agents were mid-work — one session was killed while running the test suite that verifies this fix. With the heartbeat in place, the wedge cap means "no heartbeat at all for the cap window", which is the definition of wedged.

The sweep reads ThreadBackgroundLivenessService directly rather than the projected shell — the shell's backgroundLiveness is computed from the same service at snapshot-build time, so it is the same signal minus the snapshot staleness. #5677's test is kept and adapted to the capped deferral: its months-stale scenario now correctly reaps, and that boundary is pinned by its own wedge-cap test. The stale-exit guard's cross-instance test fails without the guard (verified by reverting and re-running). ProviderSessionReaper + ProviderService + ClaudeAdapter: 110/110 on this base; ProviderRuntimeIngestion: 45/45; typecheck clean.

Bigger than the contributing guide prefers — it stays one PR because the four pieces enforce one invariant (background work is neither silently killed nor allowed to pin a session, and its loss is visible), and the heartbeat is what makes the cap meaningful. Happy to split it if you'd rather take it piecewise.

Checklist

  • This PR is small and focused (focused: one subsystem, one invariant — but not small, see note above)
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (no UI changes)
  • I included a video for animation/interaction changes (n/a)

Change authored by Claude Fable 5 via Claude Code.


Note

Medium Risk
Changes core provider session teardown, persisted binding sync, and reaper heuristics; mistakes could leave ghost running sessions or reap live autonomous work, though extensive guards and tests target the production failure modes.

Overview
Tightens background-work session lifecycle so autonomous provider work is neither silently killed nor allowed to pin processes forever, and teardown losses are visible.

Claude adapter: On SDK stream exit (clean or failed) while liveTaskIds remain, the adapter now logs claude.session.stream-ended-with-live-tasks, emits a runtime.error naming the lost agents, and drains each live task with task.completed (stopped), including a second drain after stream-fiber interrupt to close races with in-flight handlers.

Provider event pump: Adds syncBindingOnRuntimeEvent so session.exited marks the persisted binding stopped (clearing ghost running rows from adapter-internal exits), with guards for stale exits (newer replacement session on the same instance, or binding owned by a different provider instance). Turn/task lifecycle events refresh lastSeenAt (60s throttle) so provider-initiated work does not look idle since the user’s last sendTurn.

Session reaper: Defers reaping idle sessions while ThreadBackgroundLivenessService reports live work, but only up to backgroundWorkMaxIdleMs (default 4h, floored above the inactivity threshold); beyond that, wedged “live” work is reaped anyway. Sweep uses the liveness service directly instead of the projected shell.

Tests and docs/internals/providers.md document the three rules (teardown reports loss, binding follows session, reaper respects background work with a cap).

Reviewed by Cursor Bugbot for commit 5aeeb1b. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Cap background-work deferral in session reaper and report live-task losses on session teardown

  • The Claude adapter's handleStreamExit now detects background tasks still tracked as live at stream termination, emits a runtime.error describing the loss, and emits task.completed (status stopped) for each live task. A structured warning is also logged.
  • The ProviderSessionReaper defers reaping idle sessions while background work is live, up to a configurable backgroundWorkMaxIdleMs cap (default 4h), after which the session is reaped regardless.
  • The ProviderService event pump now syncs persisted bindings: marks a binding stopped on session.exited (guarded against stale cross-instance or replaced-session exits), and refreshes lastSeenAt on turn/task lifecycle events with per-thread 60s throttling.
  • Tests and providers.md documentation are added for all three behaviors.
  • Behavioral Change: sessions with live background work that exceed the idle wedge cap are now reaped unconditionally, even if liveness still reports active.
📊 Macroscope summarized 5aeeb1b. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…wn losses
Builds on pingdotgg#5677, which correctly stopped the reaper from killing sessions
with live background work. This extends that fix along the edges where it
was still lossy or unbounded:
1. Wedge cap for the deferral (pingdotgg#5677 hardening). The background-liveness
skip was unconditional, so a task that never reaches a terminal state
pins its session — and the provider child process — forever.
Deferral now applies up to backgroundWorkMaxIdleMs (default 4h,
floored strictly above the inactivity threshold so a misconfigured
cap cannot silently disable deferral). The sweep reads
ThreadBackgroundLivenessService directly: the projected shell's
backgroundLiveness is computed from the same service at
snapshot-build time, so this is the same signal minus the staleness.
2. lastSeenAt heartbeat on provider-initiated activity. The binding's
lastSeenAt only refreshed on user-initiated sendTurn; turns the
provider starts on its own (background task notifications waking the
agent) and long-running tasks heartbeating via task.progress never
routed through it, so a thread doing autonomous work read as idle
from the user's last keystroke. The event pump now touches the
binding on turn/task lifecycle events, throttled per thread (spent
only after a successful upsert), turning the wedge cap into "no
heartbeat at all for the cap window" — the definition of wedged.
3. Binding consistency on session.exited. Adapter-internal exits
(stream end, session replace, stopAll) never pass through
stopSession and left ghost `running` rows the reaper kept sweeping.
The pump now marks the binding stopped, guarded against stale exits
two ways: an exit from a different provider instance than the
binding's owner is ignored (the emitting adapter's listSessions
cannot see a replacement on another instance), and a same-instance
exit is ignored when the adapter holds a session created strictly
after the exit event (NaN-safe: an unparseable timestamp cannot
count as a replacement).
4. Honest teardown loss reporting (Claude adapter). Session teardown
drains liveTaskIds, emitting a terminal task.completed(stopped) per
task — skipping ids a concurrently-resumed notification handler
already settled — and a stream death with live tasks additionally
emits a runtime.error naming the lost agents plus a structured
claude.session.stream-ended-with-live-tasks log, so the previous
silent kill is now visible both to the user and in the field.
Diagnosed from a production incident: 13 session stops across three
threads in one day, each landing 30.1-39.7 minutes after the last user
message while background agents were mid-work; one session was killed
while running the test suite that verified this fix. The pingdotgg#5677 test is
kept, adapted to the capped deferral (its months-stale scenario now
correctly reaps — that is the wedge the cap exists for, pinned by its
own test).
Verification on this base: ProviderSessionReaper + ProviderService +
ClaudeAdapter suites 110/110; ProviderRuntimeIngestion 45/45;
typecheck clean. The cross-instance guard test fails without the guard.
@coderabbitai

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d0111c1-0880-4602-9f95-2f70f03091fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actionsgithub-actionsBot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 8, 2026
) {
return;
}
if (isSessionExit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MediumLayers/ProviderService.ts:347

syncBindingOnRuntimeEvent skips the providerInstanceId ownership check for activity events (turn.*/task.*), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's lastRuntimeEvent/lastRuntimeEventAt and refresh lastSeenAt. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing event.providerInstanceId against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 347:
`syncBindingOnRuntimeEvent` skips the `providerInstanceId` ownership check for activity events (`turn.*`/`task.*`), so after a thread moves to a different provider instance, stale events still in the old subscription's stream overwrite the new binding's `lastRuntimeEvent`/`lastRuntimeEventAt` and refresh `lastSeenAt`. The stale event also consumes the thread-wide 60-second throttle, which can suppress a legitimate heartbeat from the replacement session. The session-exited branch guards against this by comparing `event.providerInstanceId` against the persisted binding, but the activity-touch branch does not. Apply the same instance-ownership guard before activity touches, and key the throttle map by instance id so overlapping stale streams cannot suppress the replacement's heartbeats.

@macroscopeapp

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces substantial new runtime behavior in session lifecycle management: lost-task reporting on teardown, binding synchronization on runtime events, and reaper deferral for background work. These are significant infrastructure changes beyond a targeted bug fix, and there is an unresolved Medium-severity finding about missing instance ownership guards for activity events.

You can customize Macroscope's approvability policy. Learn more.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L100-499 changed lines (additions + deletions).vouch:unvouchedPR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@gfsaaser24