From 77e4e652bcb20c93b71cdb196e986af3434efb8e Mon Sep 17 00:00:00 2001 From: Norrie Taylor Date: Fri, 19 Jun 2026 07:53:37 -0700 Subject: [PATCH] fix(dispatch): arm cascade on collapsed single-task (1-hop) closures (Gap B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issues:closed cascade walk hard-coded a 2-hop tree (Feature→Unit→task), so an ADR-0028 collapsed single-task feature — whose task is a DIRECT 1-hop child of the tracking issue — was mis-classified as a Unit/tracker closure and the cascade never re-armed (tracking issue stalled, needing a manual /dispatch). sdd-dispatch-compute already admits a direct `## Task` child of the tracker; this aligns the close-walk with that. Three additive edits in sdd-route-dispatch: compute closedIsTask from the closed issue's `## Task` body block; on a 1-hop walk that terminates at a parentless tracker (stoppedBy==='none') AND a genuine task, resolve the tracker; widen the arming gate from walked===2 to walked∈{1,2}. A genuine Unit closure (no `## Task` block) leaves the tracker null and still declines; the 2-hop case is untouched. Composite action — no lock recompile. Fixes #301. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/actions/sdd-route-dispatch/action.yml | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/.github/actions/sdd-route-dispatch/action.yml b/.github/actions/sdd-route-dispatch/action.yml index 1b36b13..fb6f132 100644 --- a/.github/actions/sdd-route-dispatch/action.yml +++ b/.github/actions/sdd-route-dispatch/action.yml @@ -306,6 +306,14 @@ runs: // itself closing is not a task closure and is ignored too. if (event === 'issues' && payload.action === 'closed') { const closedIssue = payload.issue; + // A task closure is identified by the closed issue carrying + // the repo's canonical `## Task` body block (mirrors + // sdd-dispatch-compute's isTask guard). A Unit is a + // parent-of-tasks whose body has no `## Task` block, so this + // guard keeps a genuine Unit closure from arming the cascade + // via the collapsed single-task (ADR 0028) one-hop path below. + const TASK_BLOCK = /^## Task\b/m; + const closedIsTask = TASK_BLOCK.test(closedIssue.body || ''); // GitHub does not populate the child->parent pointer for // native sub-issues on either the `issues.closed` webhook // payload OR on a re-fetch via REST `GET /issues/{n}` @@ -516,13 +524,22 @@ runs: walked = gqlWalked; } } else if (gqlWalked > 0) { - // GraphQL produced a partial walk (e.g. one hop) and - // stopped at a parentless ancestor, which means the - // closed issue is a Unit or the tracker itself, not a - // task. Record the depth so the diagnostic below - // reflects the GraphQL result rather than implying a - // missing parent. + // GraphQL produced a partial walk that stopped at a + // parentless ancestor. Two shapes terminate this way: + // - a collapsed single-task feature (ADR 0028): the + // closed task is a DIRECT child of the tracking issue + // (one hop), so the ancestor reached is the top-level + // tracker. Arm the cascade when the closed issue is a + // genuine task (carries `## Task`). + // - a Unit or the tracker itself closing: not a task + // closure. The closed issue carries no `## Task` block, + // so closedIsTask is false and we decline. + // Record the true hop depth either way so the diagnostic + // below reflects the GraphQL result. walked = gqlWalked; + if (gqlStoppedBy === 'none' && closedIsTask) { + tracker = gqlTracker; + } } else { // GraphQL returned no parent for the closed issue. // Fall back to scanning armed trackers' sub-issue trees. @@ -541,7 +558,7 @@ runs: } } - if (tracker && walked === 2) { + if (tracker && (walked === 1 || walked === 2)) { const hasDispatched = (tracker.labels || []).some( (l) => (typeof l === 'string' ? l : l.name) === 'sdd:dispatched');