Skip to content

Commit dcec12a

Browse files
committed
Fix 4 review findings on the probe/attachment paths (1 High) + stale-comment sweep
#1 (HIGH) Fail-closed on a failed context probe at the 3 remaining consumers that still ignored probe.ok (the earlier #5 fix only covered the single-task + Mode-A seed paths): - hydrateCommentAttachments (A6 new-work/clarify/revise/approve, probeIssue:true): probe.ok===false → return {ok:false} → caller replies ❌ instead of running a comment task blind to a newly-attached paperclip. - Mode B decompose DISPATCH: guard on probeOk before planning (probedAttachments came from the entry probe). - reconciler hydrateParentAttachmentsForSeed: throw LinearAttachmentError on probe.ok===false so the caller rejects the epic; fixed the docstring that falsely claimed full fail-closed when it only covered screening failures. #2 (MED) Mode A retrigger no longer re-uploads the parent attachment. Re-uploading created a new S3 version and demoted the meta-pinned one to noncurrent, which the bucket's 7-day noncurrentVersionExpiration reaps → a child released/retried later referenced an expired version. Now: check the meta row exists (alreadySeeded) and skip the re-upload; the frozen releaseContext keeps the original pinned records. Corrected the wrong "replay re-screens identical bytes, never orphans a pinned version" comment (S3 versioning makes each PUT a new version). #3 (MED→LOW) Project-doc remainder count is now honest beyond the hydration cap. Added an id-only documentsForCount connection (up to 50) so projectDocumentCount reflects the true total, not the 5-capped content page — the presence hint can now flag docs 6+ instead of hiding them. #4 (LOW) Child-own attachments: cap the paperclip INPUT to the own-budget BEFORE hydrating (was upload-all-10-then-slice, orphaning the excess in S3), and name the dropped files by their friendly paperclip title (was the path-safe UUID filename). Removed the stale "live-caught: row had 1, task had 0" comments (that finding was retracted as a premature-read artifact). Tests: +regressions for #2 (retrigger→no re-upload), #3 (count>content-page), #4 (trim-before-upload + friendly names); added probe mocks to the reconciler + plan-command suites whose real-fetch-fail now (correctly) fail-closes. Full CDK build (3299 tests) + agent gate green.
1 parent 6e73a79 commit dcec12a

8 files changed

