Skip to content

cron: clean up sessions and run logs on job removal (#46369)#46475

Closed
xydt-610 wants to merge 2 commits intoopenclaw:mainfrom
xydt-610:fix/46369-cron-remove-cleanup-upstream
Closed

cron: clean up sessions and run logs on job removal (#46369)#46475
xydt-610 wants to merge 2 commits intoopenclaw:mainfrom
xydt-610:fix/46369-cron-remove-cleanup-upstream

Conversation

@xydt-610
Copy link
Copy Markdown
Contributor

Summary

  • Problem: cron.remove only deleted the job from cron.json, leaving orphaned run logs in cron/runs/ and session records in sessions.json.
  • Why it matters: Deleted jobs still appear in the Control UI session dropdown as "ghost sessions" that cannot be accessed.
  • What changed:
    • Added purgeCronJobSessions to session-reaper.ts to clean up base and run sessions from the session store.
    • Updated remove in ops.ts to call purgeCronJobSessions and delete the run log file on job removal.
  • What did NOT change (scope boundary): Existing scheduled jobs and their execution logic.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

None (internal cleanup).

Security Impact (required)

  • New permissions/capabilities? (No)
  • Secrets/tokens handling changed? (No)
  • New/changed network calls? (No)
  • Command/tool execution surface changed? (No)
  • Data access scope changed? (No)

Repro + Verification

Environment

  • OS: win32 10.0.17763
  • Runtime/container: pnpm
  • Model/provider: n/a

Steps

  1. Create a cron job.
  2. Observe run logs and session records.
  3. Delete the cron job via cron.remove.
  4. Verify run log file and session records are gone.

Expected

  • Run log file and session records are deleted.

Actual

  • Run log file and session records were previously left behind.

Evidence

  • Trace/log snippets: pnpm check passed.

Human Verification (required)

  • Verified scenarios: Code logic for identifying and removing session keys and run logs.
  • Edge cases checked: Non-existent jobs, jobs with no run logs.
  • What you did not verify: UI integration (verified via code).

Compatibility / Migration

  • Backward compatible? (Yes)
  • Config/env changes? (No)
  • Migration needed? (No)

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 14, 2026

Greptile Summary

This PR fixes an orphaned-data bug where cron.remove only deleted the job record from cron.json, leaving behind the per-job run log file (cron/runs/<jobId>.jsonl) and session entries in sessions.json that surfaced as inaccessible "ghost sessions" in the Control UI.

Changes:

  • ops.ts — captures the job reference before filtering it out, then fire-and-forgets both fs.unlink on the run log and purgeCronJobSessions on the session store after a confirmed removal.
  • session-reaper.ts — adds purgeCronJobSessions, which removes the base session key (cron:<jobId>) and all run session keys (cron:<jobId>:run:<uuid>) from the session store and immediately archives/deletes their transcripts.

Issues found:

  • The removedSessions map in purgeCronJobSessions lacks the deduplication guard present in sweepCronRunSessions (if (!has || entry.sessionFile)), which can silently drop a sessionFile reference if multiple session keys share the same sessionId.
  • Session cleanup is gated solely on resolveSessionStorePath being configured. If a deployment only sets the simpler sessionStorePath (the static path already used by the existing sweeper), the new purge is skipped entirely — and since the sweeper never prunes base sessions, a ghost base session would still linger indefinitely in that configuration.
  • Minor: parseAgentSessionKey and isCronRunSessionKey are imported from the same module via two separate import statements; they should be merged into one.

Confidence Score: 3/5

  • Safe to merge with minor issues; the core removal logic is correct but session cleanup may silently no-op in some configurations.
  • The approach is architecturally sound: job removal, persistence, and cleanup are cleanly sequenced and the async cleanup is properly fire-and-forget. However, two logic gaps reduce confidence: the missing sessionFile dedup guard could cause a session transcript to be skipped during cleanup, and the hard dependency on resolveSessionStorePath means the ghost-session fix may not apply in all valid deployment configurations.
  • src/cron/service/ops.ts (session cleanup path), src/cron/session-reaper.ts (dedup guard in purgeCronJobSessions)
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/cron/session-reaper.ts
Line: 17-18

Comment:
**Duplicate imports from same module**

`parseAgentSessionKey` and `isCronRunSessionKey` are imported from the same module in two separate statements. These should be combined into one.

```suggestion
import { parseAgentSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
```

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

---

This is a comment left during a code review.
Path: src/cron/session-reaper.ts
Line: 175-180

Comment:
**Missing deduplication guard differs from `sweepCronRunSessions`**

`removedSessions.set(entry.sessionId, entry.sessionFile)` unconditionally overwrites any previously stored value for a given `sessionId`. In `sweepCronRunSessions` (lines 97–99), the equivalent map is populated with a guard that preserves a non-`undefined` `sessionFile` over a `undefined` one:

```ts
if (!prunedSessions.has(entry.sessionId) || entry.sessionFile) {
  prunedSessions.set(entry.sessionId, entry.sessionFile);
}
```

Without this guard, if multiple session keys share the same `sessionId` and the last-visited key carries `sessionFile: undefined`, the file-path reference used by `archiveRemovedSessionTranscripts` will be lost, causing that session's transcript to be skipped during cleanup. The fix is to apply the same guard here:

```suggestion
          if (entry) {
            if (!removedSessions.has(entry.sessionId) || entry.sessionFile) {
              removedSessions.set(entry.sessionId, entry.sessionFile);
            }
          }
```

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

---

This is a comment left during a code review.
Path: src/cron/service/ops.ts
Line: 349-361

Comment:
**Session cleanup silently skipped when only `sessionStorePath` is configured**

Session purge is gated on `state.deps.resolveSessionStorePath` being present. However, `CronServiceDeps` also exposes a simpler static `sessionStorePath?: string` that the existing `sweepCronRunSessions` reaper already relies on. If a deployment only wires up `sessionStorePath` (not `resolveSessionStorePath`), the new cleanup block is entirely skipped — the base session (`cron:<jobId>`) is never cleaned up by the sweeper either (the sweeper only prunes run sessions), so the ghost session would still linger indefinitely.

Consider falling back to `state.deps.sessionStorePath` when `resolveSessionStorePath` is absent:

```ts
const sessionStorePath =
  state.deps.resolveSessionStorePath
    ? state.deps.resolveSessionStorePath(job.agentId)
    : state.deps.sessionStorePath;

if (job && sessionStorePath) {
  void purgeCronJobSessions({ jobId: id, sessionStorePath, log: state.deps.log })
    .catch((err) => { ... });
}
```

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

Last reviewed commit: edbd266

Comment on lines +17 to 18
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
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.

Duplicate imports from same module

parseAgentSessionKey and isCronRunSessionKey are imported from the same module in two separate statements. These should be combined into one.

Suggested change
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { parseAgentSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/session-reaper.ts
Line: 17-18

Comment:
**Duplicate imports from same module**

`parseAgentSessionKey` and `isCronRunSessionKey` are imported from the same module in two separate statements. These should be combined into one.

```suggestion
import { parseAgentSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
```

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +175 to +180
if (isBase || isRun) {
const entry = store[key];
if (entry) {
removedSessions.set(entry.sessionId, entry.sessionFile);
}
delete store[key];
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.

Missing deduplication guard differs from sweepCronRunSessions

removedSessions.set(entry.sessionId, entry.sessionFile) unconditionally overwrites any previously stored value for a given sessionId. In sweepCronRunSessions (lines 97–99), the equivalent map is populated with a guard that preserves a non-undefined sessionFile over a undefined one:

if (!prunedSessions.has(entry.sessionId) || entry.sessionFile) {
  prunedSessions.set(entry.sessionId, entry.sessionFile);
}

Without this guard, if multiple session keys share the same sessionId and the last-visited key carries sessionFile: undefined, the file-path reference used by archiveRemovedSessionTranscripts will be lost, causing that session's transcript to be skipped during cleanup. The fix is to apply the same guard here:

Suggested change
if (isBase || isRun) {
const entry = store[key];
if (entry) {
removedSessions.set(entry.sessionId, entry.sessionFile);
}
delete store[key];
if (entry) {
if (!removedSessions.has(entry.sessionId) || entry.sessionFile) {
removedSessions.set(entry.sessionId, entry.sessionFile);
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/session-reaper.ts
Line: 175-180

Comment:
**Missing deduplication guard differs from `sweepCronRunSessions`**

`removedSessions.set(entry.sessionId, entry.sessionFile)` unconditionally overwrites any previously stored value for a given `sessionId`. In `sweepCronRunSessions` (lines 97–99), the equivalent map is populated with a guard that preserves a non-`undefined` `sessionFile` over a `undefined` one:

```ts
if (!prunedSessions.has(entry.sessionId) || entry.sessionFile) {
  prunedSessions.set(entry.sessionId, entry.sessionFile);
}
```

Without this guard, if multiple session keys share the same `sessionId` and the last-visited key carries `sessionFile: undefined`, the file-path reference used by `archiveRemovedSessionTranscripts` will be lost, causing that session's transcript to be skipped during cleanup. The fix is to apply the same guard here:

```suggestion
          if (entry) {
            if (!removedSessions.has(entry.sessionId) || entry.sessionFile) {
              removedSessions.set(entry.sessionId, entry.sessionFile);
            }
          }
```

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

Comment thread src/cron/service/ops.ts
Comment on lines +349 to +361
if (job && state.deps.resolveSessionStorePath) {
const sessionStorePath = state.deps.resolveSessionStorePath(job.agentId);
void purgeCronJobSessions({
jobId: id,
sessionStorePath,
log: state.deps.log,
}).catch((err) => {
state.deps.log.warn(
{ err: String(err), jobId: id },
"cron: failed to purge job sessions on removal",
);
});
}
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.

Session cleanup silently skipped when only sessionStorePath is configured

Session purge is gated on state.deps.resolveSessionStorePath being present. However, CronServiceDeps also exposes a simpler static sessionStorePath?: string that the existing sweepCronRunSessions reaper already relies on. If a deployment only wires up sessionStorePath (not resolveSessionStorePath), the new cleanup block is entirely skipped — the base session (cron:<jobId>) is never cleaned up by the sweeper either (the sweeper only prunes run sessions), so the ghost session would still linger indefinitely.

Consider falling back to state.deps.sessionStorePath when resolveSessionStorePath is absent:

const sessionStorePath =
  state.deps.resolveSessionStorePath
    ? state.deps.resolveSessionStorePath(job.agentId)
    : state.deps.sessionStorePath;

if (job && sessionStorePath) {
  void purgeCronJobSessions({ jobId: id, sessionStorePath, log: state.deps.log })
    .catch((err) => { ... });
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cron/service/ops.ts
Line: 349-361

Comment:
**Session cleanup silently skipped when only `sessionStorePath` is configured**

Session purge is gated on `state.deps.resolveSessionStorePath` being present. However, `CronServiceDeps` also exposes a simpler static `sessionStorePath?: string` that the existing `sweepCronRunSessions` reaper already relies on. If a deployment only wires up `sessionStorePath` (not `resolveSessionStorePath`), the new cleanup block is entirely skipped — the base session (`cron:<jobId>`) is never cleaned up by the sweeper either (the sweeper only prunes run sessions), so the ghost session would still linger indefinitely.

Consider falling back to `state.deps.sessionStorePath` when `resolveSessionStorePath` is absent:

```ts
const sessionStorePath =
  state.deps.resolveSessionStorePath
    ? state.deps.resolveSessionStorePath(job.agentId)
    : state.deps.sessionStorePath;

if (job && sessionStorePath) {
  void purgeCronJobSessions({ jobId: id, sessionStorePath, log: state.deps.log })
    .catch((err) => { ... });
}
```

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

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: edbd266e77

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/cron/service/ops.ts
Comment on lines +351 to +355
void purgeCronJobSessions({
jobId: id,
sessionStorePath,
log: state.deps.log,
}).catch((err) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await session purge before returning from cron.remove

cron.remove fires purgeCronJobSessions in a detached promise (void ...) and returns immediately, so callers can observe stale cron sessions right after a successful remove response; this is especially visible when the UI deletes a job and immediately reloads session lists. Because cleanup is best-effort background work here, a process shutdown/restart between remove and purge also leaves orphaned session records behind, which is the same ghost-session symptom this change is trying to fix.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9717195e4e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/cron/service/ops.ts

// Clean up run log file
const logPath = resolveCronRunLogPath({ storePath: state.deps.storePath, jobId: id });
void fs.unlink(logPath).catch(() => undefined);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Await run-log cleanup before returning from cron.remove

The new run-log cleanup is launched as void fs.unlink(...), so cron.remove can return success before the log file is actually deleted. In flows that delete a job and immediately refresh run-history data, the removed job’s log can still appear transiently, and if the process exits/crashes between return and the async unlink, the orphaned log file persists indefinitely. This undermines the cleanup guarantee this change is intended to provide.

Useful? React with 👍 / 👎.

@xydt-610
Copy link
Copy Markdown
Contributor Author

@skyfallsin Please help review the code, thank you.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 26, 2026

Closing this as duplicate or superseded after Codex automated review.

Close #46475 as superseded by the already-open canonical bug/fix thread for #46369, especially PR #46506. Current main still has the reported gap, so this is not implemented-on-main: cron.remove only removes the job record and emits the removal event, while the existing reaper intentionally preserves the base cron session and only prunes expired :run: sessions. #46475 contains useful direction, but it has no regression tests and has unresolved review findings around session-store fallback, deduplication, and detached cleanup. The same user-visible bug is still tracked by #46369 and the newer #46506, which adds targeted cleanup tests and carries the follow-up discussion.

Best possible solution:

Close #46475 as the duplicate/superseded PR and consolidate the remaining #46369 work in #46506 or a maintainer-authored patch. The final fix should clean both canonical and run cron sessions, delete the per-job run log, select the correct per-agent session store with fallback behavior, avoid detached-cleanup races where feasible, and include regression tests for cron.remove.

What I checked:

So I’m closing this here and keeping the remaining discussion on the canonical linked item.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against 8bbb143ab87e.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: cron.remove does not clean up session records in sessions.json

1 participant