Skip to content

fix(sessions): keep tail follow cursor aligned with bytes actually read#108127

Merged
steipete merged 3 commits into
openclaw:mainfrom
sunlit-deng:sunlit/fix/sessions-tail-short-read
Jul 15, 2026
Merged

fix(sessions): keep tail follow cursor aligned with bytes actually read#108127
steipete merged 3 commits into
openclaw:mainfrom
sunlit-deng:sunlit/fix/sessions-tail-short-read

Conversation

@sunlit-deng

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where openclaw sessions tail --follow silently loses trajectory events. The file-follow poller (readNewFileFollowEvents in src/commands/sessions-tail.ts) reads the appended delta with one fs.readSync, ignores the returned byte count, and unconditionally jumps the cursor to stat.size. When the positional read returns short — a documented POSIX behavior, common on network filesystems — the unread bytes are skipped forever and the undecoded remainder of the buffer becomes NUL garbage in the JSONL line parser, so the affected events never render and never get retried.

Why This Change Was Made

The follow cursor now advances only past the bytes actually read (state.offset += bytesRead) and only those bytes are decoded (buffer.subarray(0, bytesRead)), so the next poll picks up the remainder instead of skipping it. This follows the short-read-safe direction of #105066 (readLogWindowFully for log tails); the existing rotation/truncation rescan checks above this read are unchanged.

User Impact

sessions tail --follow delivers every appended trajectory event even when reads return short, instead of silently dropping events for the rest of the follow session.

Evidence

  • Regression test (added, fails on main): node scripts/run-vitest.mjs run src/commands/sessions-tail.test.ts — main: Timed out waiting for output containing python ok (the appended event is never delivered); this branch: 13 passed.
  • Live proof: the real sessionsTailCommand follow path against a real trajectory JSONL file. The documented POSIX short-read contract is applied at the fs.readSync boundary (each capped call still performs a real kernel read of the real file); an event is appended mid-follow and we observe whether it ever renders.

Baseline (main @ 5afb1fb):

$ node --import tsx live-proof.mjs
follow output lines       : 1
appended tool.result seen : false
verdict                   : EVENT LOST (bytes skipped after short read)

After (this branch, same script and input):

$ node --import tsx live-proof.mjs
follow output lines       : 2
appended tool.result seen : true
verdict                   : event delivered across short reads
live-proof.mjs (save in repo root, run with `node --import tsx live-proof.mjs`)
// Runs the REAL `sessions tail --follow` command path (sessionsTailCommand)
// against a real trajectory JSONL file. The POSIX short-read contract is
// applied at the fs.readSync boundary: positional reads may return fewer
// bytes than requested. Pre-fix the follow cursor jumps to stat.size, so the
// unread bytes are skipped forever and the appended event never renders.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

const repoRoot = process.cwd();
const load = (rel) => import(pathToFileURL(path.resolve(repoRoot, rel)).href);

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-tail-"));
process.env.OPENCLAW_STATE_DIR = path.join(tmpDir, "state");

const { sessionsTailCommand, setSessionsTailFollowIntervalMsForTests } = await load(
  "src/commands/sessions-tail.ts",
);
const { replaceSessionEntry } = await load("src/config/sessions/session-accessor.ts");

const sessionKey = "agent:main:telegram:direct:owner";
const storePath = path.join(tmpDir, "sessions.json");
const trajectoryPath = path.join(tmpDir, "session-one.trajectory.jsonl");

function makeEvent(seq, type, data) {
  return {
    traceSchema: "openclaw-trajectory",
    schemaVersion: 1,
    traceId: "trace-1",
    source: "runtime",
    sourceSeq: seq,
    seq,
    sessionId: "session-one",
    type,
    ts: new Date(1747570000000 + seq * 1000).toISOString(),
    sessionKey,
    data,
  };
}

await replaceSessionEntry(
  { sessionKey, storePath },
  {
    sessionId: "session-one",
    sessionFile: "session-one.jsonl",
    updatedAt: 2,
    status: "running",
  },
);
fs.writeFileSync(trajectoryPath, `${JSON.stringify(makeEvent(1, "session.started", {}))}\n`);

// Apply the POSIX short-read contract to follow-poll reads only.
let capReads = false;
const realReadSync = fs.readSync;
fs.readSync = (fd, buffer, offset, length, position) => {
  const capped = capReads ? Math.min(length, 16) : length;
  return realReadSync(fd, buffer, offset, capped, position);
};

const lines = [];
let appended = false;
const runtime = {
  log: (message) => {
    lines.push(String(message));
    if (!appended && String(message).includes("session.started")) {
      appended = true;
      capReads = true;
      fs.appendFileSync(
        trajectoryPath,
        `${JSON.stringify(makeEvent(2, "tool.result", { name: "python", success: true }))}\n`,
      );
    }
  },
  error: (message) => lines.push(`ERR ${message}`),
  exit: () => {},
};

setSessionsTailFollowIntervalMsForTests(10);
const run = sessionsTailCommand({ store: storePath, sessionKey, tail: "1", follow: true }, runtime);
const deadlineMs = 4000;
const startedAt = Date.now();
while (!lines.some((line) => line.includes("python ok")) && Date.now() - startedAt < deadlineMs) {
  await new Promise((resolve) => setTimeout(resolve, 25));
}
process.emit("SIGTERM", "SIGTERM");
await run;
fs.readSync = realReadSync;
setSessionsTailFollowIntervalMsForTests();
fs.rmSync(tmpDir, { recursive: true, force: true });

const delivered = lines.some((line) => line.includes("python ok"));
console.log(`follow output lines       : ${lines.length}`);
console.log(`appended tool.result seen : ${delivered}`);
console.log(
  `verdict                   : ${delivered ? "event delivered across short reads" : "EVENT LOST (bytes skipped after short read)"}`,
);
process.exit(0);

AI-assisted: built with Codex

@openclaw-barnacle openclaw-barnacle Bot added commands Command implementations size: S labels Jul 15, 2026
@sunlit-deng
sunlit-deng force-pushed the sunlit/fix/sessions-tail-short-read branch 2 times, most recently from 4e51281 to a3e5107 Compare July 15, 2026 10:59
@sunlit-deng
sunlit-deng force-pushed the sunlit/fix/sessions-tail-short-read branch from a3e5107 to 108f07b Compare July 15, 2026 15:13
@steipete steipete self-assigned this Jul 15, 2026
@steipete
steipete merged commit 55a822d into openclaw:main Jul 15, 2026
110 of 111 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Merged via squash.

github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 16, 2026
…ad (openclaw#108127)

* fix(sessions): keep tail follow cursor aligned with bytes actually read

* refactor(infra): share synchronous file window reads

Co-authored-by: sunlit-deng <[email protected]>

---------

Co-authored-by: Peter Steinberger <[email protected]>
Co-authored-by: Peter Steinberger <[email protected]>
steipete pushed a commit to Monkey-wusky/openclaw that referenced this pull request Jul 16, 2026
Replace single-shot handle.read() calls in two session tail-read
paths with readFileWindowFully so multibyte-range positional reads
do not silently return incomplete tail data on network filesystems.

- readRecentTranscriptTailLinesAsync: tail-window read for recent
  session messages now loops until the requested window fills
- readLastMessagePreviewFromOpenTranscriptAsync: last-message
  preview tail read now loops until the requested window fills

The same readFileWindowFully helper was introduced in openclaw#108253 and
expanded with a sync variant in openclaw#108127 (both by sunlit-deng).
steipete pushed a commit to Monkey-wusky/openclaw that referenced this pull request Jul 16, 2026
Replace single-shot handle.read() calls in two session tail-read
paths with readFileWindowFully so multibyte-range positional reads
do not silently return incomplete tail data on network filesystems.

- readRecentTranscriptTailLinesAsync: tail-window read for recent
  session messages now loops until the requested window fills
- readLastMessagePreviewFromOpenTranscriptAsync: last-message
  preview tail read now loops until the requested window fills

The same readFileWindowFully helper was introduced in openclaw#108253 and
expanded with a sync variant in openclaw#108127 (both by sunlit-deng).
steipete pushed a commit to Monkey-wusky/openclaw that referenced this pull request Jul 16, 2026
Replace single-shot handle.read() calls in two session tail-read
paths with readFileWindowFully so multibyte-range positional reads
do not silently return incomplete tail data on network filesystems.

- readRecentTranscriptTailLinesAsync: tail-window read for recent
  session messages now loops until the requested window fills
- readLastMessagePreviewFromOpenTranscriptAsync: last-message
  preview tail read now loops until the requested window fills

The same readFileWindowFully helper was introduced in openclaw#108253 and
expanded with a sync variant in openclaw#108127 (both by sunlit-deng).
steipete added a commit that referenced this pull request Jul 16, 2026
…ads (#108655)

* fix(sessions): complete tail-read windows despite short positional reads

Replace single-shot handle.read() calls in two session tail-read
paths with readFileWindowFully so multibyte-range positional reads
do not silently return incomplete tail data on network filesystems.

- readRecentTranscriptTailLinesAsync: tail-window read for recent
  session messages now loops until the requested window fills
- readLastMessagePreviewFromOpenTranscriptAsync: last-message
  preview tail read now loops until the requested window fills

The same readFileWindowFully helper was introduced in #108253 and
expanded with a sync variant in #108127 (both by sunlit-deng).

* fix(sessions): complete sync title preview reads

---------

Co-authored-by: Peter Steinberger <[email protected]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 17, 2026
…ads (openclaw#108655)

* fix(sessions): complete tail-read windows despite short positional reads

Replace single-shot handle.read() calls in two session tail-read
paths with readFileWindowFully so multibyte-range positional reads
do not silently return incomplete tail data on network filesystems.

- readRecentTranscriptTailLinesAsync: tail-window read for recent
  session messages now loops until the requested window fills
- readLastMessagePreviewFromOpenTranscriptAsync: last-message
  preview tail read now loops until the requested window fills

The same readFileWindowFully helper was introduced in openclaw#108253 and
expanded with a sync variant in openclaw#108127 (both by sunlit-deng).

* fix(sessions): complete sync title preview reads

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands Command implementations size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants