Skip to content

fix(scheduler): write run history as one complete JSONL line - #99

Closed
SebTardif wants to merge 1 commit into
openclaw:mainfrom
SebTardif:fix/f001-atomic-run-history
Closed

fix(scheduler): write run history as one complete JSONL line#99
SebTardif wants to merge 1 commit into
openclaw:mainfrom
SebTardif:fix/f001-atomic-run-history

Conversation

@SebTardif

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where operators who run crawlctl run (directly or from launchd/systemd) can get a bricked scheduler after a short write, crash, or failed close while appending runs.jsonl. ReadHistory fails closed on any unmarshal error, so crawlctl run, status, and logs then refuse to start until the file is hand-edited.

Why This Change Was Made

appendHistory now encodes each RunRecord into a buffer, writes that one complete JSONL line, and returns Close errors instead of encoding directly onto the O_APPEND handle and discarding flush failures. The on-disk format and the fail-closed read contract are unchanged. Existing already-truncated files still need a manual edit.

User Impact

A successful crawlctl run leaves a complete last line in state/runs.jsonl, so a later run, status, or logs command can read history again. Operators still see error: unexpected end of JSON input if the file is already truncated from an older build.

Evidence

Public crawlctl against a temp config on the patched binary. A truncated last line still bricks run and status (fail-closed). After a successful job, history is one newline-terminated JSON object and status reads it.

$ printf '{"id":"partial"' > state/runs.jsonl
$ crawlctl --config crawlctl.toml run
error: unexpected end of JSON input
$ crawlctl --config crawlctl.toml status
error: unexpected end of JSON input

$ rm state/runs.jsonl
$ crawlctl --config crawlctl.toml run
ok ok duration=4ms
ok: success exit=0 log=/tmp/crawlkit-F001-live/home/logs/ok-20260829T183407Z-8951d898.log
$ crawlctl --config crawlctl.toml status
ok: success 2026-08-29T18:34:07Z

$ python3 -c 'from pathlib import Path; p=Path("state/runs.jsonl"); d=p.read_bytes(); print(len(d), d.endswith(b"\n"), d.count(b"\n"), d.splitlines()[0])'
250 True 1 b'{"id":"8951d898","job":"ok","command":["true"],"started_at":"2026-08-29T18:34:07Z","finished_at":"2026-08-29T18:34:07Z","duration_ms":4,"exit_code":0,"status":"success","log_path":"/tmp/crawlkit-F001-live/home/logs/ok-20260829T183407Z-8951d898.log"}'

Real behavior proof

  • Behavior or issue addressed: crawlctl run can leave a truncated last runs.jsonl line; later run, status, and logs then fail closed until the file is edited.

  • Real environment tested: macOS darwin/arm64, Go 1.27.0, crawlkit worktree /tmp/oc-pr-crawlkit-F001 at branch fix/f001-atomic-run-history, public crawlctl built with GOWORK=off go build -o /tmp/crawlkit-F001-live/crawlctl ./cmd/crawlctl.

  • Exact steps or command run after this patch: Built crawlctl, pointed --config at a temp crawlctl.toml with jobs.ok.command = ["true"], wrote a truncated state/runs.jsonl, ran crawlctl run and crawlctl status, removed the bad file, ran crawlctl run again, then inspected the written line with python3.

  • Evidence after fix: terminal output from the patched crawlctl:

    $ crawlctl --config crawlctl.toml run
    ok ok duration=4ms
    ok: success exit=0 log=/tmp/crawlkit-F001-live/home/logs/ok-20260829T183407Z-8951d898.log
    $ crawlctl --config crawlctl.toml status
    ok: success 2026-08-29T18:34:07Z
    bytes 250 endswith_nl True newline_count 1 records 1
    job ok status success id 8951d898
  • Observed result after fix: After a successful job, runs.jsonl is one complete JSONL line and crawlctl status prints ok: success. A truncated last line still returns error: unexpected end of JSON input and does not start jobs.

  • What was not tested: A hard crash mid-Write on NFS, launchd/systemd scheduling, and Windows close-flush behavior.

Summary

The hole has been present since d41253c (feat: add crawlctl scheduler, 2026-05-22, 99 days). ReadHistory fail-closed behavior is unchanged and is still covered by TestRunReturnsHistoryReadErrorBeforeRunningJobs.

appendHistory encoded a RunRecord directly onto an O_APPEND file
and discarded Close errors. A short write or failed flush could
leave a truncated last line. ReadHistory fails closed, so
crawlctl run, status, and logs refuse to start.

