[Bug]: Open PR links block inactivity settlement even with Keep threads with a PR off#19728

opened11d agobyDdajiaohuang

Before submitting

  • I searched existing issues and pull requests for the inactivity/PR-retention behavior.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Summary

An idle thread with a persisted, synced open link in pullRequests[] is never settled by inactivity, even when Keep threads with a PR is off. The early open-link return in resolveAutoSettlementAt bypasses both that setting and the inactivity threshold.

This is a source-established failure with an isolated execution of the production policy function bodies. I have not reproduced the multi-day flow in a running application.

Steps to reproduce

Supported application path to check:

  1. Enable inactivity auto-settlement for the thread's environment/project, for example after 3 days. Leave Keep threads with a PR off.
  2. Use Link pull request to link an open PR to an ordinary thread, and let its snapshot sync to state: "open".
  3. Leave that thread idle beyond the configured threshold. It must be unpinned, unsnoozed, with no manual settlement override, pending question/approval, active run, background task, or PR watch.
  4. Let the server's settlement sweep run. The open persisted link causes the policy to return null before considering inactivity.

Deterministic policy reproduction, without waiting days:

  • Fix nowMs at 2026-10-15T02:38:56Z.
  • Set all activity timestamps on an otherwise eligible thread to 2026-10-11T02:38:56Z, with a 3-day inactivity threshold.
  • Give it one visible manual PR link with a fully synced open snapshot and no watch.
  • Call resolveAutoSettlementAt with pullRequest: null and autoSettleKeepPullRequests: false, as the projection-backed sweep does.

I executed the unchanged policy/helper function bodies from main, stripping TypeScript and export declarations, with real effect/DateTime 4.0.4. The candidate guard returned true. Results:

text
1legacy link + open pullRequest argument, keep=false: 2026-10-11T02:38:56.000Z
2persisted open pullRequests[] link, keep=false: null
3persisted open pullRequests[] link, keep=true: null

The first row is the control. The second should return the idle activity timestamp, as the first does. This probe does not instantiate the service, database, transport, provider, or UI.

Expected behavior

With Keep threads with a PR off, an open PR does not prevent an otherwise eligible thread from settling after the inactivity threshold. This is the behavior documented in the thread guide and described by merged #14642. Turning the setting on should keep the thread active. This report does not ask to treat an unsynced/unknown snapshot as terminal.

Actual behavior and source trace

  • The public link command persists links into thread.pullRequests, and sync fills their snapshots.
  • Settlement candidate loading preserves those links from the stored thread.
  • The sweep passes the project-resolved retention setting into the policy and first attempts projection-backed settlement with pullRequest: null. Threads with persisted visible links are then excluded from the legacy source-control lookup path, so that path cannot compensate.
  • In the policy, links.some(link => link.snapshot === null || link.snapshot.state === "open") returns null at line 210. The setting check at line 242 and inactivity calculation below it are never reached for a synced open link.
  • The existing retention-setting test sets only the legacy linkedPullRequest field. It does not exercise the persisted pullRequests[] representation that takes the earlier return.

Impact

Minor bug or occasional failure: PR-linked threads can remain active indefinitely despite the configured inactivity rule and retention switch being off.

Version or commit

main @ ec1313c8f8333292506f05a355bb72d308a455d8

Environment and validation limits

Source/policy audit on Windows; Node v24.14.1, Effect 4.0.4 (the repository's pinned version). No live application session or multi-day sweep was run, and no full repository test suite was run. The source trace above establishes the supported entry path separately from the isolated policy reproduction.

Related work

#14642 introduced the opt-in retention setting and says its default is off. #9610 previously fixed open PRs blocking inactivity settlement in the earlier orchestration layer. I did not find an existing issue or open PR for this current V2 pullRequests[] short-circuit.

Workaround

Manual settlement remains available for an idle thread. No runtime patch or user-state change was made during this investigation.

Note

Grok responding on behalf of Julius.

Confirmed on main. This is a bug, not intended behavior.

In resolveAutoSettlementAt (apps/server/src/orchestration-v2/ThreadSettlementService.ts), the links.some(link => link.snapshot === null || link.snapshot.state === "open") early return runs before the autoSettleKeepPullRequests check and before the inactivity threshold. So any thread with a visible open (or not yet synced) link in pullRequests[] never settles from inactivity, whatever the setting says.

That contradicts the intended behavior:

  • #9610 made inactive threads with open PRs settle.
  • #14642 added Keep threads with a PR as an opt-in, off by default.
  • docs/user/thread-sidebar.md says an open PR doesn't prevent inactivity settlement unless that setting is on.

Likely fix: keep the early return for merge/close resolution only, and let open links fall through to the autoSettleKeepPullRequests and inactivity checks. A null snapshot should still block settlement, as you noted. A regression test with a persisted open link and keep=false should cover it.

Synced from GitHub

Could you assign this issue to @dajiaohuang if it is available? I'd like to take on the confirmed inactivity-settlement fix, keeping unsynced snapshots protected as described in triage.

Reproduced on Linux with the web dev server at 70e5e384da. Created an idle thread through the app command API and used Link pull request in the command palette to attach an open PR; both stack links synced to open, with no watch. Settings were three days and Keep threads with a PR off.

Restarted the same isolated server with an external Date.now preload advancing its clock four days. Its real settlement sweep settled the unlinked control, while the PR-linked thread kept settledOverride: null and settledAt: null. This uses simulated time, not a four-day wall-clock wait; production code was unchanged.

Runtime state transcript

PR-linked thread remains active after the sweep; unlinked control is settled