Skip to content

fix(recovery): generate unique ids in prependThinkingPart (RPTU-001) - #423

Merged
ndycode merged 5 commits into
mainfrom
fix/thinking-part-unique-id
Apr 18, 2026
Merged

fix(recovery): generate unique ids in prependThinkingPart (RPTU-001)#423
ndycode merged 5 commits into
mainfrom
fix/thinking-part-unique-id

Conversation

@ndycode

@ndycode ndycode commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Addresses RPTU-001 from the deep recovery audit.

prependThinkingPart used a fixed prt_0000000000_thinking id and wrote to the same JSON filename on every invocation, so repeated prepend operations overwrote the earlier synthetic thinking part.

This PR generates a unique id per invocation and adds a regression test covering double-prepend behavior.

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr fixes RPTU-001: prependThinkingPart previously used a hardcoded prt_0000000000_thinking id and wrote to the same filename on every call, so a second recovery pass silently clobbered the first synthetic thinking part. the fix introduces generateThinkingPartId() — a timestamp + monotonic counter + random suffix composite that preserves the prt_0000000000_thinking_ sort prefix so the part still lands lexicographically before any real part id.

Confidence Score: 5/5

safe to merge — core fix is correct, atomic-write and windows retry patterns are followed, and remaining findings are p2 only

no p0/p1 issues found. the id uniqueness guarantee is sound (counter + timestamp + random), the sort-order invariant is proven in the new test, and the atomic-write path correctly handles windows ebusy/eperm locks. the two p2 notes are hardening suggestions only and do not affect correctness.

test/recovery-storage.test.ts — consider freezing Date.now() in the RPTU-001 double-prepend test to explicitly prove counter disambiguation

Important Files Changed

Filename Overview
lib/recovery/storage.ts adds generateThinkingPartId() with a module-level monotonic counter + timestamp + random suffix; prependThinkingPart now calls it instead of using a fixed id — fix is correct and aligns with existing atomic-write/retry patterns
test/recovery-storage.test.ts adds double-prepend regression test and sort-order assertion; regression test does not freeze Date.now(), so same-millisecond counter disambiguation is not explicitly isolated — see inline comment

Sequence Diagram

sequenceDiagram
    participant R as Recovery Caller
    participant PT as prependThinkingPart
    participant G as generateThinkingPartId
    participant C as thinkingPartCounter
    participant FS as atomicWriteFileSync

    R->>PT: prependThinkingPart(sessionID, msgID) [pass 1]
    PT->>G: generateThinkingPartId()
    G->>C: read counter (0), increment to 1
    G-->>PT: prt_0000000000_thinking_T_0_R1
    PT->>FS: write prt_0000000000_thinking_T_0_R1.json (atomic rename)
    FS-->>PT: ok
    PT-->>R: true

    R->>PT: prependThinkingPart(sessionID, msgID) [pass 2]
    PT->>G: generateThinkingPartId()
    G->>C: read counter (1), increment to 2
    G-->>PT: prt_0000000000_thinking_T_1_R2
    PT->>FS: write prt_0000000000_thinking_T_1_R2.json (atomic rename)
    FS-->>PT: ok
    PT-->>R: true

    Note over FS: both files coexist on disk — no overwrite
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: test/recovery-storage.test.ts
Line: 604-641

Comment:
**same-millisecond case not exercised**

the counter's whole reason for existing is to disambiguate two calls within the same millisecond, but `Date.now()` is not frozen here. in practice the two synchronous calls almost certainly share a tick, so the test probably does exercise it — but "probably" isn't a guarantee. freezing the clock makes the intent explicit and eliminates flakiness on very slow CI hosts.

```suggestion
		it("should generate unique ids on repeat calls so retries do not overwrite (RPTU-001)", () => {
			// Freeze time so both calls share the same millisecond, forcing the
			// monotonic counter to be the sole disambiguator (RPTU-001).
			const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000);
			const sessionID = "s";
			const messageID = "m";
			const partDir = join(PART_STORAGE, messageID);
			fsMock.existsSync.mockReturnValue(true);

			expect(storage.prependThinkingPart(sessionID, messageID)).toBe(true);
			expect(storage.prependThinkingPart(sessionID, messageID)).toBe(true);

			nowSpy.mockRestore();
```

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

---

This is a comment left during a code review.
Path: lib/recovery/storage.ts
Line: 157

Comment:
**module-level counter not reset-safe under concurrent recovery runs**