Encode to a buffer, write one complete JSONL line, and return
Close errors.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
@clawsweeper

clawsweeper Bot commented Aug 29, 2026

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Aug 29, 2026
@clawsweeper

clawsweeper Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex review: needs real behavior proof before merge. Reviewed August 30, 2026, 11:15 PM ET / August 31, 2026, 03:15 UTC.

ClawSweeper review

What this changes

The branch buffers scheduler run records before appending JSONL history, returns close errors, and adds normal-write and close-error tests.

Merge readiness

Blocked until stronger real behavior proof is added - 5 items remain

The PR improves close-error reporting but does not prevent a partially accepted history write from leaving an unreadable trailing JSON record. Because the reader remains fail-closed, the stated scheduler-bricking failure remains possible.

Priority: P1
Reviewed head: b62f2b15dbebf991333d9a9da2a7839a99dd43c6

Review scores

Measure Result What it means
Overall readiness 🦐 gold shrimp (3/6) The repair is focused and has normal-path evidence, but its central interrupted-write failure remains unaddressed and unproven.
Proof confidence 🦐 gold shrimp (3/6) Needs stronger real behavior proof before merge: The changed production owner is the scheduler history writer. The provided patched crawlctl terminal trace uses temporary configuration and proves normal record creation and status reading, but not recovery after a partial write or failed flush; add redacted terminal evidence for that failure path, then update the PR body or ask a maintainer to comment @clawsweeper re-review.
Patch quality 🦐 gold shrimp (3/6) 1 actionable review finding remain.

Verification

Check Result Evidence
Real behavior Needs proof Needs stronger real behavior proof before merge: The changed production owner is the scheduler history writer. The provided patched crawlctl terminal trace uses temporary configuration and proves normal record creation and status reading, but not recovery after a partial write or failed flush; add redacted terminal evidence for that failure path, then update the PR body or ask a maintainer to comment @clawsweeper re-review.
Evidence reviewed 6 items Introduced write-error path: The new helper returns on a write error without restoring the prior file state; bytes accepted before that error remain as an incomplete final record.
Fail-closed history reader: A JSON decoding error on any scanned history line is returned directly, including an incomplete final line.
Affected CLI consumers: Both status and logs return an error when scheduler history cannot be read.
Findings 1 actionable finding [P1] Recover partial history writes
Security None None.

How this fits together

The scheduler executes configured crawl jobs and records results in a local JSONL history file. The run, status, and logs commands read that file, so an invalid trailing record blocks those workflows.

flowchart LR
  A[Configured crawl job] --> B[Scheduler executes job]
  B --> C[History append]
  C --> D[Local JSONL history]
  D --> E[History reader]
  E --> F[Run status and logs]
  C --> G[Write failure]
Loading

Before merge

  • Add real behavior proof - Needs stronger real behavior proof before merge: The changed production owner is the scheduler history writer. The provided patched crawlctl terminal trace uses temporary configuration and proves normal record creation and status reading, but not recovery after a partial write or failed flush; add redacted terminal evidence for that failure path, then update the PR body or ask a maintainer to comment @clawsweeper re-review.
  • Recover partial history writes (P1) - If Write persists a prefix and returns an error, this new helper leaves that fragment in runs.jsonl; ReadHistory then rejects it and blocks later scheduler commands. This prior blocker remains at the same head: preserve the valid pre-write state and add a prefix-plus-error regression test.
  • Resolve merge risk (P1) - A write that persists a prefix before reporting an error still leaves JSONL history unreadable and prevents later scheduler commands from proceeding.
  • Resolve merge risk (P1) - On upgrade, a close failure now makes a completed job invocation return an error; the branch does not show the resulting scheduler behavior end to end.
  • Complete next step (P2) - The remaining merge blocker requires a corrected failure-path implementation and contributor-provided real behavior proof; an automated repair lane cannot supply that external proof.

Findings

  • [P1] Recover partial history writes — scheduler/run.go:296-299
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Diff scope 2 files; production +15/-3, tests +85/-0 The patch is focused, but its added tests do not cover the failure path central to the claim.

Merge-risk options

Maintainer options:

  1. Make failed appends recoverable (recommended)
    Preserve the prior valid history state after partial writes and prove the affected CLI commands can read it afterward.
  2. Accept the narrower close-error change
    Land only the close-error reporting behavior while documenting that interrupted writes can still block scheduler history consumers.
  3. Pause the repair
    Defer the change if a crash-safe update strategy cannot be added without broader scheduler-history design work.

Technical review

Best possible solution:

Use a crash-safe history update that preserves the last valid history state, such as transactional same-directory replacement, while retaining the existing fail-closed policy for genuinely malformed persisted history.

Do we have a high-confidence way to reproduce the issue?

Yes, from source: a writer that accepts a byte prefix and returns an error leaves that prefix in history, after which the reader rejects it. The supplied runtime trace covers only a normal successful append.

Is this the best way to solve the issue?

No. Buffering alone does not recover a partial write; the persistence strategy must preserve a valid prior state or safely recover the final incomplete record.

Full review comments:

  • [P1] Recover partial history writes — scheduler/run.go:296-299
    If Write persists a prefix and returns an error, this new helper leaves that fragment in runs.jsonl; ReadHistory then rejects it and blocks later scheduler commands. This prior blocker remains at the same head: preserve the valid pre-write state and add a prefix-plus-error regression test.
    Confidence: 0.98

Overall correctness: patch is incorrect
Overall confidence: 0.98

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 52f0fb12e4d2.

Labels

Label changes:

  • add rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦐 gold shrimp and patch quality is 🦐 gold shrimp.
  • remove rating: 🦪 silver shellfish: Current PR rating is rating: 🦐 gold shrimp, so this older rating label is no longer current.

Label justifications:

  • P1: A corrupted scheduler history record can block run, status, and logs workflows for operators.
  • merge-risk: 🚨 availability: The proposed error handling leaves interrupted history appends able to make later scheduler commands unavailable.
  • rating: 🦐 gold shrimp: Overall readiness is 🦐 gold shrimp; proof is 🦐 gold shrimp and patch quality is 🦐 gold shrimp.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The changed production owner is the scheduler history writer. The provided patched crawlctl terminal trace uses temporary configuration and proves normal record creation and status reading, but not recovery after a partial write or failed flush; add redacted terminal evidence for that failure path, then update the PR body or ask a maintainer to comment @clawsweeper re-review.

Evidence

What I checked:

  • Introduced write-error path: The new helper returns on a write error without restoring the prior file state; bytes accepted before that error remain as an incomplete final record. (scheduler/run.go:296, b62f2b15dbeb)
  • Fail-closed history reader: A JSON decoding error on any scanned history line is returned directly, including an incomplete final line. (scheduler/run.go:316, b62f2b15dbeb)
  • Affected CLI consumers: Both status and logs return an error when scheduler history cannot be read. (cmd/crawlctl/main.go:274, b62f2b15dbeb)
  • Coverage gap: The new tests cover a complete write and a close error, but not a writer that accepts a prefix and returns an error or subsequent recovery from that prefix. (scheduler/scheduler_test.go:302, b62f2b15dbeb)
  • Scheduler provenance: Commit metadata identifies the initial scheduler feature commit for routing; the current branch adds the reviewed history helper. (scheduler/run.go:279, d41253c66810)
  • Provided runtime trace: The PR body shows a patched crawlctl writing and reading a normal record in temporary state, but explicitly excludes hard-crash and relevant partial-write scenarios. (b62f2b15dbeb)

Likely related people:

  • Peter Steinberger: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Vincent Koc: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rank-up moves

Optional improvements that raise the rating; they are not merge blockers.

  • Preserve a valid history file after a prefix-plus-error write and cover it with a regression test.
  • Add redacted temporary-state terminal evidence that injects the relevant failure and then shows history-consuming CLI behavior recovers.

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (6 earlier review cycles)
  • reviewed 2026-08-29T18:41:14.290Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Protect history from torn writes
  • reviewed 2026-08-29T22:04:30.637Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Make failed history appends recoverable
  • reviewed 2026-08-30T04:52:14.006Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Make interrupted appends recoverable
  • reviewed 2026-08-30T09:53:16.951Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Recover interrupted trailing history records
  • reviewed 2026-08-30T13:06:53.565Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Recover interrupted trailing history records
  • reviewed 2026-08-30T17:04:21.528Z sha b62f2b1 :: needs real behavior proof before merge. :: [P1] Make interrupted history appends recoverable

@clawsweeper clawsweeper Bot added merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. P1 Urgent regression or broken agent/channel workflow affecting real users now. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. P2 Normal priority bug or improvement with limited blast radius. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. labels Aug 29, 2026
@steipete

Copy link
Copy Markdown
Contributor

Maintainer triage: CLOSE recommended for the stated partial-history-write fix at b62f2b15dbebf991333d9a9da2a7839a99dd43c6. This recommendation leaves the PR open for the maintainer's decision.

