Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l

### Fixed

- **`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 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: 3 additions & 1 deletion src/gadgets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ comments: {

The canonical parameter name always wins. Aliases are additive; suggestions returned by the fuzzy-matcher always point at the canonical form.

For array-of-object CLI params, the canonical input remains a JSON array. The shared parser also accepts one top-level JSON object and normalizes it to a one-element array so aliases like `--comment '{"path":"src/x.ts","line":1,"body":"nit"}'` behave the way agents naturally expect. Arrays pass through unchanged, and the parser validates only the top-level JSON shape; it does not inspect individual array entries.

### `cli.fileInputAlternatives`

Opt-in `--<param>-file <path>` escape hatches for long or JSON-shaped payloads that don't survive shell quoting. Pair with `parseAs: 'json'` for array-of-object / object params so the file contents are `JSON.parse`-d before reaching the gadget.
Expand Down Expand Up @@ -169,7 +171,7 @@ Two tuning constants live in `src/gadgets/shared/cli/parseErrors.ts`: `MAX_FLAG_

## Reference: `createPRReviewDef`

`src/gadgets/github/definitions.ts` is the reference gadget for spec 014 — it carries `cliAliases: ['comment']` on `comments`, `fileInputAlternatives` entries for `--body-file` (long review summaries) and `--comments-file` (JSON inline comments), and a well-formed `examples` block. Read it before you add a new gadget with array-of-object parameters.
`src/gadgets/github/definitions.ts` is the reference gadget for spec 014 — it carries `cliAliases: ['comment']` on `comments`, `fileInputAlternatives` entries for `--body-file` (long review summaries, reply bodies, and comment updates) and `--comments-file` (JSON inline comments), and a well-formed `examples` block. Read it before you add a new gadget with array-of-object parameters.

## Reference: `ReportFriction`

Expand Down
7 changes: 7 additions & 0 deletions src/gadgets/github/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,13 @@ export const replyToReviewCommentDef: ToolDefinition = {
],
cli: {
autoResolved: ownerRepoAutoResolved,
fileInputAlternatives: [
{
paramName: 'body',
fileFlag: 'body-file',
description: 'Read reply body from file (use - for stdin)',
},
],
},
};

Expand Down
74 changes: 70 additions & 4 deletions src/gadgets/shared/cli/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,58 @@ function parseJsonOrError(
}
}

function describeJsonTopLevel(value: unknown): string {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
return typeof value;
}

function normalizeArrayOfObjectJsonOrError(
parsed: unknown,
flag: string,
paramDef: ParameterDefinition,
fileAlt: FileInputAlternative | undefined,
example: unknown,
sink: ErrorSink,
): unknown {
if (paramDef.type !== 'array' || paramDef.items !== 'object') {
return parsed;
}

if (Array.isArray(parsed)) {
return parsed;
}

if (parsed !== null && typeof parsed === 'object') {
return [parsed];
}

const fileHint = fileAlt ? ` Or pass --${fileAlt.fileFlag} <path> (or - for stdin).` : '';
return emitCliError({
type: 'json-parse',
flag,
message: `Expected JSON array or object, got ${describeJsonTopLevel(parsed)}`,
got: JSON.stringify(parsed),
expected: expectedShapeFor(paramDef, example),
hint: `Pass a JSON array of objects or one JSON object to normalize into a single-item array.${fileHint}`,
stdout: sink.stdout,
stderr: sink.stderr,
exit: sink.exit,
});
}

function parseJsonParamOrError(
raw: string,
flag: string,
paramDef: ParameterDefinition,
fileAlt: FileInputAlternative | undefined,
example: unknown,
sink: ErrorSink,
): unknown {
const parsed = parseJsonOrError(raw, flag, paramDef, fileAlt, example, sink);
return normalizeArrayOfObjectJsonOrError(parsed, flag, paramDef, fileAlt, example, sink);
}

function resolveFileInputParam(
name: string,
paramDef: ParameterDefinition,
Expand All @@ -59,7 +111,14 @@ function resolveFileInputParam(
if (typeof fileFlagValue === 'string' && fileFlagValue.length > 0) {
const contents = readFileInput(fileFlagValue);
if (fileAlt.parseAs === 'json') {
resolvedParams[name] = parseJsonOrError(contents, name, paramDef, fileAlt, example, sink);
resolvedParams[name] = parseJsonParamOrError(
contents,
name,
paramDef,
fileAlt,
example,
sink,
);
return;
}
resolvedParams[name] = contents;
Expand All @@ -69,7 +128,14 @@ function resolveFileInputParam(
if (directValue !== undefined && directValue !== null) {
if (paramDef.type === 'array' && paramDef.items === 'object') {
const asString = typeof directValue === 'string' ? directValue : JSON.stringify(directValue);
resolvedParams[name] = parseJsonOrError(asString, name, paramDef, fileAlt, example, sink);
resolvedParams[name] = parseJsonParamOrError(
asString,
name,
paramDef,
fileAlt,
example,
sink,
);
return;
}
if (typeof directValue === 'string') {
Expand Down Expand Up @@ -103,7 +169,7 @@ function resolveObjectParam(
if (typeof rawValue !== 'string') {
return;
}
resolvedParams[name] = parseJsonOrError(rawValue, name, paramDef, undefined, example, sink);
resolvedParams[name] = parseJsonParamOrError(rawValue, name, paramDef, undefined, example, sink);
}

function resolveArrayOfObjectParam(
Expand All @@ -118,7 +184,7 @@ function resolveArrayOfObjectParam(
const rawValue = flags[name];
if (rawValue === undefined) return;
const asString = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue);
resolvedParams[name] = parseJsonOrError(asString, name, paramDef, fileAlt, example, sink);
resolvedParams[name] = parseJsonParamOrError(asString, name, paramDef, fileAlt, example, sink);
}

function resolveStandardParam(
Expand Down
100 changes: 64 additions & 36 deletions src/pm/linear/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,60 @@ import type {
WorkItemLabel,
} from '../types.js';

const DESCRIPTION_VISIBILITY_TIMEOUT_MS = 1_000;
const DESCRIPTION_VISIBILITY_POLL_MS = 25;
/**
* In-process read-after-write cache of recently-PUT issue descriptions.
*
* Linear's API is eventually consistent — a GET issued moments after a PUT
* can return the previous description for seconds. Polling the GET until
* visibility (the prior approach in commit fad4dda1) was too aggressive
* (1s timeout) and DOSed itself: planning runs MNG-741 / MNG-736 / MNG-739
* (2026-05-12) all failed with "Linear description visibility timed out"
* even though every PUT succeeded on Linear's side.
*
* The new contract: after each successful PUT, store the new description
* here. The next `updateDescription` call consults the cache before
* mutating — if the GET returned a stale value within the consistency
* window, the cached value wins. After TTL the entry is evicted and the
* GET becomes authoritative again.
*
* Scope: in-process only. Cross-process races against Linear's eventual
* consistency are NOT new to this fix and were never solved by the
* visibility wait — the existing `withDescriptionMutationLock` is
* process-local too.
*/
const RECENT_DESCRIPTION_TTL_MS = 60_000;
type RecentDescription = { description: string; timestamp: number };
const recentDescriptions = new Map<string, RecentDescription>();

function rememberRecentDescription(issueId: string, description: string): void {
recentDescriptions.set(issueId, { description, timestamp: Date.now() });
// Lazy cleanup — keep the map small without a setInterval.
if (recentDescriptions.size > 200) {
const cutoff = Date.now() - RECENT_DESCRIPTION_TTL_MS;
for (const [id, entry] of recentDescriptions.entries()) {
if (entry.timestamp < cutoff) recentDescriptions.delete(id);
}
}
}

function recallRecentDescription(issueId: string): string | undefined {
const entry = recentDescriptions.get(issueId);
if (!entry) return undefined;
if (Date.now() - entry.timestamp > RECENT_DESCRIPTION_TTL_MS) {
recentDescriptions.delete(issueId);
return undefined;
}
return entry.description;
}

/**
* Test-only escape hatch — each test starts with an empty cache so module-
* level state doesn't leak between cases. NOT exported via the public API;
* called only from `tests/unit/pm/linear/adapter.test.ts`.
*/
export function __resetRecentDescriptionsForTests(): void {
recentDescriptions.clear();
}
const CASCADE_STATUS_KEYS = new Set([
'backlog',
'todo',
Expand Down Expand Up @@ -341,10 +393,18 @@ export class LinearPMProvider implements PMProvider {
throw err;
}

const newDesc = mutate(issue.description ?? '');
// Read-after-write consistency: if we recently PUT a newer description
// for this issue and the GET above returned the stale pre-PUT value
// (Linear's eventual-consistency window), use the cached fresh value
// as the source of truth for the mutation. Without this, consecutive
// in-process updates can read-modify-write over each other.
const cachedDescription = recallRecentDescription(issueId);
const baseDescription =
cachedDescription !== undefined ? cachedDescription : (issue.description ?? '');
const newDesc = mutate(baseDescription);
try {
await linearClient.updateIssue(issueId, { description: newDesc });
await this.waitForDescriptionVisibility(issueId, newDesc);
rememberRecentDescription(issueId, newDesc);
return;
} catch (err) {
if (attempt === 0) {
Expand All @@ -356,30 +416,6 @@ export class LinearPMProvider implements PMProvider {
}
}

private async waitForDescriptionVisibility(
issueId: string,
expectedDescription: string,
): Promise<void> {
const startedAt = Date.now();
const deadline = startedAt + DESCRIPTION_VISIBILITY_TIMEOUT_MS;
const expected = normalizeDescriptionForVisibility(expectedDescription);

while (true) {
const issue = await linearClient.getIssue(issueId);
if (normalizeDescriptionForVisibility(issue.description ?? '') === expected) {
return;
}

if (Date.now() >= deadline) {
throw new Error(
`Linear description visibility timed out for issue ${issueId} after ${DESCRIPTION_VISIBILITY_TIMEOUT_MS}ms`,
);
}

await sleep(Math.min(DESCRIPTION_VISIBILITY_POLL_MS, Math.max(0, deadline - Date.now())));
}
}

private logDescriptionRetry(issueId: string, err: unknown): void {
logger.warn('[Linear] Description provider update failed; retrying once', {
issueId,
Expand Down Expand Up @@ -455,11 +491,3 @@ export class LinearPMProvider implements PMProvider {
};
}
}

function normalizeDescriptionForVisibility(description: string): string {
return description.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
13 changes: 12 additions & 1 deletion tests/unit/backends/shared-nativeToolPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import {
buildTaskPrompt,
buildToolGuidance,
} from '../../../src/backends/shared/nativeToolPrompts.js';
import { createPRReviewDef, updatePRCommentDef } from '../../../src/gadgets/github/definitions.js';
import {
createPRReviewDef,
replyToReviewCommentDef,
updatePRCommentDef,
} from '../../../src/gadgets/github/definitions.js';
import { readWorkItemDef } from '../../../src/gadgets/pm/definitions.js';
import { generateToolManifest } from '../../../src/gadgets/shared/manifestGenerator.js';

Expand Down Expand Up @@ -195,6 +199,13 @@ describe('buildToolGuidance', () => {
expect(result).toContain('[--body-file <string>]');
expect(result).toContain('Read comment body from file (use - for stdin)');
});

it('renders ReplyToReviewComment body-file guidance from definition metadata', () => {
const result = buildToolGuidance([generateToolManifest(replyToReviewCommentDef)]);

expect(result).toContain('[--body-file <string>]');
expect(result).toContain('Read reply body from file (use - for stdin)');
});
});

describe('formatParam — optional string param', () => {
Expand Down
13 changes: 13 additions & 0 deletions tests/unit/backends/toolManifests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,17 @@ describe('getToolManifests', () => {
'body-file': { type: 'string' },
});
});

it('ReplyToReviewComment has body-file support', () => {
const manifests = getToolManifests();
const replyToReviewComment = manifests.find((m) => m.name === 'ReplyToReviewComment');
expect(replyToReviewComment).toBeDefined();
expect(replyToReviewComment?.parameters).toMatchObject({
body: { type: 'string', required: true },
'body-file': {
type: 'string',
description: 'Read reply body from file (use - for stdin)',
},
});
});
});
Loading
Loading