Skip to content

fix(desktop): preserve glued automation history records#6344

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
VectorPeak:codex-automation-history-jsonl-glued-records
Jul 5, 2026
Merged

fix(desktop): preserve glued automation history records#6344
wenshao merged 7 commits into
QwenLM:mainfrom
VectorPeak:codex-automation-history-jsonl-glued-records

Conversation

@VectorPeak

@VectorPeak VectorPeak commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

What this PR does

This PR updates desktop automation history compaction so it can preserve recoverable automation history records when multiple complete JSON objects are glued onto the same physical JSONL line.

The change is scoped to automations-history.jsonl retention and compaction. It keeps ordinary malformed-line skipping unchanged, does not change automation execution, scheduling, webhook retry behavior, or the root qwen-code JSONL utilities, and avoids introducing a cross-workspace dependency from the desktop package into the root core package.

Why it's needed

What Problem This Solves

Automation history is stored as JSONL: each completed automation history entry should be consumable as one JSON object record, normally separated by a newline.

Before this change, automation history compaction treated each physical line as either one valid JSON object or one malformed line. That works for ordinary records, but it loses recoverable data when a history file contains two complete objects glued together on the same line, for example:

{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":2,"ok":true}{"id":"a1","ts":3,"ok":true}

The second physical line is not random garbage. It contains two balanced top-level JSON objects, but a single JSON.parse(line) cannot parse it because the newline separator between the two records is missing. The previous compaction path caught that parse failure, treated the whole physical line as malformed, and dropped both otherwise recoverable history entries.

That matters because compaction rewrites automations-history.jsonl. Once retention runs, recoverable glued records can become permanent automation history loss. The affected data is automation run history and last-executed evidence; automation execution itself is not changed.

Changes

This PR keeps the parser deliberately narrow:

  • recovers balanced top-level object records from glued history lines;
  • keeps ordinary malformed-line skipping unchanged;
  • preserves already recovered valid objects when later stray closing braces or trailing garbage appear;
  • skips invalid recovered object segments while preserving surrounding valid recovered object segments;
  • rejects non-object JSON values such as null, 42, or [] as automation history entries;
  • keeps the desktop package self-contained instead of depending on the root packages/core JSONL helper.

It also keeps the compaction rewrite decision aware of recovery. A glued line still forces normalization even when the recovered entry count happens to match the original physical line count.

Evidence

Focused validation passed on Windows PowerShell:

cd D:\ZXY\Github\qwen-code\packages\desktop
D:\ZXY\Dev\nodejs\npx.cmd --yes bun test packages/shared/src/automations/history-store.test.ts
D:\ZXY\Dev\nodejs\npx.cmd --yes bun run typecheck:shared

Observed result:

17 pass
0 fail
54 expect() calls

Focused validation also passed under WSL/Linux:

cd /mnt/d/ZXY/Github/qwen-code/packages/desktop
npx --yes bun test packages/shared/src/automations/history-store.test.ts

Observed result:

17 pass
0 fail
54 expect() calls

The new coverage exercises:

  • nested object and 3+ glued object recovery;
  • trailing garbage / partial-write suffix handling;
  • stray closing braces after valid recovered objects;
  • invalid recovered object segments preserving surrounding valid records;
  • braces and escaped quotes inside strings;
  • count-coincidence rewrite normalization, where one glued line plus one malformed line still forces a rewrite even when recovered-entry-count matches physical-line-count;
  • non-object JSON rewrite normalization for null, 42, and [].

Screenshots are not included because this is a non-UI persistence/parser fix.

Possible call chain / impact

automations-history.jsonl
  -> compactAutomationHistory()
  -> runCompaction()
  -> compactEntries()
  -> parseHistoryLine()
  -> recoverHistoryObjectsFromLine()
  -> writeFile() normalized JSONL output

Production readers consume automation history line by line. A glued physical line is therefore invisible to normal per-line JSON parsing until compaction normalizes it back into separate records.

This PR changes only the desktop automation history compaction path. It does not change automation execution, scheduling semantics, webhook retries, UI rendering, or the root packages/core JSONL utilities.

Reviewer Test Plan

How to verify

Run the focused desktop automation history test and confirm the glued-record cases pass. The new coverage writes a temporary automations-history.jsonl file containing complete object records with a missing newline separator:

{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":2,"ok":true}{"id":"a1","ts":3,"ok":true}

Expected behavior after compaction:

  • all complete object records are preserved when retention limits allow them, including nested and 3+ glued objects;
  • malformed non-recoverable lines are still skipped, while valid recovered object segments are preserved around invalid object segments;
  • stray closing braces after valid recovered objects do not discard already recovered entries;
  • non-object JSON values such as null, 42, and [] are not treated as history entries and are normalized out on rewrite;
  • braces, nested objects, trailing partial writes, count-coincidence rewrites, and escaped quotes inside string values do not break glued-record recovery;
  • no dependency is introduced from the desktop package to the root packages/core JSONL helper.

Evidence (Before & After)

N/A for screenshots; this is a non-UI persistence/parser fix.

Before: a glued line such as {"id":"a1","ts":2,"ok":true}{"id":"a1","ts":3,"ok":true} was treated as one malformed physical line and dropped during compaction.

After: the two balanced object records are recovered as separate automation history entries before compaction rewrites the file. The latest follow-up also preserves valid recovered objects before a later stray } branch.

Tested on

OS Status
macOS N/A
Windows focused test + shared typecheck
Linux WSL focused test

Environment (optional)

Windows: Node.js v24.15.0 with npx --yes bun using Bun v1.3.14.

WSL/Linux: Node.js v24.15.0 with npx --yes bun using Bun v1.3.14.

Risk & Scope

  • Main risk or tradeoff: the recovery logic must stay narrow so it does not reinterpret arbitrary malformed JSONL as valid history. The implementation only recovers balanced top-level object records from a physical line; ordinary malformed lines, non-object JSON values, stray closing braces, and trailing partial writes are still skipped or trimmed while preserving any valid object segments recovered before those suffixes. Glued lines still force a normalization rewrite even when recovered entry count matches physical line count.
  • Not validated / out of scope: this does not change the root packages/core JSONL helper, retry scheduling semantics, automation execution, webhook request behavior, or UI rendering.
  • Breaking changes / migration notes: none expected. Existing valid history lines and ordinary malformed-line skipping should remain unchanged.

Linked Issues

Fixes #6343

中文说明

What this PR does

这个 PR 调整 desktop automation history 的压缩逻辑,使其在多个完整 JSON object 被粘在同一条 JSONL 物理行上时,可以恢复并保留这些可恢复的 automation history 记录。

改动范围只限于 automations-history.jsonl 的 retention/compaction。它不改变 automation 执行、调度、webhook retry 行为,也不跨 workspace 引入根目录 packages/core 的 JSONL helper。

Why it's needed

What Problem This Solves

automation history 使用 JSONL 存储。正常情况下,每条完成的 automation history entry 都应该是一条 JSON object record,并通过换行分隔。

修改前,compaction 把每个物理行当成“一个合法 JSON object”或“一条 malformed line”。这对普通记录没问题,但如果文件里出现两个完整 object 粘在同一行的情况,例如:

{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":2,"ok":true}{"id":"a1","ts":3,"ok":true}

第二个物理行并不是完全不可恢复的坏数据,而是两个 balanced top-level JSON objects 少了中间的换行符。旧逻辑用单次 JSON.parse(line) 解析失败后,会把整条物理行当成 malformed line 跳过。

这会影响 automation history,因为 compaction 会重写 automations-history.jsonl。一旦 retention 跑过,原本可恢复的 glued records 就可能变成永久历史记录丢失。受影响的是 automation run history 和 last-executed evidence,不是 automation 执行本身。

Changes

这个 PR 的恢复逻辑保持很窄:只恢复 glued line 里的 balanced top-level object records,继续跳过普通 malformed line、trailing partial write,并且不把 null42[] 这类非 object JSON 值当成 history entry。

最新 follow-up 也修复了 stray } 分支:如果前面已经恢复出了有效 object,后面再遇到多余的 } 或 garbage,不会把前面已经恢复的对象清空。

同时,当 glued line 被恢复时,即使恢复出的记录数恰好等于原始物理行数量,也会强制 normalization rewrite,避免文件继续保留对逐行 reader 不可见的粘连记录。

Evidence

Windows focused test、shared typecheck、WSL/Linux focused test 均已通过:

17 pass
0 fail
54 expect() calls

该变更是非 UI 的持久化/parser 修复,因此截图或录屏不适用。

Possible call chain / impact

automations-history.jsonl
  -> compactAutomationHistory()
  -> runCompaction()
  -> compactEntries()
  -> parseHistoryLine()
  -> recoverHistoryObjectsFromLine()
  -> writeFile() normalized JSONL output

受影响的是 desktop automation history compaction 路径。automation 执行、调度、webhook retry、UI 渲染和根目录 packages/core JSONL helper 不在本 PR 范围内。

Reviewer Test Plan

见英文部分。

Risk & Scope

主要风险是恢复逻辑不能过宽,避免把任意坏 JSONL 误解释为合法 automation history。因此修复保持在 automation history compaction 的局部边界内,不引入跨 workspace 依赖,同时只保留可独立解析和验证的完整 object segment。

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! (re-run)

Template looks good ✓ — all required sections present, bilingual, linked issue #6343.

Problem: This is a real bug with a linked issue (#6343) and a clear failure mode. Glued JSONL records occur when a partial write (process crash, power failure) leaves a line without its trailing newline — the next entry then gets concatenated onto the same physical line. Compaction drops both records as "malformed." The before/after demonstration confirms: old compaction keeps 2 of 4 entries, PR keeps all 4.

Direction: Aligned. Narrow fix scoped to automation history compaction only — no changes to automation execution, scheduling, webhook retry, or root packages/core JSONL utilities. Falls under roadmap/background-automation. The PR explicitly avoids introducing a cross-workspace dependency, keeping the desktop package self-contained.

Approach: Scope feels right. The change is minimal — one new parsing function (~50 lines) and a refactor of the line-parsing loop in compactEntries. The needsRewrite flag correctly handles the edge case where recovered entry count happens to match physical line count. Tests cover all stated edge cases (nested objects, 3+ glued, trailing garbage, stray braces, non-object JSON, escaped quotes in strings, count-coincidence).

Moving on to code review. 🔍

中文说明

感谢贡献!(re-run)

模板完整 ✓ — 所有必要部分齐全,双语,关联了 issue #6343

问题:这是一个真实的 bug,有关联 issue(#6343)和清晰的失败模式。当部分写入(进程崩溃、断电)导致某行缺少尾部换行符时,下一条记录会被拼接到同一物理行上。压缩逻辑会把两条记录都当成 malformed line 丢弃。before/after 验证确认:旧压缩保留 4 条中的 2 条,PR 保留全部 4 条。

方向:对齐。改动范围窄,仅限于 automation history 压缩逻辑——不涉及 automation 执行、调度、webhook retry 或根目录 packages/core JSONL 工具。属于 roadmap/background-automation 范畴。PR 明确避免了引入跨 workspace 依赖,保持 desktop 包自包含。

方案:范围合理。改动最小——一个新增的解析函数(~50 行)加上 compactEntries 中行解析循环的重构。needsRewrite 标志正确处理了恢复出的记录数恰好等于物理行数的边界情况。测试覆盖了所有声明的边界场景(嵌套对象、3+ 粘连、尾部垃圾、多余花括号、非 object JSON、字符串中的转义引号、计数巧合)。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

2a. Code Review (re-run)

Independent proposal: I would have added a character-by-character JSON object boundary scanner that tracks brace depth while correctly handling string literals and escape sequences. When JSON.parse(line) fails, run the scanner to extract balanced top-level object segments, validate each with JSON.parse, and reject non-object values (null, primitives, arrays). Force a rewrite when recovery changes the record count or structure.

The PR's implementation matches this approach exactly:

  • recoverHistoryObjectsFromLine is a clean depth-tracking scanner — handles strings (inString), escapes (escaped), nested objects (brace depth), stray closing braces (depth < 0 → early return), and inter-object garbage (non-whitespace at depth 0 → early return).
  • isValidHistoryObject correctly rejects null, primitives, and arrays — important because the old code's parsed.id ?? '' would have silently passed these through.
  • needsRewrite flag correctly catches glued records that survive recovery with matching count, preventing invisible glued lines from persisting in the file.

No correctness bugs, no security issues, no AGENTS.md violations. The code is focused, well-structured, and stays within the desktop package without introducing cross-workspace dependencies.

2b. Real-Scenario Testing

Unit tests: 17 pass, 0 fail, 54 expect() calls — including 7 new tests covering glued-record recovery.

Before/after demonstration with crafted automations-history.jsonl containing glued records:

$ node tmp/triage-6344/test-glued.mjs 2>&1 | tee tmp/triage-6344/output.log
=== Input automations-history.jsonl ===
{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":2,"ok":true}{"id":"a1","ts":3,"ok":true}
not-json{{{
{"id":"a1","ts":4,"ok":true}

=== BEFORE (old compaction) ===
{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":4,"ok":true}

Entries preserved: 2 (ts: 1, 4)

=== AFTER (PR #6344 compaction) ===
{"id":"a1","ts":1,"ok":true}
{"id":"a1","ts":2,"ok":true}
{"id":"a1","ts":3,"ok":true}
{"id":"a1","ts":4,"ok":true}

Entries preserved: 4 (ts: 1, 2, 3, 4)

=== Verdict ===
Before: 2 entries survived compaction (ts 2 and 3 DROPPED)
After:  4 entries survived compaction (all recoverable records preserved)

The before/after confirms: old compaction silently drops glued records (ts 2 and 3 lost), PR's compaction recovers all 4 entries and normalizes them onto separate lines.

中文说明

2a. 代码审查(re-run)

独立方案:我会添加一个逐字符的 JSON 对象边界扫描器,在正确处理字符串字面量和转义序列的同时追踪花括号深度。当 JSON.parse(line) 失败时,运行扫描器提取平衡的顶层对象片段,用 JSON.parse 验证每个片段,并拒绝非 object 值(null、基本类型、数组)。当恢复改变了记录数量或结构时强制重写。

PR 的实现与这个方案完全一致:

  • recoverHistoryObjectsFromLine 是一个简洁的深度追踪扫描器——处理字符串(inString)、转义(escaped)、嵌套对象(花括号深度)、多余闭合花括号(depth < 0 → 提前返回)和对象间垃圾(depth 0 处的非空白字符 → 提前返回)。
  • isValidHistoryObject 正确拒绝 null、基本类型和数组——这很重要,因为旧代码的 parsed.id ?? '' 会默默放过这些值。
  • needsRewrite 标志正确捕获了恢复后记录数恰好匹配的情况,防止不可见的粘连行继续留在文件中。

没有正确性 bug,没有安全问题,没有 AGENTS.md 违规。代码聚焦、结构清晰,保持在 desktop 包内,不引入跨 workspace 依赖。

2b. 真实场景测试

单元测试:17 pass, 0 fail, 54 expect() calls ——包括 7 个新测试覆盖粘连记录恢复。

使用构造的 automations-history.jsonl(含粘连记录)进行 before/after 验证:

(见上方终端输出)

before/after 确认:旧压缩默默丢弃粘连记录(ts 2 和 3 丢失),PR 的压缩恢复了全部 4 条记录并将它们规范化为单独的行。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Stepping back (re-run): this is a well-executed, narrow bug fix. The problem is real — partial writes to automations-history.jsonl can leave two JSON objects glued on one line, and old compaction silently drops both. The fix is the minimum code needed: a ~50-line object boundary scanner with proper string/escape handling, integrated cleanly into the existing compactEntries flow.

My independent proposal matched the PR's approach exactly. The implementation handles all stated edge cases, the test suite is thorough (17 tests, 54 assertions), and the before/after demonstration confirms the bug is fixed — 2 entries recovered where 2 were previously dropped.

No scope creep, no cross-package dependencies, no unnecessary abstractions. The needsRewrite flag is a subtle but important addition that prevents invisible glued lines from persisting across compaction cycles.

Approving. ✅

中文说明

回顾(re-run):这是一个执行良好的、范围精确的 bug 修复。问题是真实的——对 automations-history.jsonl 的部分写入可能导致两个 JSON 对象粘在同一行,旧压缩逻辑会默默丢弃两者。修复是所需的最少代码:一个约 50 行的对象边界扫描器,具有正确的字符串/转义处理,干净地集成到现有的 compactEntries 流程中。

我的独立方案与 PR 的方案完全一致。实现处理了所有声明的边界情况,测试套件充分(17 个测试,54 个断言),before/after 验证确认 bug 已修复——之前丢弃的 2 条记录现在被恢复。

没有范围蔓延,没有跨包依赖,没有不必要的抽象。needsRewrite 标志是一个微妙但重要的补充,防止不可见的粘连行在压缩周期中持续存在。

批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Downgraded from Approve to Comment: CI still running. Suggestion-level recommendations are in the Suggestion summary comment below.

@wenshao

wenshao commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit 77d094775957ecd87a3d6bf94c0c2ba7eaea2d3f

File Issue Suggested fix
packages/desktop/packages/shared/src/automations/history-store.ts The three recovery paths (recoverHistoryObjectsFromLine early returns, parseHistoryLine inner catch, non-object JSON rejection) silently discard data with no diagnostic logging. Peer modules in the same directory (retry-scheduler.ts, event-bus.ts) log error-recovery paths verbosely using the same createLogger utility. Add log.debug at bail-out points and the inner catch, e.g. log.debug('[HistoryStore] Recovery stopped at position %d, recovered %d objects', i, recovered.length)

— qwen3.7-max via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Downgraded from Approve to Comment: CI still running. No critical issues found — the implementation is correct for the documented use case. Suggestions below for robustness improvements.

— qwen3.7-max via Qwen Code /review

return [];
}
} else if (depth === 0 && char.trim() !== '') {
return [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The in-loop guard return [] fires immediately on any non-whitespace at depth 0, even after valid objects have been accumulated in recovered. Similarly, recovered.length > 1 ? recovered : [] at the end discards a single valid object with trailing corruption.

In a crash-during-append scenario (e.g. {"id":"a","ts":1}{"id":"b","ts":2}partial-write), both valid entries are silently discarded despite the state machine having successfully identified them.

Consider breaking out of the loop (instead of return []) when trailing non-whitespace is encountered and returning whatever was already accumulated. Also adjust the final gate from > 1 to >= 1 — the caller already filters single-segment returns through the normal JSON.parse fast path.

— qwen3.7-max via Qwen Code /review

@VectorPeak VectorPeak Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this. Updated in 25f3bf586: the recovery state machine now preserves already recovered complete object records when it encounters trailing garbage/partial-write text, and the focused tests cover that exact prefix-preservation case.

// Fall through to drop the whole glued line below.
}
return [];
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The comment "Fall through to drop the whole glued line below" suggests continued execution, but return [] on the next line exits immediately. A future maintainer reading only the comment might conclude the return [] is redundant and remove it, silently changing behavior.

Suggested change
}
} catch {
// Any sub-object failed — reject the entire glued line.
}
return [];

— qwen3.7-max via Qwen Code /review

@VectorPeak VectorPeak Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. Updated in 25f3bf586: the misleading fall-through comment is gone, and recovered segments are now validated through a shared helper while invalid recovered object segments are skipped without dropping other valid segments.

expect(entries).toHaveLength(3);
});

it('should preserve glued object records while dropping unrecoverable lines', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The two new test cases cover the happy path well, but several edge cases for the recovery state machine are untested:

  • Nested objects in glued lines (e.g. {"a":{"b":1}}{"c":2}) — depth tracking is the core mechanism but only tested implicitly via string-embedded braces
  • 3+ glued objects — only 2-object lines are tested; cursor tracking across multiple boundaries is untested
  • Trailing garbage (e.g. {"a":1}{"b":2}garbage) — the rejection path is untested
  • needsRewrite triggers file rewrite — no test asserts the file is rewritten when glued lines exist but counts are within limits
  • All-or-nothing drop — no test verifies what happens when one sub-object in a glued line is invalid

— qwen3.7-max via Qwen Code /review

@VectorPeak VectorPeak Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks, added the requested edge coverage in 25f3bf586: nested objects, 3+ glued objects, trailing partial garbage, rewrite normalization when glued lines are within limits, and invalid recovered object segments preserving surrounding valid records.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks @wenshao for the careful suggestions. I updated the PR in 25f3bf586 to:

  • extract the repeated object-validity check into a local isValidHistoryObject() helper, with explicit string-only id extraction;
  • keep valid recovered object segments instead of dropping the whole glued line when one recovered segment is invalid;
  • add focused tests for nested/3+ glued objects, trailing partial writes, rewrite normalization, and invalid recovered object segments.

Validation re-run after rebasing on the updated remote branch:

D:\ZXY\Dev\nodejs\npx.cmd --yes bun test packages/shared/src/automations/history-store.test.ts
14 pass, 0 fail, 43 expect() calls

D:\ZXY\Dev\nodejs\npx.cmd --yes bun run typecheck:shared
passed

wsl -e bash -lc 'cd /mnt/d/ZXY/Github/qwen-code/packages/desktop && npx --yes bun test packages/shared/src/automations/history-store.test.ts'
14 pass, 0 fail, 43 expect() calls

git diff --check
passed

The commit includes your co-author trailer for the substantive review suggestion. Thanks again.

@wenshao

wenshao commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — real local build & tests ✅

I built and ran this PR locally against the real desktop bun workspace at head 517b942, plus A/B mutation and an end-to-end driver on the real compaction code path. Verdict: correct, well-scoped, safe to merge. One non-blocking test-coverage note below.

Why local matters here: the changed files live in packages/desktop/**, which is an independent bun workspace excluded from the root npm workspaces ("!packages/desktop") and absent from vitest.workspace.ts. The PR-gating Test (ubuntu-latest) job does not run these files — desktop tests only run in desktop-release.yml. So the green CI check does not exercise this change; local bun is the real signal.

1 · Real test suite + typecheck @ head

12 pass / 0 fail for the focused suite, tsc --noEmit clean, and 226 pass / 0 fail across the whole automations/ dir (no regression) — matching the author's reported result.

test suite + typecheck

2 · A/B mutation — are the tests (and the fix) load-bearing?

  • Mutation A — swap the PR source for the pre-fix (main) version, keep the new tests → exactly the 2 new glued-record tests fail (10 pass / 2 fail). The tests genuinely pin the fix. It also reproduces the old non-object leak: 42/[] used to survive as junk entries (ts → [1, undefined, undefined]).
  • Mutation B — keep the recovery, delete only the needsRewrite dirty-check gate → all 12 tests still pass (surviving mutant). See §4.

A/B mutation

3 · End-to-end driver on the real compaction path

A driver that imports the actual compactAutomationHistory (→ runCompactionwithMutexcompactEntrieswriteFile) and reads results back the way production consumers do (server-core/.../rpc/automations.ts: per-line JSON.parse, so glued lines are invisible to readers). All 13 checks pass across 7 scenarios — including the issue #6343 repro (glued line invisible before → all records visible after), narrow non-object rejection, braces/escaped-quotes inside strings preserved byte-for-byte, single-malformed still dropped, idempotency (stable fixpoint), and recovery composing with the retention caps.

e2e driver

4 · Note (non-blocking): the needsRewrite flag is correct but untested

The new needsRewrite flag is load-bearing — it's what forces a rewrite in the "count-coincidence" case (a glued line + a malformed line, where recovered-entry-count happens to equal physical-line-count, so the old trimmed.length === originalLineCount short-circuit would return null). Head splits the glued line correctly; with the flag removed the file is left un-normalized and readers see ts=[]:

needsRewrite head vs mutant

Impact is low (delayed normalization, not data loss — the bytes stay on disk and a later compaction where the counts differ will still rewrite), but since removing the flag survives the whole suite, a one-line test for the count-coincidence case would be worth adding. The two items in my earlier suggestion-summary comment (all-or-nothing partial recovery, duplicated object-validity check) remain non-blocking — the all-or-nothing behavior is a safe superset of pre-PR behavior (which dropped these lines entirely), not a regression, as §3 scenario 4 shows.

Verdict

Correct, narrow, no regressions, idempotent, retention-safe. LGTM — safe to merge. The needsRewrite coverage gap and the two style suggestions are all follow-up-optional, not merge blockers.

🇨🇳 中文说明(点击展开)

维护者验证 —— 本地真实构建与测试 ✅

我在本地针对真实的 desktop bun workspace(head 517b942)构建并运行了本 PR,另外做了 A/B 变异测试和一个跑在真实 compaction 代码路径上的端到端驱动。结论:实现正确、范围收敛、可以合并。 下面有一条不阻塞合并的测试覆盖建议。

为什么本地验证在这里很关键: 改动文件位于 packages/desktop/**,这是一个独立的 bun workspace —— 它被 root npm workspaces 显式排除("!packages/desktop"),也不在 vitest.workspace.ts 里。PR 门禁的 Test (ubuntu-latest) job 根本不会运行这些文件,desktop 测试只在 desktop-release.yml 里跑。所以那个绿色的 CI 勾并没有覆盖本次改动,本地 bun 才是真正的信号。

1 · head 上的真实测试 + 类型检查:聚焦测试 12 pass / 0 failtsc --noEmit 无错误,整个 automations/ 目录 226 pass / 0 fail(无回归),与作者报告一致(图 1)。

2 · A/B 变异 —— 测试和修复是否承重?

  • 变异 A:把 PR 源码换回修复前(main)版本、保留测试 → 恰好两个新的粘连记录测试失败10 pass / 2 fail),说明测试真正钉住了修复;同时复现了旧代码的非对象泄漏 bug:42/[] 以前会作为垃圾条目留存(ts → [1, undefined, undefined])。
  • 变异 B:保留 recovery、只删掉 needsRewrite 脏检查那一处 → 12 个测试仍全过(存活变异)。见第 4 点。

3 · 跑在真实 compaction 路径上的端到端驱动:驱动直接 import 真实的 compactAutomationHistory(→ runCompactionwithMutexcompactEntrieswriteFile),并按生产消费方的方式读回(server-core/.../rpc/automations.ts:逐行 JSON.parse,因此粘连行对读者不可见)。7 个场景共 13 项检查全过 —— 包括 issue #6343 复现(compaction 粘连行不可见 → 全部记录可见)、非对象值的窄化拒绝、字符串内花括号/转义引号逐字节保留、单条 malformed 仍被丢弃、幂等性(稳定不动点),以及 recovery 与 retention 上限的组合。

4 · 提示(不阻塞):needsRewrite 标志正确但无测试覆盖:新增的 needsRewrite承重的 —— 在"计数巧合"场景(一条粘连行 + 一条 malformed 行,恢复出的条目数恰好等于物理行数,导致旧的 trimmed.length === originalLineCount 短路会返回 null)下,正是它强制了重写。head 能正确拆分粘连行;去掉这个标志后文件不会被规整,读者看到 ts=[](图 4)。影响较低(只是延迟规整,不是数据丢失 —— 字节仍在磁盘上,之后某次计数不巧合的 compaction 仍会重写),但既然删掉它能骗过整个测试套件,建议补一条针对该场景的一行测试。我之前 suggestion summary 评论里的两点(all-or-nothing 的部分恢复、重复的对象校验)依旧不阻塞 —— all-or-nothing 是修复前行为的安全超集(修复前这些行整条被丢),并非回归,正如第 3 点场景 4 所示。

结论:正确、收敛、无回归、幂等、retention 安全。LGTM,可以合并。 needsRewrite 覆盖缺口和两条风格建议都是可选的后续项,不是合并阻塞项。

Verified locally on macOS with bun 1.3.14 in an isolated git worktree at PR head 517b942; screenshots are real terminal output rendered from captured ANSI. Driver & mutation scripts available on request.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks @wenshao, great catch on the surviving needsRewrite mutant. I added the focused count-coincidence coverage in e6bd16b54: one glued line plus one malformed line now verifies that compaction still rewrites the file even when recovered-entry-count matches physical-line-count.

Re-run validation after the new test:

D:\ZXY\Dev\nodejs\npx.cmd --yes bun test packages/shared/src/automations/history-store.test.ts
15 pass, 0 fail, 46 expect() calls

D:\ZXY\Dev\nodejs\npx.cmd --yes bun run typecheck:shared
passed

wsl -e bash -lc 'cd /mnt/d/ZXY/Github/qwen-code/packages/desktop && npx --yes bun test packages/shared/src/automations/history-store.test.ts'
15 pass, 0 fail, 46 expect() calls

git diff --check
passed

PR body has also been updated with the new validation count and count-coincidence rewrite coverage. Thanks again for the precise local verification.

cursor = i + 1;
start = -1;
} else if (depth < 0) {
return [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] return [] on depth < 0 discards all previously recovered valid objects. Every other early-exit path in this function (non-whitespace gap at depth 0, trailing non-whitespace, end-of-loop trailing content) correctly returns recovered — only this branch throws them away.

For input like {"id":"a1","ts":1}}garbage, the first object is fully parsed and pushed into recovered, then the stray } decrements depth to -1, and return [] silently drops the valid entry. This is the exact data-loss scenario the PR exists to prevent.

Suggested change
return [];
} else if (depth < 0) {
return recovered;
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching this. Fixed in 221c2570b: the depth < 0 branch now returns the already recovered object segments instead of clearing them, so a later stray } cannot discard valid history objects that were parsed before it.

I also added focused coverage for this exact case: a valid recovered record followed by a stray }/garbage suffix is preserved alongside the following normal record.

cursor = i + 1;
start = -1;
} else if (depth < 0) {
return [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Two defensive branches lack dedicated test coverage:

  1. The depth < 0 branch (line 186) — no test exercises a stray } after valid objects on a glued line.
  2. The isValidHistoryObject false path in parseHistoryLine's try-block — while null/42/[] lines appear in the "nested and 3+ glued" test, they only assert final entry count, not that these values trigger file rewrite via needsRewrite.

Consider adding a test for each path, e.g.:

it('should recover valid objects before a stray closing brace', async () => {
  writeFileSync(
    join(tempDir, AUTOMATIONS_HISTORY_FILE),
    JSON.stringify(makeEntry('a1', 1)) + '}garbage\n' + JSON.stringify(makeEntry('a1', 2)) + '\n',
  );
  await compactAutomationHistory(tempDir, 20, 1000);
  const entries = readHistory(tempDir);
  expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added both defensive coverage cases in 221c2570b:

  • stray closing brace after a valid recovered object;
  • non-object JSON lines (null, 42, []) being normalized out of the rewritten history file.

Re-run validation:

D:\ZXY\Dev\nodejs\npx.cmd --yes bun test packages/shared/src/automations/history-store.test.ts
17 pass, 0 fail, 52 expect() calls

D:\ZXY\Dev\nodejs\npx.cmd --yes bun run typecheck:shared
passed

wsl -e bash -lc 'cd /mnt/d/ZXY/Github/qwen-code/packages/desktop && npx --yes bun test packages/shared/src/automations/history-store.test.ts'
17 pass, 0 fail, 52 expect() calls

git diff --check
passed

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up review. I pushed 221c2570b to address the requested recovery edge case and test coverage:

  • recoverHistoryObjectsFromLine() now preserves already recovered objects when a later stray } drives depth < 0;
  • added focused tests for the stray-closing-brace branch and for non-object JSON rewrite normalization;
  • refreshed the PR body with the updated 17 pass / 0 fail / 52 expect() calls validation results.

Local validation passed on Windows and WSL/Linux, plus shared typecheck and git diff --check.

}
}

if (depth !== 0 || inString || line.slice(cursor).trim() !== '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The final guard clause is dead code — both branches return the same recovered:

if (depth !== 0 || inString || line.slice(cursor).trim() !== '') {
    return recovered;  // ← returns recovered
}

return recovered;  // ← also returns recovered

The condition has no observable effect. A future maintainer may assume the condition gates different behavior and add logic to one branch, introducing a subtle bug. Either remove the if entirely (just return recovered;) or implement the intended distinction — e.g., return { objects: recovered, partial: true } to signal incomplete recovery.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in the latest follow-up commit: the final guard was removed because it returned recovered on both paths and had no observable effect. The recovery function now has a single explicit return recovered; at the end.

await compactAutomationHistory(tempDir, 20, 1000);

const entries = readHistory(tempDir);
expect(entries.map((entry) => entry.ts)).toEqual([1, 2]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This test verifies both entries survive compaction but doesn't assert that the file was actually rewritten to remove }garbage. Sibling tests (e.g., "should preserve nested and 3+ glued object records") check the rewritten file content. Adding a file-content assertion would catch a regression where needsRewrite is accidentally bypassed:

const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
expect(rewritten).not.toContain('}garbage');
expect(rewritten.trim().split('\n')).toHaveLength(2);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the requested rewrite assertion to the stray }garbage test. It now verifies both behavior surfaces:

  • recovered entries still read back as [1, 2];
  • the rewritten file no longer contains }garbage and has exactly two normalized JSONL records.

Re-run validation:

D:\ZXY\Dev\nodejs\npx.cmd --yes bun test packages/shared/src/automations/history-store.test.ts
17 pass, 0 fail, 54 expect() calls

D:\ZXY\Dev\nodejs\npx.cmd --yes bun run typecheck:shared
passed

wsl -e bash -lc 'cd /mnt/d/ZXY/Github/qwen-code/packages/desktop && npx --yes bun test packages/shared/src/automations/history-store.test.ts'
17 pass, 0 fail, 54 expect() calls

git diff --check
passed

@VectorPeak

Copy link
Copy Markdown
Contributor Author

Latest reviewer suggestions have been addressed and pushed:

  • removed the dead final guard in recoverHistoryObjectsFromLine();
  • added rewrite assertions to the stray }garbage recovery test;
  • refreshed the PR body validation count to 17 pass / 0 fail / 54 expect() calls.

Validation passed on Windows, WSL/Linux, shared typecheck, and git diff --check.

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Downgraded from Approve to Comment: CI still running. No blockers — three suggestion-level findings below.

— qwen3.7-max via Qwen Code /review

} else if (depth < 0) {
return recovered;
}
} else if (depth === 0 && char.trim() !== '') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The recovery parser bails out (return recovered) on any non-whitespace character at depth 0, abandoning all subsequent objects on the same physical line. For a crash-interrupted write like {"id":"a1","ts":1,"ok":tr followed by the next entry {"id":"a1","ts":2,"ok":true} glued on the same line, the partial first object triggers a bail-out and the valid second object is silently lost. This is still strictly better than the old code (which dropped the entire line), but consider either resuming the scan after garbage or logging a warning when the early-return fires, so data loss is at least observable.

— qwen3.7-max via Qwen Code /review

await compactAutomationHistory(tempDir, 20, 1000);

const rewritten = readFileSync(join(tempDir, AUTOMATIONS_HISTORY_FILE), 'utf-8');
expect(rewritten).not.toContain('}garbage');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This assertion checks expect(rewritten).not.toContain('}garbage'), but the test input above uses 'partial-write' as the trailing garbage text. The substring }garbage never appears in the input, making this assertion vacuously true — it would pass regardless of whether the rewrite actually stripped the garbage. It should check expect(rewritten).not.toContain('partial-write') instead. (The sibling "stray closing brace" test correctly asserts against }garbage — this looks like a copy-paste from that test.)

Suggested change
expect(rewritten).not.toContain('}garbage');
expect(rewritten).not.toContain('partial-write');

— qwen3.7-max via Qwen Code /review


if (char === '{') {
if (depth === 0 && line.slice(cursor, i).trim() !== '') {
return recovered;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This bail-out branch (depth === 0 && line.slice(cursor, i).trim() !== '') — which fires when non-whitespace garbage appears between two recovered objects before the next { — has no dedicated test coverage. An input like {"a":1}xxx{"b":2} would exercise this path and verify that the parser correctly stops recovery when inter-object garbage is present.

— qwen3.7-max via Qwen Code /review

@danialzivehdadr

Copy link
Copy Markdown

Ok all dz 🧐

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestion-level recommendations are in the Suggestion summary comment below.

@wenshao

wenshao commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/core Core engine and logic priority/P2 Medium - Moderately impactful, noticeable problem scope/session-management Session state and persistence labels Jul 5, 2026
@qwen-code-ci-bot qwen-code-ci-bot added the type/bug Something isn't working as expected label Jul 5, 2026
@wenshao wenshao added this pull request to the merge queue Jul 5, 2026
Merged via the queue into QwenLM:main with commit 47f62a4 Jul 5, 2026
91 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/core Core engine and logic priority/P2 Medium - Moderately impactful, noticeable problem scope/session-management Session state and persistence type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop automation history compaction drops glued JSONL records

4 participants