fix(openclaw-tps-mail): surface and reconcile a failed cur/ record write after a terminal transition - #500
Conversation
…ite after a terminal transition (cli#492) Route every ack/nack stamp through one locked, existing-only, atomic cur/ writer that reuses the CLI's own mailbox lock. A failed write is logged by message id, record path and error code instead of being swallowed, and the terminal obligation is re-stamped onto the record on the next scan.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (9)
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe plugin now reports failed cur-record stamp writes, retries eligible acknowledgment and failure stamps, and reconciles terminal obligations with cur records during startup. Startup recovery and retention handling now account for records whose obligation state is unknown. ChangesTerminal stamp recovery
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant ackObligation
participant retryTerminalStamp
participant patchMailFile
participant CurRecord
ackObligation->>retryTerminalStamp: request ack stamp
retryTerminalStamp->>patchMailFile: apply stamp
patchMailFile->>CurRecord: write terminal stamp
CurRecord-->>retryTerminalStamp: return write result
retryTerminalStamp-->>ackObligation: return success or failure
Suggested reviewers: Merge Risk: ⚪ Minimal · up to No actionable issue introduced by this change remains identified. The PR is mergeable after normal checks. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The recovery design preserves identity checks, locking, and conservative handling of unresolved state. A changed public return contract could cause older integrations to mistake a failed write for success, although no affected integration has been identified. Retained concerns
Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 34.62% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 26 functions across 8 files. (1 skipped: 1 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
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. Comment |
Claim sweep (cli#492, measured on e08dd50)Every sentence the PR adds or changes, checked against the code.
PR body
Test names / operator strings
|
… (bounded, cancelled on stop); startup reconcile stays the backstop (#492) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mp uses main's locked cur/ helper Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mechanical merge: no conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y and changelog name the writer actually used Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reported; reconcile is idempotent; stop cancels its timer (tested) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e-dispatched; bad records never stop reconcile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…bound unresolved; validation text exact Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a mismatched obligation holds every identity it could represent Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nreadable directory; test titles say what they assert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… never inferred from the id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tps-mail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… give one remedy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ies left Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pin the unknown-vs-retained state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d; stop-time cancellation is not claimed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r every stamp/obligation message Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#468); every other message is main's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, not an assumed filename (#468) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iation holds unresolved (#468) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@coderabbitai review |
✅ Action performedReview finished.
|
tps-kern
left a comment
There was a problem hiding this comment.
Review — tpsdev-ai/cli PR #500 (cur/ stamp reconcile, head b143687)
Verdict: APPROVE. All five focus properties hold, each pinned by tests I ran green. Repo visibility re-checked today via the API: public. The findings below are one minor truthfulness question and environment disclosures — nothing blocking, nothing requiring private handling.
Scope note: the branch carries its full stack (merge-base with main is 70bccde; main has since advanced on other paths). I reviewed the net delta GitHub will land (9 files, +1050/−44) and verified it introduces no reversals of main's newer work — notably it keeps and builds on the #469 rule (d5a09c2 is an ancestor of the fork point, so the existing-only helper this PR tightens is the locked one).
Focus adjudications
The reconcile cannot regress a newer state — verified. patchMailFile (index.ts:632) drives the record only through updateExistingRecord's mutate-existing path, and its mutator: refuses a replaced identity (current.id !== inboundId → ID_MISMATCH throw, never stamps a stranger's record); returns null — no write — when current[stampKey] is already set (already-stamped ⇒ ok, never re-stamps, never overwrites); and the reconcile loop itself skips records whose stamp is present (index.ts:690) and applies only the additive terminal patch (ackedAt+read, or nackedAt+nackReason) the durable obligation says is owed. Nothing un-stamps, nothing moves a record backward, and a missing record is terminal (record-missing, no retry). Pinned by cur-record-write.test.ts: "a replacement identity is refused on the live stamp path", "a missing record is reported after the durable transition", and the restart matrix (stamps deleted from a settled record are re-stamped exactly once per boot, never re-dispatched).
The #469 existing-only update rule is followed — verified. Every stamp goes through updateExistingRecord (packages/cli/src/utils/mail.ts:189): read-modify-write under the mail lock with scratch+rename, never creating a record. The PR tightens the helper's error contract: gone now requires err?.path === path (mail.ts:224) so an ENOENT from the lock file or scratch space can't be misread as the record being gone, and lock-acquisition failures now carry the lock's own path (via mailLockPath), which patchMailFile's catch maps so the diagnostic names the lock — not a phantom record path (index.ts:652-655). Pinned by "lock/scratch acquisition reports the failing path in live and startup stamps".
No retry loop without a bound — verified. stampTerminalCur (index.ts:957): delays [1000, 4000, 16000], attempt 3 gets undefined → stop with retriesExhausted; record-missing never retries; every timer is account-lifetime-tracked (index.ts:822) and isLiveContext-gated, so account teardown cancels pending retries. Pinned exactly: "initial attempt + 3 retries = 4 logged failures, then no more… no attempt beyond the bound… nothing retries after the bound", and "stopping the account cancels the pending stamp retries". The reconcile pass itself is single-shot per boot, and findCurPath bounds the cur/ scan at 4096 entries (SCAN_LIMIT).
Diagnostics name id, path and code but no message content — verified. formatStampDiagnostic (diagnostics.ts:13) emits kind, id, actor, path, code, obligation presence, no retries left, and a remedy — never envelope/subject/body text; the reconcile's info lines carry id+path only; the nack reason (rec.failure, a named code) is written to the record but never logged. Pinned by diagnostics.test.ts and the id/path/code assertions throughout cur-record-write.test.ts.
A reconcile never re-delivers or re-acks — verified. reconcileTerminalCurStamps (index.ts:660) reads obligations/cur records defensively (readObligationResult never throws; torn or malformed records become unknown, not truth) and its only write is the stamp. It touches no dispatch path. The interlock is the load-bearing part: at boot, the reconcile runs FIRST (index.ts:2384); its unresolved set then (a) blocks crash-recovery re-dispatch of exactly those records (index.ts:2402 — a record whose terminal state cannot be proven is never re-delivered), and (b) holds those obligations from the retention sweep (obligations.ts:589, heldForRecovery), with a full obligations-directory read failure ("*") skipping the sweep entirely. Pinned by the "holds aged obligations across restarts without redispatch" matrix (both states × four failure stages, two restarts: one diagnostic per restart, dispatch() stays null, obligation bytes unchanged) and the retention cases (j)/(k).
Findings
- [minor, question] index.ts:677-678 / :691-692 — the reconciled stamp is reconcile-time, not transition-time. The reconcile writes
ackedAt/nackedAt: new Date().toISOString(); the obligation already carries the truthful transition time (lastTransitionAt, present since the field's introduction, withinboundTimestampfallback for legacy records). After a reconcile, the cur record claims the terminal transition happened at reconcile time. Retention is unaffected (the sweep ages by the obligation'slastTransitionAt, not the cur stamp — verified), so the impact is record truthfulness/observability only. Considerrec.lastTransitionAt ?? new Date().toISOString(), or a comment pinning the current choice as deliberate. - [nit] index.ts:652 — the
busybranch ofpatchMailFilereports the record path, not the lock path. The catch path carefully maps lock-path errors to the lock; the non-blockingbusystatus (currently unreachable — no caller setsnonBlocking) would name the record. Fine as-is; worth a one-line comment if a non-blocking caller ever appears. - [environment disclosure, not the PR] The plugin's standalone
tscbuild is red in my worktrees on this head and at origin/main: the pre-existing gateway-adapter block (isEnabled: (account) => …, index.ts:1692-1693 on head) loses contextual typing when theopenclawdev dependency's types don't materialize, plus — on main only — a staleopenclaw/plugin-sdk/channel-contractimport that this branch actually fixes. Likewise the suite's two remaining failures ("the trap, demonstrated" ×2,ENOENT …/node_modules/openclaw/package.json) are the dev dependency not being materialized in my tree. Control at origin/main fails the same builds (with more errors), so I attribute all of the above to my environment, not to this PR; CI is out of my lane.
What I ran / could not see
Internal author (tps-anvil): bun install --frozen-lockfile + root build (packages/agent + packages/cli — direct packages/cli build exits 0; the root script's first || true step emitted a pre-existing TS18046 at mail.ts:698, outside this PR's hunks, and does not fail the chain), plugin build attempted (see finding 3), and the full plugin suite through its HOME-isolated launcher (TMPDIR=/tmp required by its own guard — which refused to run inside the operator home, correctly): 303 pass / 2 fail, the two failures being the openclaw-dev-dependency materialization above, unrelated to the four changed test files, all of which pass (including the 557-line cur-record-write matrix, patch-mail-file, diagnostics, obligation-retention). The launcher's env allowlist and TPS_MAIL_DIR/TPS_TEST_KEYS_DIR isolation meant no run touched live mail state; my own runs left no processes behind. I could not see any truncated content that affected these conclusions; the one thing I did not run is the repo-root suites (agent/cli/root-test), which CI owns.
tps-sherlock
left a comment
There was a problem hiding this comment.
Sherlock review — PR #500, head b1436870 (surface + reconcile a failed cur/ stamp)
Repo visibility: PUBLIC (GET repos/tpsdev-ai/cli → .visibility = "public", checked before writing). Author tps-anvil is a tps-* agent, so this is an internal PR and I built and ran the tree. Scope: 8 files — production changes in plugins/openclaw-tps-mail/src/{index.ts,obligations.ts,diagnostics.ts} and packages/cli/src/utils/mail.ts.
Evidence (my own runs). Worktree ~/work/review-500-sherlock @ b1436870; repo build (bun run build) exit 0. Plugin suite run through the isolated launcher (node plugins/openclaw-tps-mail/scripts/run-tests.mjs, TMPDIR outside the home, HOME pointed at an empty dir): 301 pass / 4 fail / 1 error across 305 tests. The four failures are environmental, not this diff: node-load (cli#394) ×2, the trap, demonstrated, and a final-selection module load — all require the plugin's built dist/src/index.js and the openclaw peerDependency, neither present in this worktree (the node-load test states the precondition itself: "the workspace … and this plugin are built, so dist/src/index.js … exist"). Every cli#492 case and every reconciliation/retention case passes. The launcher's HOME-ISOLATION GUARD was clean once HOME pointed at an empty dir (it had flagged concurrent live-agent writes to the real ~/.tps on the first run — its own note: "on a host where live agents write ~/.tps, their activity shows here too"). No Harper started; production (:9926) untouched.
Focus — the diagnostic names id, path and code; no message content
formatStampDiagnostic (diagnostics.ts:13) composes only the named facts:
const fields = [ `tps-mail: ${facts.kind}:`, ...(facts.id === undefined ? [] : [facts.id]),
`actor=${facts.actor}`, `path=${facts.path}`, ...(facts.code === undefined ? [] : [`code=${facts.code}`]) ];There is no body/content/subject/nackReason field, and all three call sites (index.ts:665, :694, :971) pass only {kind, actor, id, path, code, obligation, retriesExhausted}. code is a system err.code (EACCES/ENOENT), a scan marker (SCAN_LIMIT/AMBIGUOUS_ID), or undefined; path is a mail-file path; id is the inbound id — never message content. diagnostics.test.ts pins the exact output strings. The nack body (nackReason: rec.failure) is written to the record, never to the log. Confirmed.
Focus — a reconcile never re-delivers or re-acks
reconcileTerminalCurStamps (index.ts:660) does exactly one write per terminal obligation — the terminal stamp that failed — and nothing else: it reads the obligation + cur record, skips when cur[key] is already set, and calls patchMailFile(curPath, patch, key, rec.inboundId), which routes through updateExistingRecord (existing-only, locked; cli#469 rule). It never dispatches, sends, or acks. The returned unknownInbounds set is then used to suppress the paths that could re-deliver or re-touch an uncertain inbound: crash recovery (index.ts:2402), the retention sweep (:2465), and the re-arm loop (:2483) all skip unknownInbounds. The tests assert this end-to-end — expect(next.dispatch()).toBeNull() and the … holds an aged timestamped inbound … without redispatch cases. The live retry (stampTerminalCur) re-runs only the stamp write. Confirmed: no re-delivery, no re-ack.
Supporting checks (all green here)
- The
_reindex-style identity guard:patchMailFilethrowsID_MISMATCHwhencurrent.id !== inboundId, so a replaced record is never stamped (tested for acked and failed). - A concurrent stamp between precheck and lock is preserved (
if (cur[key]) continueplus the locked re-read); a second reconciliation preserves mtime/inode and logs nothing. packages/cli/src/utils/mail.ts: the ENOENT branch is narrowed toerr?.code === "ENOENT" && err?.path === path, so a missing lock/scratch ENOENT is no longer mistaken for a gone record; lock-acquisition failures now carrymailLockPath(root)so the diagnostic names the lock.
Minor notes (non-blocking)
- [1]
index.ts:660— a terminal obligation whosecur/record is absent is held from the sweep.findCurPath(..., onReadError)reportsENOENTwhen no cur file matches, soreconcileTerminalCurStampsmarks that inbound unknown, and the sweep then retains it (the tests pin this: "missing cur lookup holds the obligation", and the retention suite now requires a stamped cur record to sweep). This is deliberate and logged (held N for unresolved cur/ recovery), so it is not silent — but an obligation whose cur record never reappears is retained indefinitely. Worth a bounded follow-up (retention is Kern's lane); not a defect in this diff. - [2] Retry bound.
stampTerminalCuris bounded to the initial attempt +[1000, 4000, 16000]ms, timersunref'd and cancelled when the context stops — tested by "no retries left" and "stopping the account cancels the pending stamp retries".
Verdict: APPROVE. The diagnostic exposes only id/path/code and a state summary (no message content); reconciliation only re-stamps the durable terminal state and its unknownInbounds set actively suppresses re-dispatch/re-acquire; the failed-stamp path is bounded, logged and covered. The four suite failures are a missing build artifact and openclaw peer dep, unrelated to this change.
A failed
cur/stamp write after a durable acked/failed transition was silently ignored bypatchMailFile.What changed
Evidence
A real OpenClaw lifecycle test is a follow-up.
Measured on cab4372:
bun run build,bun run lint:ci, pluginnpm run buildand CInpm run typecheck:test: exit 0.Closes #492.
Summary by CodeRabbit