I reproduced the failure with real crawlctl binaries built from current main (52f0fb12e4d28b329d182925138d96f552add9d5) and this PR, on macOS arm64 with Go 1.27.0. Each used temporary synthetic configuration and history. After one successful job, an OS-enforced file-size limit allowed the next append to persist only 80 bytes; a second fixture repeated this with 120 bytes. The binary produced the partial record itself. No manual corruption was used for this failure test.

Both versions produced the same result:

limited run: exit 1; error: write <temporary state>/runs.jsonl: file too large
history: 1 valid prior record + 80 (or 120) trailing bytes; no final newline
unrestricted status: exit 1; error: unexpected end of JSON input
unrestricted logs: exit 1; error: unexpected end of JSON input
unrestricted run ok: exit 1; error: unexpected end of JSON input

Normal runs and history reads passed on both versions. A separate malformed-history fixture also correctly blocked a marker-writing job. All test data was temporary; parent process limits were unchanged.

The root cause is a partial append remaining in a file whose reader rejects any malformed record. Go's JSON encoder already buffers the complete record and newline before one Write, so the added buffer provides no additional partial-write protection. This is an unresolved existing failure, not a new corruption regression introduced by the contributor. Returning close errors is a useful narrower improvement, but does not establish the reliability claim. Independent Codex autoreview, covering priorities P0–P3, reached the same conclusion and identified no other distinct actionable defect.

The existing Linux and Windows CI run is green, but its tests do not establish recovery from a real partially accepted write. I recommend a separately scoped decision about crash-safe persistence versus recovery of an incomplete trailing record before replacing the history strategy. No contributor changes are requested by this triage.

Minimal real-binary reproducer

Build each revision with GOWORK=off go build -o /tmp/crawlctl-proof ./cmd/crawlctl, then run this driver as python3 repro.py /tmp/crawlctl-proof. Repeat for main and the PR. It uses only temporary data and process-local limits. On this Darwin host, the append-mode limit was calibrated to the permitted append byte count; on Linux it is the existing file size plus that allowance.

from pathlib import Path
import resource
import signal
import subprocess
import sys
import tempfile

binary = str(Path(sys.argv[1]).resolve())
for allowance in (80, 120):
    with tempfile.TemporaryDirectory(prefix="crawlkit-history-proof-") as temp:
        root = Path(temp)
        env = {"PATH": "/usr/bin:/bin", "LANG": "C"}
        for key in ("HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME"):
            directory = root / key.lower()
            directory.mkdir()
            env[key] = str(directory)
        cfg = root / "crawlctl.toml"
        cfg.write_text('version = 1\n[jobs.ok]\nenabled = true\n'
                       'command = ["/usr/bin/true"]\n')
        argv = [binary, "--config", str(cfg)]
        def run(*args, limited=False):
            def constrain():
                signal.signal(signal.SIGXFSZ, signal.SIG_IGN)
                resource.setrlimit(resource.RLIMIT_FSIZE, (limit, limit))
            return subprocess.run(
                argv + list(args), cwd=root, env=env,
                capture_output=True, text=True, timeout=20,
                preexec_fn=constrain if limited else None)
        seed = run("run", "ok")
        assert seed.returncode == 0, seed.stderr
        history = root / "state" / "runs.jsonl"
        original = history.read_bytes()
        limit = allowance if sys.platform == "darwin" else len(original) + allowance
        failed = run("run", "ok", limited=True)
        data = history.read_bytes()
        print("allowance", allowance, "exit", failed.returncode, failed.stderr.strip())
        print("prior_preserved", data.startswith(original),
              "complete_records", len(data.split(b"\n")) - 1,
              "trailing_bytes", len(data.rsplit(b"\n", 1)[-1]),
              "newline", data.endswith(b"\n"))
        for args in (("status",), ("logs",), ("run", "ok")):
            result = run(*args)
            print(" ".join(args), "exit", result.returncode, result.stderr.strip())

@steipete

Copy link
Copy Markdown
Contributor

Thanks, @SebTardif, for investigating the scheduler history failure and adding coverage. We're closing this PR because the added buffering duplicates what encoding/json already does, and the real-binary reproduction confirms that the partial-write failure remains unchanged. Crash-safe history persistence will be addressed separately as a storage-contract design. We appreciate the work you put into this.

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

Labels

merge-risk: 🚨 availability 🚨 Merging this PR could cause crashes, hangs, restart loops, stalls, or process outages. P1 Urgent regression or broken agent/channel workflow affecting real users now. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants