@@ -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 */
474493function 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
0 commit comments