Skip to content

Fix Codex /btw parent thread lookup after compaction checkpoints #86393

Description

@felixboenkost-droid

Summary

Codex /btw side questions can fail after an auto-compaction if the active session entry resolves to a pre-compaction checkpoint transcript.

The Codex app-server parent thread binding is persisted next to the active post-compaction transcript:

session-1.jsonl.codex-app-server.json

But /btw resolves its parent session transcript through resolveBtwSessionTranscriptPath() and then the Codex side-question hook reads:

${resolvedSessionFile}.codex-app-server.json

If resolvedSessionFile is the checkpoint transcript:

session-1.checkpoint.<id>.jsonl

then Codex looks for:

session-1.checkpoint.<id>.jsonl.codex-app-server.json

That file is not written for the checkpoint, so /btw reports:

Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.

This is misleading because the active post-compaction transcript still has a valid Codex app-server binding.

Affected path

commands-btw.ts
  -> runBtwSideQuestion()
  -> resolveBtwSessionTranscriptPath()
  -> resolveSessionFilePath()
  -> harness.runSideQuestion()
  -> runCodexAppServerSideQuestion()
  -> readCodexAppServerBinding(sessionFile)

The failure happens before model/provider execution is relevant. It is not primarily an openai vs openai-codex provider mismatch. The immediate problem is that the side-question hook receives a checkpoint transcript path that does not have a Codex app-server binding sidecar.

Old code

src/agents/btw-transcript.ts

export function resolveBtwSessionTranscriptPath(params: {
  sessionId: string;
  sessionEntry?: StoredSessionEntry;
  sessionKey?: string;
  storePath?: string;
}): string | undefined {
  try {
    const agentId = params.sessionKey?.split(":")[1];
    const pathOpts = resolveSessionFilePathOptions({
      agentId,
      storePath: params.storePath,
    });
    return resolveSessionFilePath(params.sessionId, params.sessionEntry, pathOpts);
  } catch (error) {
    diag.debug(
      `resolveSessionTranscriptPath failed: sessionId=${params.sessionId} err=${String(error)}`,
    );
    return undefined;
  }
}

Proposed fix

When /btw resolves to a checkpoint transcript, map it back to the matching checkpoint's post-compaction transcript before invoking the harness. This keeps the Codex side-question hook pointed at the active transcript and its existing .codex-app-server.json sidecar.

src/agents/btw-transcript.ts

export function resolveBtwSessionTranscriptPath(params: {
  sessionId: string;
  sessionEntry?: StoredSessionEntry;
  sessionKey?: string;
  storePath?: string;
}): string | undefined {
  try {
    const agentId = params.sessionKey?.split(":")[1];
    const pathOpts = resolveSessionFilePathOptions({
      agentId,
      storePath: params.storePath,
    });
    const sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, pathOpts);
    return (
      resolvePostCompactionSessionTranscriptPath({
        sessionFile,
        sessionEntry: params.sessionEntry,
        pathOpts,
      }) ?? sessionFile
    );
  } catch (error) {
    diag.debug(
      `resolveSessionTranscriptPath failed: sessionId=${params.sessionId} err=${String(error)}`,
    );
    return undefined;
  }
}

New helper:

function resolvePostCompactionSessionTranscriptPath(params: {
  sessionFile: string;
  sessionEntry?: StoredSessionEntry;
  pathOpts?: ReturnType<typeof resolveSessionFilePathOptions>;
}): string | undefined {
  const checkpoints = params.sessionEntry?.compactionCheckpoints;
  if (!Array.isArray(checkpoints) || checkpoints.length === 0) {
    return undefined;
  }
  const resolvedSessionFile = path.resolve(params.sessionFile);
  for (let index = checkpoints.length - 1; index >= 0; index -= 1) {
    const checkpoint = checkpoints[index];
    const preSessionId = checkpoint.preCompaction.sessionId?.trim();
    if (!preSessionId) {
      continue;
    }
    const preSessionFile = resolveSessionFilePath(
      preSessionId,
      { sessionFile: checkpoint.preCompaction.sessionFile },
      params.pathOpts,
    );
    if (path.resolve(preSessionFile) !== resolvedSessionFile) {
      continue;
    }
    const postSessionId = checkpoint.postCompaction.sessionId?.trim();
    if (!postSessionId) {
      return undefined;
    }
    return resolveSessionFilePath(
      postSessionId,
      { sessionFile: checkpoint.postCompaction.sessionFile },
      params.pathOpts,
    );
  }
  return undefined;
}

Proposed regression test

New test file:

src/agents/btw-transcript.test.ts

Coverage:

  1. If the stored session file points at session-1.checkpoint.<id>.jsonl, resolveBtwSessionTranscriptPath() returns the matching post-compaction session-1.jsonl.
  2. If the stored session file already points at the active transcript, the resolver leaves it unchanged.

Representative test:

it("uses the active post-compaction transcript when the stored session file points at a checkpoint", () => {
  const sessionsDir = "/tmp/openclaw-btw-session";
  const sessionId = "session-1";
  const checkpointFile = path.join(
    sessionsDir,
    "session-1.checkpoint.98904e8a-a402-4d92-8795-4a2d7bba2276.jsonl",
  );
  const postCompactionFile = path.join(sessionsDir, "session-1.jsonl");
  const entry: SessionEntry = {
    sessionId,
    sessionFile: checkpointFile,
    compactionCheckpoints: [
      {
        checkpointId: "98904e8a-a402-4d92-8795-4a2d7bba2276",
        sessionKey: "agent:main:telegram:direct:user",
        sessionId,
        createdAt: 1_779_695_000_000,
        reason: "threshold",
        preCompaction: {
          sessionId,
          sessionFile: checkpointFile,
          leafId: "pre-leaf",
        },
        postCompaction: {
          sessionId,
          sessionFile: postCompactionFile,
          leafId: "post-leaf",
        },
      },
    ],
  };

  expect(
    resolveBtwSessionTranscriptPath({
      sessionId,
      sessionEntry: entry,
      sessionKey: "agent:main:telegram:direct:user",
      storePath: path.join(sessionsDir, "sessions.json"),
    }),
  ).toBe(postCompactionFile);
});

Local verification

Command:

./node_modules/.bin/vitest run src/agents/btw-transcript.test.ts src/agents/btw.test.ts extensions/codex/src/app-server/side-question.test.ts

Result:

Test Files  5 passed (5)
Tests       83 passed (83)

Security / redaction

This report uses synthetic session ids, checkpoint ids, session keys, and auth profile placeholders only. It does not include OAuth tokens, API keys, real account ids, real thread ids, or real auth profile ids.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions