Skip to content

Commit 7edf144

Browse files
committed
fix(orchestration): fail loud on Mode-A sub-issue truncation (review blocker 7)
fetchSubIssueGraph fetched a single 100-node page with no cursor and no cap check on the Mode-A path — so a human-authored epic with >100 sub-issues was SILENTLY truncated to the first 100, and the dropped children's dependency edges were filtered out (childIds only held the fetched set), leaving survivors that release out of order / immediately. The code comment claimed such epics are 'rejected before execution', but the cap lives only on the Mode-B decompose path. Added pageInfo{hasNextPage} to both the children and per-child inverseRelations connections and now return an explicit 'error' on truncation (split the epic) instead of a silently-wrong-order run. Chose fail-loud over full cursor pagination: a >100-sub-issue single epic is a modelling mistake, not a workload to silently accept. +2 truncation tests.
1 parent 361e57b commit 7edf144

2 files changed

Lines changed: 88 additions & 7 deletions

File tree

cdk/src/handlers/shared/linear-subissue-fetch.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,18 @@ const REQUEST_TIMEOUT_MS = 8000;
4646
const RELATION_TYPE_BLOCKS = 'blocks';
4747

4848
/**
49-
* Page size for the children / relations connections. Bounded by
50-
* ``max_sub_issues`` policy downstream; a parent with more children
51-
* than this is over-cap and will be rejected before execution, so a
52-
* single page is sufficient for the MVP (no cursor pagination).
49+
* Page size for the children / relations connections.
50+
*
51+
* review blocker #7: this query fetches a SINGLE page (no cursor loop). The old
52+
* comment claimed a >100-child parent is "rejected before execution", but the
53+
* Mode-A path (a human-authored sub-issue graph) has NO cap check — so an
54+
* over-size epic was SILENTLY truncated to the first 100 AND its dropped
55+
* children's dependency edges were filtered out, leaving survivors that release
56+
* out of order. Rather than silently truncate, we now detect ``hasNextPage`` on
57+
* either connection and return an explicit ``error`` (fail loud) so the operator
58+
* splits the epic instead of getting a wrong-order run. 100 is the platform
59+
* ceiling for a single Mode-A epic; a genuinely larger workload should be
60+
* multiple epics.
5361
*/
5462
const CONNECTION_PAGE_SIZE = 100;
5563

@@ -58,19 +66,22 @@ const CONNECTION_PAGE_SIZE = 100;
5866
*
5967
* For child C, ``inverseRelations`` of type ``blocks`` are relations
6068
* whose *source* issue blocks C — i.e. C's predecessors. We take the
61-
* related issue id from each as a ``depends_on`` edge.
69+
* related issue id from each as a ``depends_on`` edge. ``pageInfo.hasNextPage``
70+
* on both connections lets us detect (and reject) a truncated over-size graph.
6271
*/
6372
const SUB_ISSUE_GRAPH_QUERY = `
6473
query SubIssueGraph($issueId: String!, $first: Int!) {
6574
issue(id: $issueId) {
6675
id
6776
identifier
6877
children(first: $first) {
78+
pageInfo { hasNextPage }
6979
nodes {
7080
id
7181
identifier
7282
title
7383
inverseRelations(first: $first) {
84+
pageInfo { hasNextPage }
7485
nodes {
7586
type
7687
issue { id }
@@ -113,18 +124,22 @@ interface RawRelationNode {
113124
readonly issue?: { readonly id?: string } | null;
114125
}
115126

127+
interface RawPageInfo {
128+
readonly hasNextPage?: boolean;
129+
}
130+
116131
interface RawChildNode {
117132
readonly id?: string;
118133
readonly identifier?: string;
119134
readonly title?: string;
120-
readonly inverseRelations?: { readonly nodes?: readonly RawRelationNode[] } | null;
135+
readonly inverseRelations?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawRelationNode[] } | null;
121136
}
122137

123138
interface RawSubIssueGraph {
124139
readonly data?: {
125140
readonly issue?: {
126141
readonly id?: string;
127-
readonly children?: { readonly nodes?: readonly RawChildNode[] } | null;
142+
readonly children?: { readonly pageInfo?: RawPageInfo; readonly nodes?: readonly RawChildNode[] } | null;
128143
} | null;
129144
};
130145
readonly errors?: unknown;
@@ -204,6 +219,33 @@ export async function fetchSubIssueGraph(
204219
return { kind: 'no_children', parentIssueId: issue.id };
205220
}
206221

222+
// review blocker #7: fail LOUD on truncation instead of silently dropping
223+
// children (and their dependency edges). If Linear reports more children than
224+
// one page holds, or any child has more blockers than one page holds, we can't
225+
// build a correct DAG from a single fetch — reject so the operator splits the
226+
// epic rather than getting a silently-wrong-order run.
227+
if (issue.children?.pageInfo?.hasNextPage) {
228+
logger.warn('Linear sub-issue fetch truncated — parent has more children than one page', {
229+
parent_issue_id: issue.id, page_size: CONNECTION_PAGE_SIZE,
230+
});
231+
return {
232+
kind: 'error',
233+
message: `This epic has more than ${CONNECTION_PAGE_SIZE} sub-issues — too many for a single `
234+
+ `orchestration. Split it into multiple epics of at most ${CONNECTION_PAGE_SIZE} sub-issues each.`,
235+
};
236+
}
237+
const truncatedBlockersChild = childNodes.find((c) => c.inverseRelations?.pageInfo?.hasNextPage);
238+
if (truncatedBlockersChild) {
239+
logger.warn('Linear sub-issue fetch truncated — a child has more blockers than one page', {
240+
parent_issue_id: issue.id, child_id: truncatedBlockersChild.id, page_size: CONNECTION_PAGE_SIZE,
241+
});
242+
return {
243+
kind: 'error',
244+
message: `A sub-issue has more than ${CONNECTION_PAGE_SIZE} blocking relations — too many to order `
245+
+ `reliably. Reduce the cross-dependencies on sub-issue ${truncatedBlockersChild.identifier ?? truncatedBlockersChild.id}.`,
246+
};
247+
}
248+
207249
// Restrict depends_on edges to ids that are themselves children of
208250
// this parent — a "blocks" relation pointing at an issue outside the
209251
// epic is not an intra-epic ordering constraint. (validateDag also

cdk/test/handlers/shared/linear-subissue-fetch.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,4 +172,43 @@ describe('fetchSubIssueGraph — error shapes', () => {
172172
const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl });
173173
expect(result.kind).toBe('error');
174174
});
175+
176+
// review blocker #7: over-size epics must FAIL LOUD, not silently truncate.
177+
test('children truncated (hasNextPage) → error, not a silently-cut graph', async () => {
178+
const fetchImpl = mockFetch({
179+
data: {
180+
issue: {
181+
id: 'PARENT',
182+
children: {
183+
pageInfo: { hasNextPage: true }, // Linear says there are more children
184+
nodes: [{ id: 'A' }, { id: 'B' }],
185+
},
186+
},
187+
},
188+
});
189+
const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl });
190+
expect(result.kind).toBe('error');
191+
if (result.kind === 'error') expect(result.message).toMatch(/more than 100 sub-issues/i);
192+
});
193+
194+
test("a child's blockers truncated (hasNextPage) → error (edges would be incomplete)", async () => {
195+
const fetchImpl = mockFetch({
196+
data: {
197+
issue: {
198+
id: 'PARENT',
199+
children: {
200+
pageInfo: { hasNextPage: false },
201+
nodes: [{
202+
id: 'A',
203+
identifier: 'ENG-1',
204+
inverseRelations: { pageInfo: { hasNextPage: true }, nodes: [] },
205+
}],
206+
},
207+
},
208+
},
209+
});
210+
const result = await fetchSubIssueGraph('tok', 'PARENT', { fetchImpl });
211+
expect(result.kind).toBe('error');
212+
if (result.kind === 'error') expect(result.message).toMatch(/blocking relations/i);
213+
});
175214
});

0 commit comments

Comments
 (0)