`thinkingPartCounter` is module-scoped and increments for the lifetime of the process, which is fine for the single-threaded Node.js happy path. just worth noting: if two separate recovery processes (e.g., a CLI restart while another instance is still running) both start at counter=0 in the same millisecond, the random suffix is the only remaining disambiguator (~33 bits). given the existing `Math.random()` component this is astronomically unlikely, but if you ever want to harden further, `crypto.randomInt` or a per-call UUID would eliminate the concern entirely. no action required now.

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

Reviews (2): Last reviewed commit: "test(recovery): remove brittle path/id s..." | Re-trigger Greptile

… (RPTU-001)

prependThinkingPart used a fixed part id (prt_0000000000_thinking) so two
recovery passes on the same messageID would atomically overwrite the same
synthetic thinking-part file, losing any prior state. Recovery paths retry
on failure (recoverThinkingBlockOrder loops over orphan messages and the
caller retries the request), so collisions were reachable in normal
operation rather than only under hypothetical races.

Introduces generateThinkingPartId() which keeps the prt_0000000000_thinking_
prefix so the synthetic part still sorts before real generatePartId() ids
and preserves the prepend semantics relied on by findMessagesWithOrphanThinking
and findMessageByIndexNeedingThinking. The id then appends a hex millisecond
timestamp, a monotonic counter, and a short random suffix so every
invocation writes a distinct file.

Adds a regression test that invokes prependThinkingPart twice on the same
sessionID/messageID and asserts that both calls stage and rename DIFFERENT
target paths with distinct embedded ids, plus a sort-order test that guards
the prepend invariant against future id-format drift.

No behavior change for first-pass recovery. Retries and repeated passes now
accumulate synthetic thinking parts instead of silently clobbering the
earlier one, which matches the observable file-per-part model used by
readParts and stripThinkingParts.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

replaces fixed synthetic thinking part id with dynamic generation using millisecond timestamp, monotonic counter, and random suffix. prevents overwrites when prependThinkingPart is called multiple times for the same session/message pair.

Changes

Cohort / File(s) Summary
ID Generation Logic
lib/recovery/storage.ts
Added thinkingPartCounter global state and generateThinkingPartId() export. new function generates unique IDs with format prt_0000000000_thinking_<hex-timestamp>_<counter>_<random>, used in prependThinkingPart to eliminate overwrite risk.
Test Coverage
test/recovery-storage.test.ts
Updated prependThinkingPart assertions to validate new ID format. added regression tests: (1) consecutive calls produce distinct IDs and files; (2) generated IDs sort lexicographically before generatePartId() results.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Suggested labels

bug


review notes:

  • lib/recovery/storage.ts: the global thinkingPartCounter state needs careful review for concurrency safety. if this module can be called from multiple async contexts or workers, counter increments race. consider using atomic or lock-free primitives if parallel writes are possible.

  • lib/recovery/storage.ts: the hex timestamp alone won't guarantee uniqueness across fast consecutive calls on the same millisecond. the counter mitigates this, but the random suffix as tiebreaker relies on crypto randomness—flag whether randomness source is cryptographically secure.

  • test/recovery-storage.test.ts: good coverage for the happy path, but missing windows edge cases. validate that file paths with the new ID format work correctly on windows (long path handling, forbidden characters in the random suffix if hex-based). also missing regression tests for: (1) counter overflow/wraparound behavior; (2) concurrent calls from multiple event loops or worker threads; (3) storage layer behavior when two processes write simultaneously.

  • the lexicographic sort test is solid but verify the prefix comparison semantics—prt_0000000000_thinking_ should consistently sort before prt_... from generatePartId() across all locale/collation contexts.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR description adequately covers the bug (RPTU-001: fixed id overwrite), the solution (generateThinkingPartId with timestamp+counter+random), and includes regression test coverage. However, the validation checklist is incomplete—build/lint/test commands are not checked off. Confirm all validation steps (npm run lint, typecheck, test, build) have passed before merge. Verify windows concurrency edge cases are covered.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed title follows conventional commits format with fix type, recovery scope, and lowercase imperative summary under 72 chars. accurately describes the core change: unique id generation in prependThinkingPart.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/thinking-part-unique-id
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/thinking-part-unique-id

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/recovery/storage.ts`:
- Around line 171-175: generateThinkingPartId can produce an empty random suffix
when Math.random() yields 0; update generateThinkingPartId to produce a
fixed-width 6-character base36 token instead of substring(2,8). Replace the
current random computation with something like: generate a random integer in
range [0, 36^6-1], convert it to base36, and padStart(6,'0') so the suffix is
always 6 chars; keep the rest of the id format and continue using
thinkingPartCounter and timestamp as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7d1ede88-aa3b-450b-afb5-fa02702389ed

📥 Commits

Reviewing files that changed from the base of the PR and between 3f1c1fe and 20c5f65.

📒 Files selected for processing (2)
  • lib/recovery/storage.ts
  • test/recovery-storage.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/recovery-storage.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/recovery/storage.ts
🪛 ast-grep (0.42.1)
test/recovery-storage.test.ts

[warning] 586-588: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(
^${partDir.replace(/\\/g, "\\\\")}[\\\\/]prt_0000000000_thinking_[0-9a-f]+_[0-9a-z]+_[0-9a-z]+\\.json$,
)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 632-634: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(
^${partDir.replace(/\\/g, "\\\\")}[\\\\/]prt_0000000000_thinking_.+\\.json$,
)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)


[warning] 637-639: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(
^${partDir.replace(/\\/g, "\\\\")}[\\\\/]prt_0000000000_thinking_.+\\.json$,
)
Note: [CWE-1333] Inefficient Regular Expression Complexity [REFERENCES]
- https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
- https://cwe.mitre.org/data/definitions/1333.html

(regexp-from-variable)

🔇 Additional comments (3)
lib/recovery/storage.ts (1)

410-416: good: prepend now uses the synthetic id generator.

Line 416 (lib/recovery/storage.ts:416) removes the fixed target filename path by using generateThinkingPartId(), and the affected vitest coverage is in test/recovery-storage.test.ts:604.

test/recovery-storage.test.ts (2)

573-602: good: single-prepend expectations match the new id shape.

Line 581 (test/recovery-storage.test.ts:581) and Line 599 (test/recovery-storage.test.ts:599) assert the dynamic thinking id format instead of the old fixed filename.


652-662: good: sort invariant is covered.

Line 661 (test/recovery-storage.test.ts:661) verifies the synthetic prefix, and Line 662 (test/recovery-storage.test.ts:662) preserves the ordering relied on by lib/recovery/storage.ts:384.

Comment thread lib/recovery/storage.ts
Comment on lines +171 to +175
export function generateThinkingPartId(): string {
const timestamp = Date.now().toString(16);
const counter = (thinkingPartCounter++).toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `prt_0000000000_thinking_${timestamp}_${counter}_${random}`;

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.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node - <<'NODE'
const current = (0).toString(36).substring(2, 8);
const fixed = Math.floor(0 * 36 ** 6).toString(36).padStart(6, "0");
console.log(JSON.stringify({ current, currentLength: current.length, fixed, fixedLength: fixed.length }, null, 2));
NODE

Repository: ndycode/codex-multi-auth

Length of output: 150


fix the empty suffix edge case in generateThinkingPartId.

lib/recovery/storage.ts:174 can produce an empty random suffix when Math.random() returns 0. .substring(2, 8) on "0" gives "", violating the documented id shape and the regex in test/recovery-storage.test.ts:599. use a fixed-width base36 token to guarantee the suffix is always 6 characters:

export function generateThinkingPartId(): string {
	const timestamp = Date.now().toString(16);
	const counter = (thinkingPartCounter++).toString(36);
-	const random = Math.random().toString(36).substring(2, 8);
+	const random = Math.floor(Math.random() * 36 ** 6)
+		.toString(36)
+		.padStart(6, "0");
	return `prt_0000000000_thinking_${timestamp}_${counter}_${random}`;
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function generateThinkingPartId(): string {
const timestamp = Date.now().toString(16);
const counter = (thinkingPartCounter++).toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `prt_0000000000_thinking_${timestamp}_${counter}_${random}`;
export function generateThinkingPartId(): string {
const timestamp = Date.now().toString(16);
const counter = (thinkingPartCounter++).toString(36);
const random = Math.floor(Math.random() * 36 ** 6)
.toString(36)
.padStart(6, "0");
return `prt_0000000000_thinking_${timestamp}_${counter}_${random}`;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/recovery/storage.ts` around lines 171 - 175, generateThinkingPartId can
produce an empty random suffix when Math.random() yields 0; update
generateThinkingPartId to produce a fixed-width 6-character base36 token instead
of substring(2,8). Replace the current random computation with something like:
generate a random integer in range [0, 36^6-1], convert it to base36, and
padStart(6,'0') so the suffix is always 6 chars; keep the rest of the id format
and continue using thinkingPartCounter and timestamp as before.

Comment thread test/recovery-storage.test.ts
ndycode added 4 commits April 18, 2026 16:07
Responds to PR #423 review feedback.

Freeze Date.now/Math.random in the double-prepend regression so the test
catches timestamp/counter shape regressions deterministically while still
allowing the short random suffix to vary within the expected base36 shape.
@ndycode
ndycode merged commit d7acb80 into main Apr 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant