Skip to content
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ All notable user-visible changes to CASCADE are documented here. The format is l

### Fixed

- **Linear and JIRA inline checklist writes are now idempotent across provider/tool retries.** The shared markdown checklist engine now upserts by exact `### {Checklist Name}` heading and exact item text, merges duplicate inline sections into the first matching section, preserves non-checkbox prose from collapsed duplicate sections, preserves checked state when duplicate rows disagree, and keeps Trello on native checklist APIs. Linear also caches accepted description writes in-process so stale readback no longer overwrites or replays checklist appends into duplicate blocks. See Linear issue [MNG-741](https://linear.app/mongrel/issue/MNG-741/make-linear-checklist-mutations-idempotent-after-visibility-timeouts).

- **`cascade-tools scm reply-to-review-comment` now accepts `--body-file`, and `create-pr-review --comment` handles one inline comment object ergonomically.** `ReplyToReviewComment` now exposes the same generated body file-input contract as the other SCM comment commands. Array-of-object CLI params still prefer JSON arrays, but one top-level JSON object is normalized to a one-item array, while `null` and primitive JSON values fail early with the structured `json-parse` envelope instead of reaching the GitHub client. See Linear issues [MNG-736](https://linear.app/mongrel/issue/MNG-736/fix-cascade-tools-scm-review-command-cli-drift), [MNG-731](https://linear.app/mongrel/issue/MNG-731/friction-tooling-low-cascade-tools-reply-to-review-comment-rejects), and [MNG-729](https://linear.app/mongrel/issue/MNG-729/friction-tooling-low-create-pr-review-comment-accepted-an-object-but).

- **Linear inline checklist creation now waits for description visibility before releasing its mutation lock.** Linear can briefly return stale issue descriptions after accepting `updateIssue()`, so CASCADE now polls `getIssue()` under the existing per-issue description lock until the new markdown is visible. Parallel `AddChecklist` flows no longer fail with `Checklist not found in description` when one flow creates a checklist heading immediately before another appends items. See Linear issue [MNG-685](https://linear.app/issue/MNG-685).
- **Linear inline checklist creation now preserves freshly written descriptions across stale readback windows.** Linear can briefly return stale issue descriptions after accepting `updateIssue()`, so CASCADE records successful writes under the existing per-issue description lock and uses that in-process value as the next mutation's base while the provider catches up. Parallel `AddChecklist` flows no longer fail with `Checklist not found in description` when one flow creates a checklist heading immediately before another appends items. See Linear issue [MNG-685](https://linear.app/issue/MNG-685).

- **Linear and JIRA inline checklist updates no longer lose sibling checklist rows during concurrent updates.** Both providers rewrite the whole issue description for checklist mutations, so their read/mutate/write path is now serialized per provider/work item with a stale-safe temp-file lock and provider-only retry semantics. The shared inline checklist parser also keeps scanning through prose, indented detail lines, and bullet detail lines until the next heading, so `ReadWorkItem` reports every visible checkbox row under a checklist heading. See Linear issue [MNG-656](https://linear.app/issue/MNG-656).

Expand Down
4 changes: 2 additions & 2 deletions src/integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,9 @@ Different PM providers have different native concepts of "checklist". The `PMPro

**Why inline markdown for Linear and JIRA?** Both providers support markdown checkboxes natively in their description editors but lack a dedicated lightweight checklist primitive — sub-issues and subtasks are full work items, which clutters boards when used for things like acceptance criteria or implementation steps. Inline markdown matches Trello's lightweight semantics without creating orphan issues. See [spec 008](../../docs/specs/008-inline-checklists.md.done) for full rationale.

The shared engine that parses, appends, toggles, and removes inline checklist items lives at `src/pm/_shared/inline-checklist.ts` and is consumed by both the Linear and JIRA adapters.
The shared engine that parses, upserts, toggles, and removes inline checklist items lives at `src/pm/_shared/inline-checklist.ts` and is consumed by both the Linear and JIRA adapters. Inline checklist creation is idempotent by exact markdown heading and exact item text: repeated writes for the same `### {Checklist Name}` section merge into the first matching section, duplicate sections with the same heading are collapsed while preserving non-checkbox prose, duplicate rows converge to one row, and checked state wins if any duplicate row is checked. This is intentional for CASCADE-generated checklists and keeps provider/tool retries from duplicating `Implementation Steps` or `Acceptance Criteria` blocks. Trello remains on native checklists and does not use this markdown merge behavior.

Because Linear and JIRA checklist mutations rewrite the whole description, their adapters serialize the full read/mutate/write operation with `withDescriptionMutationLock(provider, workItemId, fn)` from `src/pm/_shared/description-mutation-lock.ts`. Keep future inline-description mutations inside that guard; otherwise concurrent `cascade-tools pm update-checklist-item` processes can overwrite each other's description snapshots without a provider-side conflict error. Initial checklist creation for inline-description providers should implement `createChecklistWithItems` so `AddChecklist` can create the section and all starting rows in one locked description mutation instead of `createChecklist` plus one write per item. Linear mutations must also keep the lock held until `linearClient.getIssue()` observes the markdown just written by `linearClient.updateIssue()`, because Linear can briefly serve stale descriptions after accepting a write. Releasing the lock before read-after-write convergence lets the next queued checklist mutation start from an old snapshot and miss a newly created `###` checklist heading.
Because Linear and JIRA checklist mutations rewrite the whole description, their adapters serialize the full read/mutate/write operation with `withDescriptionMutationLock(provider, workItemId, fn)` from `src/pm/_shared/description-mutation-lock.ts`. Keep future inline-description mutations inside that guard; otherwise concurrent `cascade-tools pm update-checklist-item` processes can overwrite each other's description snapshots without a provider-side conflict error. Initial checklist creation for inline-description providers should implement `createChecklistWithItems` so `AddChecklist` can create the section and all starting rows in one locked description mutation instead of `createChecklist` plus one write per item. Linear can briefly serve stale descriptions after accepting `linearClient.updateIssue()`, so Linear records each successful description write in an in-process recent-description cache and uses that cached value as the next locked mutation's base while the provider catches up. Do not replay an accepted append solely because readback is stale; rely on idempotent upsert helpers plus the recent-description cache to keep retries and consecutive writes from duplicating or losing inline checklist content. Because `withDescriptionMutationLock` is a **filesystem** lock shared by separate `cascade-tools` processes, Linear also writes a durable cross-process sidecar (via `writeLockedDescription` / `readLockedDescription` in `description-mutation-lock.ts`) while holding the lock after each successful PUT. The next process that acquires the same lock reads the sidecar as its fresh base, preventing it from overwriting the previous process's accepted write with a stale Linear snapshot.

The default description-lock wait budget is intentionally lower than the checklist tool timeout: the lock waits up to 45s, while `AddChecklist`, `PMUpdateChecklistItem`, and `PMDeleteChecklistItem` allow 60s. Keep that relationship when adjusting either value so a queued, legitimate Linear/JIRA checklist mutation is not aborted by the outer tool runner before the lock wait can complete.

Expand Down
69 changes: 68 additions & 1 deletion src/pm/_shared/description-mutation-lock.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { constants } from 'node:fs';
import { mkdir, open, readFile, stat, unlink } from 'node:fs/promises';
import { mkdir, open, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand All @@ -22,6 +22,73 @@ const DEFAULT_STALE_MS = 120_000;
const DEFAULT_POLL_MS = 25;
const LOCK_DIR_ENV = 'CASCADE_DESCRIPTION_MUTATION_LOCK_DIR';

/**
* TTL for the cross-process description sidecar files. Must comfortably
* exceed the provider's eventual-consistency window so the fresh base
* written by one process is still readable by the next process that
* acquires the lock. 60 s matches the in-process recent-description
* cache TTL in the Linear adapter.
*/
const SIDECAR_TTL_MS = 60_000;

function getSidecarPath(lockDir: string, provider: string, workItemId: string): string {
return join(lockDir, `${sanitizePathPart(provider)}-${sanitizePathPart(workItemId)}.desc.json`);
}

/**
* Read the description written by the most-recent locked mutation for
* `(provider, workItemId)`. Returns `undefined` when no recent sidecar
* exists or it has expired.
*
* **Must be called while holding the description mutation lock** for the
* same (provider, workItemId) pair so the read-and-use is atomic across
* concurrent processes.
*/
export async function readLockedDescription(
provider: string,
workItemId: string,
options: DescriptionMutationLockOptions = {},
): Promise<string | undefined> {
const settings = resolveOptions(options);
const sidecarPath = getSidecarPath(settings.lockDir, provider, workItemId);
try {
const raw = await readFile(sidecarPath, 'utf8');
const data = JSON.parse(raw) as { description: string; timestamp: number };
if (typeof data.description !== 'string' || typeof data.timestamp !== 'number') {
return undefined;
}
if (Date.now() - data.timestamp > SIDECAR_TTL_MS) {
await unlink(sidecarPath).catch(() => {});
return undefined;
}
return data.description;
} catch {
return undefined;
}
}

/**
* Persist the description that was just written so that a subsequent locked
* mutation in a **different process** can use it as a fresh base rather than
* reading a stale snapshot from the PM provider.
*
* **Must be called while holding the description mutation lock** for the
* same (provider, workItemId) pair, immediately after a successful PUT.
* Best-effort: failures are non-fatal because the in-process cache still
* handles same-process retries.
*/
export async function writeLockedDescription(
provider: string,
workItemId: string,
description: string,
options: DescriptionMutationLockOptions = {},
): Promise<void> {
const settings = resolveOptions(options);
await mkdir(settings.lockDir, { recursive: true });
const sidecarPath = getSidecarPath(settings.lockDir, provider, workItemId);
await writeFile(sidecarPath, JSON.stringify({ description, timestamp: Date.now() }), 'utf8');
}

export async function withDescriptionMutationLock<T>(
provider: string,
workItemId: string,
Expand Down
Loading
Loading