Lines changed: 244 additions & 63 deletions

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 87 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,19 @@ async function hydrateCommentAttachments(params: {
315315
let paperclips: readonly LinearProbeAttachment[] = [];
316316
if (params.probeIssue) {
317317
const probe = await probeLinearIssueContext(params.accessToken, params.issueId);
318+
// Review #1: fail-CLOSED on a probe error. When probeIssue is set, a
319+
// newly-attached paperclip on the issue is a valid material source; if the
320+
// probe couldn't read the issue (ok:false — 500/timeout) an empty paperclip
321+
// list means "unknown", not "none", so a paperclip-only spec would silently
322+
// vanish. Reject rather than dispatch blind. (The comment BODY was still read
323+
// above; this only guards the probe-sourced paperclips.)
324+
if (probe.ok === false) {
325+
return {
326+
ok: false,
327+
message: "ABCA couldn't read this issue's attachments from Linear (the API errored or timed out). "
328+
+ 'Re-comment to retry rather than run on a spec that may be attached but unreadable.',
329+
};
330+
}
318331
paperclips = probe.attachments ?? [];
319332
}
320333
if (!commentHasUploads && !paperclips.some((a) => isLinearUploadsUrl(a.url))) {
@@ -397,19 +410,50 @@ async function hydrateChildrenOwnAttachments(
397410
continue;
398411
}
399412
const descHasUploads = Boolean(child.description && child.description.includes('uploads.linear.app'));
400-
if (!descHasUploads && !paperclips.some((a) => isLinearUploadsUrl(a.url))) continue;
413+
const ownPaperclips = paperclips.filter((a) => isLinearUploadsUrl(a.url));
414+
if (!descHasUploads && ownPaperclips.length === 0) continue;
415+
// Review #4a: cap the child's OWN budget = per-task limit − inherited parent
416+
// files, and TRIM THE INPUT before hydrating so we never fetch+screen+UPLOAD
417+
// files that would only be dropped afterward (the old code uploaded the full
418+
// 10 then sliced, orphaning the excess in S3 until lifecycle expiry). The
419+
// paperclip inputs carry a friendly `title`, so the drop note names real
420+
// filenames (review #4b), not the path-safe UUID the record exposes.
421+
const ownBudget = Math.max(0, MAX_ATTACHMENTS_PER_TASK - inheritedCount);
422+
const keptPaperclips = ownPaperclips.slice(0, ownBudget);
423+
const droppedPaperclips = ownPaperclips.slice(keptPaperclips.length);
424+
if (droppedPaperclips.length > 0) {
425+
const droppedNames = droppedPaperclips.map((a) => a.title || '(untitled)').join(', ');
426+
await safeReportIssueFailure(
427+
child.sub_issue_id, workspaceId,
428+
`⚠️ This sub-issue has more attachments than fit the ${MAX_ATTACHMENTS_PER_TASK}-file per-task limit `
429+
+ `once the epic's ${inheritedCount} shared file(s) are included, so these were NOT sent to the agent: `
430+
+ `${droppedNames}. Remove some attachments (here or on the epic) and re-apply the trigger label if the agent needs them.`,
431+
);
432+
logger.warn('Child own attachments trimmed to per-task cap BEFORE upload — user notified', {
433+
orchestration_id: orchestrationId,
434+
sub_issue_id: child.sub_issue_id,
435+
own_paperclips: ownPaperclips.length,
436+
inherited: inheritedCount,
437+
kept: keptPaperclips.length,
438+
});
439+
}
440+
// If the budget is fully consumed by inherited files and there are no
441+
// description-embedded uploads to try, there's nothing left to hydrate.
442+
if (ownBudget === 0 && !descHasUploads) continue;
401443
try {
402444
// Per-child S3 namespace so a child's own files never collide with the
403445
// epic key or another child's. taskId is a label here, not a real task id.
446+
// remainingSlots = ownBudget so the helper's own overflow guard matches the
447+
// cap; description-derived uploads beyond it throw → caught fail-open below.
404448
const hydrated = await hydrateLinearAttachments({
405449
issueId: child.sub_issue_id,
406450
uploadsText: child.description,
407451
workspaceId,
408452
platformUserId,
409453
accessToken,
410454
taskId: `child-${child.sub_issue_id}`,
411-
remainingSlots: MAX_ATTACHMENTS_PER_TASK,
412-
paperclips,
455+
remainingSlots: ownBudget,
456+
paperclips: keptPaperclips,
413457
});
414458
if (!hydrated.ok) {
415459
// Fail-OPEN: log + skip this child's own file (the epic + its inherited
@@ -420,37 +464,12 @@ async function hydrateChildrenOwnAttachments(
420464
continue;
421465
}
422466
if (hydrated.records.length > 0) {
423-
// Review #4: cap the child's OWN set so own + inherited-parent ≤ the
424-
// per-task limit, and SURFACE any drop (releaseChild's slice was a silent
425-
// truncation — the very pattern the single-task path is loud about). Own
426-
// files are kept ahead of the shared epic spec (most relevant to THIS
427-
// piece); a note names what won't be included so the user can trim/split.
428-
const ownBudget = Math.max(0, MAX_ATTACHMENTS_PER_TASK - inheritedCount);
429-
const kept = hydrated.records.slice(0, ownBudget);
430-
if (kept.length < hydrated.records.length) {
431-
const dropped = hydrated.records.slice(kept.length).map((r) => r.filename).join(', ');
432-
await safeReportIssueFailure(
433-
child.sub_issue_id, workspaceId,
434-
`⚠️ This sub-issue has more attachments than fit the ${MAX_ATTACHMENTS_PER_TASK}-file per-task limit `
435-
+ `once the epic's ${inheritedCount} shared file(s) are included, so these were NOT sent to the agent: `
436-
+ `${dropped}. Remove some attachments (here or on the epic) and re-apply the trigger label if the agent needs them.`,
437-
);
438-
logger.warn('Child own attachments trimmed to per-task cap — user notified', {
439-
orchestration_id: orchestrationId,
440-
sub_issue_id: child.sub_issue_id,
441-
own: hydrated.records.length,
442-
inherited: inheritedCount,
443-
kept: kept.length,
444-
});
445-
}
446-
if (kept.length > 0) {
447-
await setChildOwnAttachments(ddb, ORCHESTRATION_TABLE!, orchestrationId, child.sub_issue_id, kept, now);
448-
// Return the records so the caller can patch the in-memory snapshot
449-
// directly — a re-loadOrchestration here is eventually-consistent and
450-
// can read the pre-stamp replica, releasing the child WITHOUT its own
451-
// attachment (live-caught on abca-demo: row had 1, task had 0).
452-
stampedByChild.set(child.sub_issue_id, kept);
453-
}
467+
await setChildOwnAttachments(ddb, ORCHESTRATION_TABLE!, orchestrationId, child.sub_issue_id, hydrated.records, now);
468+
// Return the records so the caller can patch the in-memory snapshot
469+
// directly — a re-loadOrchestration here is eventually-consistent and
470+
// could read a pre-stamp replica, releasing the child WITHOUT its own
471+
// attachment. Patching in memory sidesteps that read-after-write window.
472+
stampedByChild.set(child.sub_issue_id, hydrated.records);
454473
}
455474
} catch (err) {
456475
logger.warn('Child own-attachment hydrate/persist failed (non-fatal)', {
@@ -468,8 +487,8 @@ async function hydrateChildrenOwnAttachments(
468487
* set from `stampedByChild` (sub_issue_id → records). Used right after
469488
* {@link hydrateChildrenOwnAttachments} so the release path sees a child's OWN
470489
* attachments WITHOUT a re-loadOrchestration (that Query is eventually-consistent
471-
* and can read a replica from before the stamp write — live-caught on abca-demo:
472-
* the child row had the attachment but the released task had zero).
490+
* and could read a replica from before the stamp write — the release would then
491+
* omit the just-stamped attachment; patching in memory closes that window).
473492
*/
474493
function patchChildOwnAttachments(
475494
snapshot: NonNullable<Awaited<ReturnType<typeof loadOrchestration>>>,
@@ -910,14 +929,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
910929
if (ORCHESTRATION_TABLE && resolvedAccessToken) {
911930
// finding #1 (Mode A): a parent with pre-existing sub-issues seeds HERE, not
912931
// through the reconciler's Mode-B path — so hydrate the parent's attachments
913-
// and stamp them on the meta row (releaseContext) so every child inherits
914-
// them. The helper no-ops (no S3/DDB) when the issue has no uploads, so the
915-
// common fall-through-to-single_task case pays nothing. Replay-safe without a
916-
// gate: the S3 key is derived deterministically from the upload URL (stable
917-
// across redeliveries → same key, idempotent PUT) and seedOrchestration is
918-
// frozen-at-first-seed (the meta row's pinned records never change on a
919-
// re-trigger), so a replay can only re-screen identical bytes, never orphan a
920-
// pinned version (#4).
932+
// and stamp them on the meta row (releaseContext) so every child inherits them.
933+
//
921934
// Fetch the sub-issue graph ONCE up front so we can (a) only hydrate the
922935
// parent's attachments to the `epic-<id>` key when children ACTUALLY exist
923936
// (a plain issue that falls through to single_task must NOT hydrate here —
@@ -926,12 +939,25 @@ export async function handler(event: ProcessorEvent): Promise<void> {
926939
// (b) hand the SAME graph to discoverOrchestration so it doesn't re-fetch.
927940
const graphSource = linearGraphSource(resolvedAccessToken, issue.id);
928941
const graphResult = await graphSource();
942+
// Review #2: hydrate ONLY on the FIRST seed. seedOrchestration is
943+
// frozen-at-first-seed, so on a RE-TRIGGER of an already-seeded epic the meta
944+
// row's releaseContext already pins the original records (a specific
945+
// s3_version_id). Re-uploading here would PUT a new current version and demote
946+
// the pinned one to noncurrent — which the bucket's 7-day
947+
// noncurrentVersionExpiration then reaps, so a child released/retried >7 days
948+
// later would reference an expired version. (My earlier "replay re-screens
949+
// identical bytes, never orphans a pinned version" comment was WRONG: S3
950+
// versioning makes each PUT a new version.) So skip the re-upload when the
951+
// orchestration meta row already exists.
952+
const alreadySeeded = graphResult.kind === 'ok'
953+
? Boolean(await loadOrchestration(ddb, ORCHESTRATION_TABLE, deriveOrchestrationId(issue.id)))
954+
: false;
929955
let epicAttachments: PassedAttachmentRecord[] = [];
930-
if (graphResult.kind === 'ok') {
931-
// Review #5: a failed context probe means we can't see the parent's native
932-
// paperclips — don't seed a whole epic whose children would inherit a spec
933-
// we couldn't read. Fail-closed (the graph fetch above succeeded, so this
934-
// is specifically an attachment-probe failure).
956+
if (graphResult.kind === 'ok' && !alreadySeeded) {
957+
// Review #5/#1: a failed context probe means we can't see the parent's
958+
// native paperclips — don't seed a whole epic whose children would inherit a
959+
// spec we couldn't read. Fail-closed (the graph fetch above succeeded, so
960+
// this is specifically an attachment-probe failure).
935961
if (!probeOk) {
936962
await safeReportIssueFailure(
937963
issue.id, workspaceId,
@@ -1259,6 +1285,18 @@ export async function handler(event: ProcessorEvent): Promise<void> {
12591285
return;
12601286
}
12611287
const planReqId = crypto.randomUUID();
1288+
// Review #1: fail-CLOSED on a probe error. probedAttachments came from the
1289+
// entry probe; if that failed (ok:false) we can't see native paperclips, so
1290+
// don't dispatch the planner blind to a spec it can't retrieve (no Linear
1291+
// MCP). The description-embedded uploads check below still holds regardless.
1292+
if (!probeOk) {
1293+
await safeReportIssueFailure(
1294+
issue.id, workspaceId,
1295+
"❌ ABCA couldn't read this issue's attachments from Linear (the API errored or timed out). "
1296+
+ 'Re-apply the trigger label to retry rather than plan a decomposition on a possibly-missing spec.',
1297+
);
1298+
return;
1299+
}
12621300
const planHasAttachments = Boolean(issue.description?.includes('uploads.linear.app'))
12631301
|| probedAttachments.some((a) => isLinearUploadsUrl(a.url));
12641302
// ADR-016: hand the planner the ACTUAL attachment bytes, not just a "there

cdk/src/handlers/orchestration-reconciler.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,26 +1708,37 @@ async function reconcileDecomposePlan(evt: DecomposePlanEvent): Promise<void> {
17081708
* attachments (from the context probe) and description-embedded uploads.linear.app
17091709
* links (from the planning task's description = the issue title+body).
17101710
*
1711-
* FAIL-CLOSED (review #5): if the issue HAS attachments but one can't be safely
1712-
* screened, this THROWS `LinearAttachmentError` — the caller rejects the epic
1713-
* rather than silently seeding children without a spec they may require. It
1714-
* returns `[]` ONLY when there's genuinely nothing to hydrate (no attachments) or
1715-
* screening isn't configured — the same "no attachments" outcome as before, not a
1716-
* swallowed failure. Returns `passed` records referencing S3 objects.
1711+
* FAIL-CLOSED (review #5 + #1): this THROWS `LinearAttachmentError` — the caller
1712+
* rejects the epic rather than seeding children blind — in ANY of three cases:
1713+
* 1. the context probe FAILED (ok:false: 500/timeout) — an empty paperclip list
1714+
* then means "unknown", not "none", so a paperclip-only spec could be
1715+
* present-but-unread (review #1: this path previously ignored probe.ok);
1716+
* 2. the issue HAS attachments but screening isn't configured;
1717+
* 3. a selected attachment can't be fetched/screened.
1718+
* Returns `[]` ONLY when the probe succeeded AND there's genuinely nothing to
1719+
* hydrate — not a swallowed failure. Returns `passed` records referencing S3.
17171720
*/
17181721
async function hydrateParentAttachmentsForSeed(
17191722
evt: DecomposePlanEvent,
17201723
accessToken: string,
17211724
): Promise<import('./shared/types').PassedAttachmentRecord[]> {
17221725
const probe = await probeLinearIssueContext(accessToken, evt.parentIssueId);
1726+
if (probe.ok === false) {
1727+
// Case 1: probe failure → fail closed. We can't see native paperclips, so a
1728+
// paperclip-only spec would vanish; don't seed the epic's children blind.
1729+
throw new LinearAttachmentError(
1730+
"ABCA couldn't read this issue's attachments from Linear (the API errored or timed out). "
1731+
+ 'Re-apply the trigger label to retry rather than seed sub-issues on a possibly-missing spec.',
1732+
);
1733+
}
17231734
const paperclips = (probe.attachments ?? []).filter((a) => isLinearUploadsUrl(a.url));
17241735
const descHasUploads = Boolean(evt.taskDescription?.includes('uploads.linear.app'));
17251736
if (paperclips.length === 0 && !descHasUploads) return []; // nothing to hydrate
17261737

17271738
const screeningConfig = attachmentScreeningConfig();
17281739
if (!screeningConfig || !ATTACHMENTS_BUCKET) {
1729-
// The issue HAS attachments but we can't screen them — fail closed (mirrors
1730-
// the webhook single-task path's "screening not configured" rejection).
1740+
// Case 2: the issue HAS attachments but we can't screen them — fail closed
1741+
// (mirrors the webhook single-task path's "screening not configured" reject).
17311742
throw new LinearAttachmentError(
17321743
'This issue has attachments, but ABCA attachment screening is not configured. Contact your ABCA admin.',
17331744
);

cdk/src/handlers/shared/linear-issue-context-probe.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,19 @@ const MAX_HINTED_ATTACHMENT_TITLES = 5;
4949
/** Cap on project documents whose CONTENT is pulled into the task context. */
5050
const MAX_HYDRATED_PROJECT_DOCS = 5;
5151

52+
/**
53+
* Upper bound for COUNTING project docs (review #3). The content page is capped
54+
* at {@link MAX_HYDRATED_PROJECT_DOCS}, but the presence-hint needs the TRUE
55+
* total so it can flag docs beyond that cap (previously the count came from the
56+
* capped content page, so docs 6+ were invisible to the hint). A second aliased
57+
* id-only connection counts up to this bound cheaply (no bodies fetched); a
58+
* project with more docs than this is vanishingly rare and the hint's "+N more"
59+
* is approximate past it anyway.
60+
*/
61+
const MAX_COUNTED_PROJECT_DOCS = 50;
62+
5263
const ISSUE_CONTEXT_QUERY = `
53-
query IssueContext($id: String!, $docs: Int!) {
64+
query IssueContext($id: String!, $docs: Int!, $docCount: Int!) {
5465
issue(id: $id) {
5566
id
5667
attachments(first: 25) {
@@ -66,6 +77,9 @@ query IssueContext($id: String!, $docs: Int!) {
6677
documents(first: $docs) {
6778
nodes { id title content }
6879
}
80+
documentsForCount: documents(first: $docCount) {
81+
nodes { id }
82+
}
6983
}
7084
}
7185
}
@@ -159,7 +173,7 @@ export async function probeLinearIssueContext(
159173
},
160174
body: JSON.stringify({
161175
query: ISSUE_CONTEXT_QUERY,
162-
variables: { id: issueId, docs: MAX_HYDRATED_PROJECT_DOCS },
176+
variables: { id: issueId, docs: MAX_HYDRATED_PROJECT_DOCS, docCount: MAX_COUNTED_PROJECT_DOCS },
163177
}),
164178
signal: controller.signal,
165179
});
@@ -175,6 +189,7 @@ export async function probeLinearIssueContext(
175189
id?: string;
176190
name?: string;
177191
documents?: { nodes?: Array<{ id?: string; title?: string; content?: string }> };
192+
documentsForCount?: { nodes?: Array<{ id?: string }> };
178193
} | null;
179194
};
180195
};
@@ -204,14 +219,20 @@ export async function probeLinearIssueContext(
204219
const projectDocuments = documentNodes
205220
.filter((d): d is { title?: string; content: string } => typeof d?.content === 'string' && d.content.trim().length > 0)
206221
.map((d) => ({ title: typeof d.title === 'string' && d.title.trim() ? d.title.trim() : 'Untitled document', content: d.content }));
222+
// Review #3: the TRUE doc total from the id-only count connection (up to
223+
// MAX_COUNTED_PROJECT_DOCS), NOT the capped content page — so the presence
224+
// hint can flag docs beyond the hydration cap. Fall back to the content page
225+
// length if the count connection is absent (older API shape / test mock).
226+
const countNodes = project?.documentsForCount?.nodes;
227+
const projectDocumentCount = Array.isArray(countNodes) ? countNodes.length : documentNodes.length;
207228
return {
208229
attachmentTitles,
209230
attachments,
210231
projectName,
211-
projectHasDocuments,
232+
projectHasDocuments: projectHasDocuments || projectDocumentCount > 0,
212233
projectDocuments,
213234
ok: true,
214-
projectDocumentCount: documentNodes.length,
235+
projectDocumentCount,
215236
};
216237
} catch (err) {
217238
logger.warn('Linear issue context probe request failed', {

cdk/test/handlers/linear-webhook-plan-command.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ jest.mock('../../src/handlers/shared/linear-oauth-resolver', () => ({
6868
resolveLinearOauthToken: (...args: unknown[]) => resolveLinearOauthTokenMock(...args),
6969
}));
7070

71+
// The single-task approve path hydrates the issue's attachments (probeIssue:true);
72+
// review #1 makes it fail-CLOSED when the probe errors (ok:false). Mock a healthy
73+
// empty probe so these non-attachment plan-command tests take the normal path —
74+
// otherwise the real probe's in-test fetch failure would (correctly) reject the
75+
// approve as attachment-unreadable and never call createTaskCore.
76+
jest.mock('../../src/handlers/shared/linear-issue-context-probe', () => ({
77+
probeLinearIssueContext: jest.fn().mockResolvedValue({
78+
attachmentTitles: [],
79+
attachments: [],
80+
projectName: null,
81+
projectHasDocuments: false,
82+
projectDocuments: [],
83+
ok: true,
84+
projectDocumentCount: 0,
85+
}),
86+
renderIssueContextHint: jest.fn(() => ''),
87+
}));
88+
7189
process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME = 'LinearProjects';
7290
process.env.LINEAR_USER_MAPPING_TABLE_NAME = 'LinearUsers';
7391
process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry';

0 commit comments

Comments
 (0)