Skip to content

Fix timer heartbeats resuming sessions without restoring project workspace#1907

Open
konbluesky wants to merge 2 commits intopaperclipai:masterfrom
konbluesky:fix/timer-session-workspace-restore
Open

Fix timer heartbeats resuming sessions without restoring project workspace#1907
konbluesky wants to merge 2 commits intopaperclipai:masterfrom
konbluesky:fix/timer-session-workspace-restore

Conversation

@konbluesky
Copy link
Copy Markdown

@konbluesky konbluesky commented Mar 27, 2026

Thinking path

Paperclip's heartbeat model is supposed to let agents continue useful work across short wake/sleep cycles. For code-focused agents, that only works if session reuse and workspace reuse stay aligned. In the reported failure mode, timer heartbeats correctly reused an OpenCode session but incorrectly fell back to agent_home as the current workspace. That split caused OpenCode to treat the managed project checkout as external_directory, so even basic repo commands were denied. The fix therefore targets the control-plane workspace/session handoff itself: persist enough session metadata to reconstruct the original project workspace, and only apply that recovery when a fresh wake is not explicitly required.

Summary

  • persist session params and session display ids in agent runtime state so timer heartbeats can recover the last known project workspace
  • reuse the latest task-session/project workspace metadata when runtime state only points at an agent_home fallback
  • avoid resuming OpenCode project sessions inside agent_home timer runs, which currently triggers external_directory permission failures

Problem

Timer heartbeats can reuse an existing OpenCode session while falling back to agent_home as the current workspace. When the reused session was created in a managed project workspace, OpenCode treats the project directory as external_directory relative to the fallback workspace and rejects basic commands like git status.

Fix

This patch teaches heartbeat runtime recovery to carry forward workspace metadata with the resumed session and to recover project workspace context from runtime/task session history when task context is missing.

Validation

  • rebuilt the Paperclip Docker image on the target server successfully
  • verified a manual wakeup without task context now resolves paperclipWorkspace.cwd to the managed project workspace instead of agent_home
  • verified the agent can glob and read files inside the managed checkout without triggering external_directory

Notes

  • local pnpm test / pnpm -r typecheck were not runnable before dependency installation in the local clone
  • the server-side Docker build completed successfully after the patch

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 27, 2026

Greptile Summary

This PR fixes timer heartbeat runs that could resume an OpenCode session while using the agent-home fallback directory as the working directory — causing external_directory permission failures when the original session was created inside a managed project workspace. The fix persists sessionParams and sessionDisplayId in agentRuntimeState.stateJson after every run, and teaches the heartbeat resume path to recover that workspace context (falling back through the latest task-session and run context-snapshot) before handing params to the adapter.\n\nKey changes:\n- New exported helper readRuntimeStateSessionSnapshot reads sessionParams/sessionDisplayId out of the runtime state JSON using the adapter's codec.\n- New internal resolveRuntimeStateSessionSnapshotForRun performs a multi-step fallback: runtime state → latest task-session (if session ID matches) → latest run context-snapshot, preferring whichever source has a non-fallback cwd.\n- updateRuntimeState now persists params and displayId alongside the legacy sessionId.\n- previousSessionParams in the main run-execution flow gains a fourth fallback to runtimeSessionSnapshot.params.\n\nP1 concern: runtimeSessionSnapshot.params is applied unconditionally as the last fallback, including when resetTaskSession = true (e.g. wakeReason: \"issue_assigned\"). This bypasses the existing guard (runtimeSessionFallback = null when resetTaskSession) and could cause the agent to resume a stale session on a new task assignment instead of starting fresh.\n\nPR description note: per CONTRIBUTING.md, PR descriptions should open with a "thinking path" — a top-down chain of reasoning from Paperclip's overall purpose down to exactly why this fix is needed and what benefit it delivers. The current description has a good Problem/Fix/Validation structure but is missing that narrative trace.

Confidence Score: 4/5

Safe to merge for the timer-heartbeat path, but the resetTaskSession guard bypass is a real regression risk for issue_assigned wakes and should be addressed first.

One P1 finding: runtimeSessionSnapshot.params feeds into previousSessionParams even when resetTaskSession=true, which can cause a stale session to be resumed on a new issue assignment. The core timer-heartbeat fix is well-structured and the new tests are accurate, but the regression on the issue_assigned wake path is a present defect on a production code path.

server/src/services/heartbeat.ts — specifically the previousSessionParams chain around line 2166 and the unconditional resolveRuntimeStateSessionSnapshotForRun call at line 2153.

Important Files Changed

Filename Overview
server/src/services/heartbeat.ts Adds readRuntimeStateSessionSnapshot and resolveRuntimeStateSessionSnapshotForRun to persist and recover session/workspace metadata across timer heartbeats; also widens updateRuntimeState's session argument to carry params/displayId. Contains a P1 regression where runtimeSessionSnapshot.params is used as a fallback even when resetTaskSession=true, potentially resuming stale sessions on new issue assignments.
server/src/tests/heartbeat-workspace-session.test.ts Adds two unit tests for the new readRuntimeStateSessionSnapshot export — covers the happy-path (params + explicit displayId) and the fallback (derives displayId from sessionParams). Tests are accurate and self-contained.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 2166-2170

Comment:
**Runtime snapshot used even when session reset is requested**

`runtimeSessionSnapshot.params` is now the last fallback in the `previousSessionParams` chain. This means it is also evaluated for wake events that intentionally request a fresh session — such as `wakeReason: "issue_assigned"`, where `shouldResetTaskSessionForWake` returns `true`.

When a new issue is assigned, `taskSessionForRun` is explicitly set to `null` (line 2152) to prevent session reuse. But because `runtimeSessionSnapshot.params` can be non-null (carrying the session ID and workspace from the previous run), it bypasses that guard and populates `previousSessionParams` with the stale session. This in turn populates `previousSessionDisplayId` and `runtimeSessionParamsForAdapter`, causing the adapter to resume the old session — which is the opposite of the intended "fresh session" behavior.

Note that `runtimeSessionFallback` on line 2435 was deliberately set to `null` when `resetTaskSession` is true for exactly this reason.

The fix is to gate the fallback on `!resetTaskSession`:

```suggestion
    const previousSessionParams =
      explicitResumeSessionParams ??
      (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
      normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)) ??
      (!resetTaskSession ? runtimeSessionSnapshot.params : null);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 2153-2157

Comment:
**Unconditional DB queries even when snapshot won't be used**

`resolveRuntimeStateSessionSnapshotForRun` always runs up to two extra DB queries — one against `agentTaskSessions` and one via `getLatestRunForSession` — regardless of whether the result will actually be needed. Since `runtimeSessionSnapshot.params` is the last item in the `??` chain, it is skipped whenever any earlier source (`explicitResumeSessionParams`, `explicitResumeSessionDisplayId`, or `taskSessionForRun`) is non-null.

Consider moving the call inside the `??` chain or lazily resolving it only when the earlier sources are all null, to avoid the overhead on the common (task-session-present) path.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix heartbeat timer session workspace re..." | Re-trigger Greptile

Comment on lines +2166 to +2170
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)) ??
runtimeSessionSnapshot.params;
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.

P1 Runtime snapshot used even when session reset is requested

runtimeSessionSnapshot.params is now the last fallback in the previousSessionParams chain. This means it is also evaluated for wake events that intentionally request a fresh session — such as wakeReason: "issue_assigned", where shouldResetTaskSessionForWake returns true.

When a new issue is assigned, taskSessionForRun is explicitly set to null (line 2152) to prevent session reuse. But because runtimeSessionSnapshot.params can be non-null (carrying the session ID and workspace from the previous run), it bypasses that guard and populates previousSessionParams with the stale session. This in turn populates previousSessionDisplayId and runtimeSessionParamsForAdapter, causing the adapter to resume the old session — which is the opposite of the intended "fresh session" behavior.

Note that runtimeSessionFallback on line 2435 was deliberately set to null when resetTaskSession is true for exactly this reason.

The fix is to gate the fallback on !resetTaskSession:

Suggested change
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)) ??
runtimeSessionSnapshot.params;
const previousSessionParams =
explicitResumeSessionParams ??
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)) ??
(!resetTaskSession ? runtimeSessionSnapshot.params : null);
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 2166-2170

Comment:
**Runtime snapshot used even when session reset is requested**

`runtimeSessionSnapshot.params` is now the last fallback in the `previousSessionParams` chain. This means it is also evaluated for wake events that intentionally request a fresh session — such as `wakeReason: "issue_assigned"`, where `shouldResetTaskSessionForWake` returns `true`.

When a new issue is assigned, `taskSessionForRun` is explicitly set to `null` (line 2152) to prevent session reuse. But because `runtimeSessionSnapshot.params` can be non-null (carrying the session ID and workspace from the previous run), it bypasses that guard and populates `previousSessionParams` with the stale session. This in turn populates `previousSessionDisplayId` and `runtimeSessionParamsForAdapter`, causing the adapter to resume the old session — which is the opposite of the intended "fresh session" behavior.

Note that `runtimeSessionFallback` on line 2435 was deliberately set to `null` when `resetTaskSession` is true for exactly this reason.

The fix is to gate the fallback on `!resetTaskSession`:

```suggestion
    const previousSessionParams =
      explicitResumeSessionParams ??
      (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
      normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)) ??
      (!resetTaskSession ? runtimeSessionSnapshot.params : null);
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +2153 to +2157
const runtimeSessionSnapshot = await resolveRuntimeStateSessionSnapshotForRun({
agent,
runtime,
codec: sessionCodec,
});
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.

P2 Unconditional DB queries even when snapshot won't be used

resolveRuntimeStateSessionSnapshotForRun always runs up to two extra DB queries — one against agentTaskSessions and one via getLatestRunForSession — regardless of whether the result will actually be needed. Since runtimeSessionSnapshot.params is the last item in the ?? chain, it is skipped whenever any earlier source (explicitResumeSessionParams, explicitResumeSessionDisplayId, or taskSessionForRun) is non-null.

Consider moving the call inside the ?? chain or lazily resolving it only when the earlier sources are all null, to avoid the overhead on the common (task-session-present) path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/heartbeat.ts
Line: 2153-2157

Comment:
**Unconditional DB queries even when snapshot won't be used**

`resolveRuntimeStateSessionSnapshotForRun` always runs up to two extra DB queries — one against `agentTaskSessions` and one via `getLatestRunForSession` — regardless of whether the result will actually be needed. Since `runtimeSessionSnapshot.params` is the last item in the `??` chain, it is skipped whenever any earlier source (`explicitResumeSessionParams`, `explicitResumeSessionDisplayId`, or `taskSessionForRun`) is non-null.

Consider moving the call inside the `??` chain or lazily resolving it only when the earlier sources are all null, to avoid the overhead on the common (task-session-present) path.

How can I resolve this? If you propose a fix, please make it concise.

@konbluesky
Copy link
Copy Markdown
Author

Thanks — I followed up on both points.\n\nWhat changed in the latest push:\n- gated the runtime-state fallback so it is not used when is true, preserving the intended fresh-session behavior for wakes\n- made runtime-state session recovery lazy by resolving it only when explicit/task-session sources are absent\n\nI also kept the server-side validation loop running against the Paperclip deployment that originally reproduced the bug. The timer/on-demand no-task path now restores the managed project workspace instead of resuming a project session inside .

@konbluesky
Copy link
Copy Markdown
Author

Thanks — I pushed a follow-up fix for both review points.

Changes in the latest commit:

  • gated the runtime-state fallback so it is not used when resetTaskSession is true, preserving fresh-session behavior for issue_assigned wakes
  • made runtime-state session recovery lazy, so the extra DB lookups only happen when explicit resume params and task-session params are both absent

I also revalidated the fix against the Paperclip deployment that originally reproduced the bug. After rebuilding the server with the patch, a no-task wakeup was able to restore the managed project workspace instead of resuming a project session inside agent_home.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants