feat(carve S7): the orchestration compute plane — reconciler, release, rollup - #659
feat(carve S7): the orchestration compute plane — reconciler, release, rollup#659isadeks wants to merge 35 commits into
Conversation
9302c20 to
59ba7e2
Compare
b51246e to
18bcf34
Compare
59ba7e2 to
bc71750
Compare
18bcf34 to
4581e88
Compare
bc71750 to
c9ab31c
Compare
Automated review — carve S7 (#659)Reviewed as its own diff only ( Slice standalone-ness: PASS
CFN safety — the questions worth asking before this mergesStream enablement: in-place, no replacement. CONFIRMED. GSI addition: one per deploy. CONFIRMED.
Since GSI projection: correct for its purpose — but four call sites already pay for it.
So the routing lookups the index was designed for are fully covered, and the projection is right for them. But four call sites need attributes the index does not project, and each pays a per-item base-table
Each is deliberate and documented in-code. I am raising it because a GSI projection cannot be changed in place — DynamoDB rejects it, as the code comment itself records from experience. So this list is the permanent cost of the current shape. Four known consumers already working around it, two of them for the same field ( On the previously-flagged
|
Addendum to the review above — two further findingsA second pass surfaced two things the first review missed. MINOR (user-copy + public-fitness) — a user-facing retry instruction names the wrong label.
The trigger label is per-project configurable ( So a user who follows this instruction removes and re-applies a label the webhook does not filter on, and nothing happens. It should interpolate the resolved NIT —
Worth removing rather than leaving: the only reason a caller would reach for this prop is to point writes at a different table, and doing so would silently have no effect while appearing to work. NIT — third instance of the "cited artifact lands later" pattern.
This is the same shape as the ADR-016 citations in #654 and the model/IAM split in #654 → #662. See the stack-level note in the summary: the recurring boundary error in this stack is code landing in an earlier slice than the artifact that authorises it. |
4581e88 to
8b85d8e
Compare
c9ab31c to
f28f5ff
Compare
8b85d8e to
32e969a
Compare
f28f5ff to
007e462
Compare
Review findings addressedThank you for the CFN-safety pass — checking stream enablement and GSI count against six refs is exactly the verification this slice needed, and I'm glad the in-place claims held up under it. MINOR — user-facing retry copy named the wrong label. Fixed. This was the most valuable finding in the review: the hint told users to re-apply a hardcoded label while the shipped default is a different string and the label is per-project configurable, so a user following the instruction got silence. Note the existing test asserted the hardcoded string, which is why this shipped — so I fixed the assertion too and added two cases (a custom label, and the default fallback). All three fail if the hardcoded string comes back. MINOR — GSI projection —
Duplicated MINOR — ~600 lines of dead rendering logic. Confirmed: seven exports with no MINOR — raw UUID on the fallback path. Checked NIT — third instance of "cited artifact lands later". Your stack-level observation is the most useful thing in this review. Two of the three are addressed (the descriptor/YAML split, the model/grant split); the doc citations I have left, with reasoning on #654. The pattern is real and worth stating as a slicing rule: code must not land in an earlier slice than the artifact that authorises it. |
007e462 to
8f7f73f
Compare
32e969a to
f5afe7e
Compare
8f7f73f to
a510000
Compare
Operator note: this slice adds one GSI, but a STALE deployment may need two — DynamoDB allows one per updateFound while deploying this arc to a second environment. Correcting something I asserted earlier in this PR. What I verified, and why it was the wrong baseline. I checked that But DynamoDB does not constrain the delta against An environment whose stack predates #640 has three GSIs live ( The dev account did not surface this only because its stack was already current with Operator guidance for merging/deploying this:
Also worth recording, since it bears on how anyone verifies this: on that environment two |
f5afe7e to
0fb2cc1
Compare
62e29fc to
13338b8
Compare
Second-round review: my retry-hint fix was cosmetic — now actually wiredThe reviewer checked whether any production caller passes the Fixed properly. The label is now captured where the mapping is in hand — at seed time — and persisted on the meta row's release context; the reconciler reads it back and passes it to the panel. Rows seeded before the field existed simply have none and fall back to the platform default, as before. Only the settled-panel call site is wired, deliberately: the retry hint renders only when the epic is terminal and something failed, and the other two sites render an in-progress panel that has no hint. I verified that rather than assuming it. Guarded in both directions, and the read half is the one that mattered: dropping the write fails the seed test, but dropping the read hydration originally left the entire 3500-test suite green. Both are now pinned. On On the Verified and not changed: the |
Upgrade-ordering note added, from a second-environment deploy — and a correctionDeployed this arc to a different account (one whose stack was last updated at pre-#643 main) and hit the GSI limit I had reasoned about earlier from the wrong baseline: I had verified "exactly one new GSI" against Operator guidance: confirm the target stack is current with Nothing in the template needs changing, and splitting the index across slices would not help — the constraint is about the deployed baseline. A correction to something I wrote earlier in this thread. I claimed a stray artifact URI "would fail closed anyway" because the reconciler's grant is scoped to |
0fb2cc1 to
b1410ea
Compare
13338b8 to
3b1863f
Compare
…le docs The project-mapping table's field list documented decompose_allowed, max_sub_issues, and max_parent_budget_usd. Nothing in the main-bound slices reads them, since decomposition stays on the development branch as experimental, and a doc describing configuration the code does not honour is worse than no doc — an operator would set those fields and wait for behaviour that never comes. The table is schemaless apart from its partition key, so this removes only prose; any row carrying those fields is still read back untouched.
4f90e00 to
836c3d0
Compare
f81c7eb to
e432ce9
Compare
`linear onboard-project` offered --decompose-allowed, --max-sub-issues, and --max-parent-budget-usd. With decomposition staying on the development branch, those wrote project-mapping fields nothing reads. Worse than inert: the command printed "Auto-decomposition: ENABLED" and told the operator which labels to apply, so the only feedback they got said it had worked. They would then label an issue and get an ordinary single task, with nothing anywhere explaining why. Found by widening the search past cdk/ and agent/, where I had been looking. The table itself is schemaless, so a project row that already carries these fields is still read back unharmed — they are simply ignored.
e432ce9 to
c5a6d3c
Compare
836c3d0 to
4409dcc
Compare
Scope change: auto-decomposition is no longer part of this carveHeads-up before you re-read this — every slice was force-pushed, so a review in progress is against a stale diff. Sorry for the churn; the reason is a scope decision, not a fix. Auto-decomposition is staying on the development branch as experimental and is not landing on What moved~7,900 lines removed. 23 dedicated files, the agent workflow and its prompt, the admission-table entry, the CLI flags, and about 314 lines embedded in files that survive.
Three things worth knowing as a reviewerSome of the review fixes from the last round are gone with the feature. The verdict-marker fix (B1 on #662) and the retry-hint Two things looked decomposition-only and were not. Removing the feature exposed dead IAM. The reconciler held an Verification
|
…load the PDF Every PDF attachment was being refused with "Attachment '<name>' could not be stored." The S3 upload threw "Cannot perform Construct on a detached ArrayBuffer", and because attachment handling is fail-closed the whole task was rejected. pdf-parse transfers ownership of whatever it is handed to its worker, which DETACHES the underlying ArrayBuffer. It was handed `new Uint8Array(content.buffer, content.byteOffset, content.byteLength)` — a VIEW, which shares its backing store with the caller's Buffer. The caller uploads those same bytes to S3 after screening returns, so by then its buffer was dead. The existing comment reasoned about this and still got it wrong: it said "slice to the exact PDF bytes so a pooled Buffer's backing ArrayBuffer isn't handed over wholesale", which correctly avoids donating a whole pooled buffer but still hands over the caller's. A view narrows WHAT is transferred, not WHOSE it is. Now `Uint8Array.from(content)` — a copy. That costs one allocation of an already-size-capped payload, against silently rejecting every PDF upload. The unit tests could not catch this: they mock the PDFParse class, so no transfer ever happens and a view behaves like a copy. The new test asserts the OWNERSHIP boundary instead of the outcome — same bytes reach pdf-parse, different backing store — which is checkable without a real worker. Verified by restoring the view and watching it fail.
…straction
The dormant foundation for parent/sub-issue orchestration: pure graph logic,
the DynamoDB row store, and a surface-agnostic feedback layer. Nothing here is
instantiated in a stack yet — the executor and the stack wiring land later, so
this slice is synth-and-unit-test only and changes no deployed behaviour.
Graph core (pure, no I/O):
- orchestration-dag: validate a `depends_on` graph (duplicate id, dangling
edge, cycle) and return its topological layering via Kahn's algorithm. The
layers are what a reconciler releases children from, in dependency order.
- orchestration-graph-source: the seam between "where the graph came from" and
the executor. Three tiers — a tracker that has native sub-issues (read it), a
caller that supplies the DAG declaratively (CLI/API, or a planner), or a
structureless trigger (single task).
- orchestration-integration-node: when a graph fans out to several leaves,
append a synthetic node depending on all of them so there is one combined
artifact instead of N unrelated PRs.
- orchestration-epic-tip: where a newly-added, dependency-less node stacks — the
leaf frontier, so it inherits the epic's accumulated unmerged work rather than
branching off the default branch.
- orchestration-base-branch: pick a child's base branch from its predecessors.
Row store and table:
- OrchestrationTable construct: orchestration_id (PK) + sub_issue_id (SK), with
sparse GSIs to resolve a child back from its task id or its head branch.
- orchestration-store: the read/write surface over those rows, including the
idempotency markers that keep duplicate webhook deliveries from double-acting.
- orchestration-log-events: structured events for a run, plus the scheduled
sweep's backstop.
- linear-subissue-fetch: read a parent's children and their `blocks` relations
into a DAG. Fails loud rather than silently truncating an over-size epic.
Channel abstraction:
- orchestration-channel: the surface-agnostic interface — required operations
every channel must implement, optional ones gated by declared capability, so
the engine never branches on which tracker it is talking to.
- orchestration-channel-{linear,jira,slack} + factory: per-surface adapters
selected from the stored row.
- orchestration-comment-trigger: parse a mention comment into a command,
ignoring the bot's own comments.
- iteration-reply: one maturing threaded reply per iteration rather than a
stream of new comments; the two async writers of a reply converge instead of
overwriting each other.
…it repo-wide The test-scope lint relaxation switched off four rules across all 152 test files to accommodate 13 new ones. `max-len` and `no-magic-numbers` are fair there — literal fixtures and long expected strings are the point of a test. `no-shadow` is not: it is correctness-adjacent in test code, where a shadowed `row` or `mock` inside a nested describe is a common way to assert against the wrong fixture and still pass. So `no-shadow` stays on, and the two shadows it found are fixed rather than silenced. One was a redundant local `makeDdb` identical to the file-level helper (deleted). The other was a local `child` that builds a persisted ROW shadowing a file-level `child` that builds a graph node — genuinely two different shapes under one name, which is exactly the hazard. Renamed to say which it is.
…ontain
Review caught two inaccurate comments. Both were worth fixing, and looking for
more of the same class turned up two others.
The integration-node comment gave the synthetic id as `<orchestrationId>#integration`
while the code builds `__integration`. Not a harmless typo: the `#` form is exactly
what the comment thirty lines above warns will 400 the child, because the id flows
into an idempotency key validated against /^[a-zA-Z0-9_-]{1,128}$/. A reader
trusting the example would reach for the one separator that breaks it. Now points
at the suffix constant so the two cannot drift again.
Three comments referenced symbols that are not in this slice:
- `findOrchestrationIds` — real, but in the stranded-orchestration reconciler,
which lands later. Replaced the cross-reference with the property it was there to
convey: every paginated read of this table must follow LastEvaluatedKey, because a
single page is a silent partial answer rather than an error.
- `discoverOrchestration` — the consumer lands with the compute plane; now described
by role instead of by name.
- `renderFailureReply` — called "the existing" renderer when nothing of that name
exists here yet, which is the most misleading form: it reads as a claim about the
current tree. Now describes it as arriving with the activation slice.
A comment naming a symbol a reader cannot grep is a dead end, and in a stacked
series it is an easy defect to introduce, since the symbol does exist on the branch
the slice was carved from.
…ormant)
Turns "one plain issue" into a reviewable sub-issue graph, and makes a long
iteration legible while it runs. Like S5, none of this is wired into a stack
yet — no handler calls it and no construct is instantiated, so this slice is
unit-test-only and changes no deployed behaviour.
Auto-decomposition — plan, review, revise, then run:
- decomposition-mode: pure label parsing. The plain trigger label runs an issue
as one task (or runs its existing sub-issue graph); `…:decompose` plans first
and waits for approval; `…:auto` plans and starts immediately. A decompose
suffix on an issue that already has sub-issues is a no-op.
- decomposition-planner: parse and validate the plan a `coding/decompose-v1`
agent emits as an artifact. Budget is DERIVED from each piece's S/M/L size
rather than asked of the model, so the Σ is a stable, explainable ceiling.
Edges are indices into the plan's own list, validated as a DAG before anything
is created.
- decomposition-caps: per-project limits. Over-cap REJECTS with a message and
never trims — trimming can silently drop a node others depend on.
- decomposition-render: the proposal comment, and every note around it. Plain
English throughout: no "critical path" or "cost ceiling" jargon, the spend
figure is framed as the safety limit it is, and each decline says what actually
happened rather than a plausible-sounding stand-in.
- decomposition-store: the pending plan survives between the propose webhook and
the approve one. Create-once so a redelivery is a no-op; consume is a
conditional delete-and-return so two racing approvals can't both write back.
- decomposition-writeback: create the real sub-issues and their `blockedBy`
relations. Idempotent and resumable — a planned node whose title already
exists is reused, so a retry after a partial write-back doesn't double-create.
- decomposition-flow: ties the above into caps → propose-or-seed, plus the
approve/reject verdict path. All I/O injected, so the control flow is testable
without Linear or DynamoDB.
- plan-commands / plan-revise / plan-revise-interpret: a reviewer can edit the
plan directly ("drop 3", "merge 1 and 2") or in prose. Edits apply
deterministically to the CURRENT plan and the "What changed" line is a COMPUTED
before→after diff — never a model self-report, which used to let a dropped
piece quietly reappear with a fabricated justification.
Iteration feedback:
- iteration-heartbeat (+ construct and scheduled sweep): a long iteration used to
show "starting on this" and then nothing until it finished. The sweep edits the
SAME maturing reply in place to show elapsed time and the latest progress note
— no new comments. Eligibility keys on the reply-routing fields, so standalone
iterations are covered too, not just orchestrated ones.
- iteration-reply-claim: claim a reply so two writers converge on one comment.
- failure-reply: failure is answerable. A red build points at the build log by
task id (the agent runs the build itself, and the target repo may have no CI);
an agent crash gives the classified reason, a truncated excerpt, and whether to
retry or escalate. Never the raw build output — that's untrusted repo code.
- clarify-resume: resume a task that stopped to ask a question before spending.
… a dead regex **A private commit sha was the whole justification for skipping a PROMPT_ATTACK screen.** That is the one sentence a security reviewer needs to be able to follow, and it pointed at a commit no public reader can resolve. Replaced with the actual structural argument: the reviewer's instruction is embedded as delimited data inside a prompt whose only job is to classify it into a fixed edit vocabulary, the output is validated against that closed set before anything is applied, so a jailbreak cannot widen what the caller does — and a note not to reuse this shape where the output is executed rather than matched. **The multi-part conjunction regex could not match its own intended phrasings.** A trailing `\b` after the `plus,` and `;` alternatives requires a word character to follow, which inverts the intent: "the form, plus, a signup page" scored zero while "plus,a signup page" scored one, and "do a; b; c" scored zero while "do a;b;c" scored two. So the correctly-punctuated prose this is meant to catch was the case it missed, and both punctuation alternatives were dead weight. Word alternatives keep their boundaries; the punctuation ones no longer require one. **`latestProgressNote` was documented as a shipped capability but has no producer** anywhere in the stack — the sweep never sets it and there is no persisted attribute to read, so the heartbeat shows elapsed time only. The render path is written and tested, so rather than delete it or half-wire it, the field and the module/construct docs now say plainly that it is reserved and what wiring it needs. **`IterationHeartbeat` shipped without a construct test**, the one place in this slice where source and test coverage parted company. Added, following the repo's per-construct synth-assertion convention — including an assertion for the least-privilege claim the construct's own comment makes but nothing checked. Also: a cross-file line-number reference that pointed at a line which does not exist until a later slice (named the function instead), and the last of the private work-item shorthand.
…t overclaimed Two findings from a second review pass on my own fixes. **The conjunction regex now false-positived on pasted code.** Removing the trailing `\b` fixed the punctuation alternatives, but a bare `;` with no boundary matches EVERY semicolon — and an issue description written for a coding agent routinely contains a snippet, a stack trace or CSS. Four realistic single-task bug reports flipped to "multi-part" and would have nagged the user to decompose them. That is a false positive in the direction that costs attention, and my previous commit message argued for avoiding exactly that. The bare `;` is dropped. It never earned its place in either form: with the boundary it only matched unnatural no-space input, and without one it matched everything. `plus,` keeps the boundaryless form and the word alternatives keep theirs, which is enough for both real multi-part prose shapes; a numbered list is caught by the separate list-item path. A test now pins that a code paste stays single-task, which is what the previous test set never asserted. **The guardrail comment asserted a safety property the code did not have** — which is worse than the private commit sha it replaced, because a reviewer will trust prose. Three claims failed: the instruction was interpolated raw, so reviewer text containing the `"""` fence could close the data block and continue at prompt level; the free-text fields were type-checked but unbounded; and the computed diff is title-keyed, so an edit reusing an existing title reports as "modified" rather than "added" and cannot be relied on to surface a malicious edit. Rather than soften the prose, the first two are now true: the delimiter is neutralized in reviewer text and the instruction and returned fields are bounded. The comment names the real backstop — the downstream guardrail screen at task creation — and says plainly that the diff is a reviewer-facing summary, not a security control. All three new guards are mutation-verified.
…decomposition Decomposition stays on the development branch as experimental rather than landing on main: it presupposes one way of working — an issue split into a sub-issue graph with a human approval gate on the proposed plan — and main should not presuppose that. The iteration work in this slice does not: it applies to any task that opened a PR, however that task came to exist. Removes the 20 decomposition files (planner, caps, flow, store, types, writeback, render, the plan-command and plan-revise handlers, and their tests). What remains is the 12 files this slice is now about: the iteration heartbeat and its sweep, the reply claim, clarify-resume, and the failure reply. Two helpers in the deleted label module were NOT decomposition and had to survive: `DEFAULT_LABEL_FILTER`, which the project-mapping table treats as the default when a project sets no `label_filter` and the rollup renders in operator-facing copy, and `hasHelpLabel`, the one-time explainer label. They now live in `trigger-label.ts`, named for what they are. `triggerLabelVariants` was NOT kept — it existed to return the `:decompose` and `:auto` variants, so with those labels gone it would only ever return the bare base. Tests came across with the module, plus one pinning `DEFAULT_LABEL_FILTER`'s value directly. That constant is load-bearing in a way its size hides: changing it silently stops every project that never set a filter explicitly. Slice size drops from 8538 lines to 1918. The PR title and description need to change to match, since this is no longer a decomposition slice.
…, rollup The engine that actually runs a sub-issue graph: it watches child tasks reach a terminal state, releases whatever they unblocked, keeps the parent's status panel current, and re-stacks dependents when a branch moves. - orchestration-discovery: the composer that turns a trigger into a seeded run — read the graph, validate it, append the integration node if it fans out, then persist it and hand back the root set to start. - orchestration-reconcile: pure gating. Given a child that just went terminal, decide which dependents are now releasable, which must be skipped, and when the parent itself is done. Also covers two recovery shapes: comment-fixing a failed child un-fails it and re-releases the dependents that were skipped behind it, and re-triggering a finished epic retries only its failed/skipped children. - orchestration-release: create a child's task, with a claim so two concurrent releasers can't both launch it. A guardrail rejection or an un-onboarded repo is terminal with a reason the user can act on; a 5xx or a duplicate replay rolls back to ready so the sweep retries. - orchestration-rollup: the parent's single maturing panel — one comment that matures in place rather than a stream of updates. A failed row carries an indented sub-line saying what failed and where to read it. - orchestration-restack: when a child's branch changes, work out which dependents need their base moved. - orchestration-parent-comment: route a comment on the epic to the sub-issue it's about, and say so plainly when it looks like new work instead of a change. - orchestration-reconciler (handler + construct): the TaskTable-stream consumer that drives all of the above, with a DLQ for poison records, a filter so only terminal-status writes invoke it, and partial-batch reporting so one bad record can't re-drive its healthy siblings. - reconcile-stranded-orchestrations (handler + construct): a scheduled backstop for a run whose stream event was lost. Not dormant, unlike S5/S6 — read this part carefully: TaskTable gains a LinearIssueIndex GSI and has its stream enabled (NEW_IMAGE). Both are needed by this slice: the reconciler consumes that stream, and it queries that index to total an issue's iteration cost. The GSI's projection is deliberately narrow because a projection cannot be changed in place later. Enabling a stream and adding a GSI are both in-place CloudFormation updates, so no table replacement. The reconciler constructs themselves are NOT instantiated in any stack yet — the wiring lands in the next slice — so nothing consumes the stream on deploy.
The epic panel's retry fallback told the user to remove and re-apply a hardcoded label. The trigger label is per-project configurable through the project mapping's `label_filter`, and the platform default is a different string — so a user who followed the instruction re-applied a label the webhook does not filter on, and nothing happened. `renderEpicPanel` and `upsertEpicPanel` now take the resolved label and interpolate it, falling back to the platform default when a caller has none. Rendering the panel confirms the output; the existing test had asserted the hardcoded string, which is why it never caught this.
… hint The previous fix made `renderEpicPanel` take a `labelFilter` and interpolate it — but no production caller passed one, so in practice every user still saw the platform default and the hint was still wrong for any project that renamed its trigger label. The fix was cosmetic. The reconciler is where the panel is rendered, and it has no project id to look a mapping up with — it works from the orchestration row. So the label is now captured where the mapping IS in hand, at seed time, and persisted on the meta row's release context; the reconciler reads it back and passes it to the panel. Rows seeded before this field existed simply have no label and fall back to the platform default, as before. Only the settled-panel call site is wired, deliberately: the retry hint renders only when the epic is terminal AND something failed, and the other two call sites render an in-progress panel that has no hint. Guarded in both directions — dropping the write fails the seed test, dropping the read hydration fails the load test. The read half had no coverage until now, so removing it left the whole suite green.
… the default Review flagged trigger_label as only half-wired in this slice. Checking it, the gap is wider than that: the field is read, persisted, hydrated, and threaded to the panel, but NO code path on any branch ever assigns it a value — not this slice, not the activation slice, not the branch this was carved from. The whole plumbing was dead, so every epic's retry hint renders the default label regardless of what the project is configured to trigger on. That makes the hint actively misleading rather than merely generic: telling an operator to re-apply `bgagent` when their project triggers on something else sends them to a label that will not start anything. The seed site is in this slice, so the fix belongs here. The decompose task already carries its configuration through `channel_metadata` (mode, caps, revision round), so the label rides along the same way and is populated onto the release context at seed time. A blank or whitespace value is treated as absent so the renderer's default applies, rather than rendering an empty label in the hint — which also covers a task stamped before this field existed. The producer side (the webhook stamping `decompose_trigger_label` from the project mapping's resolved `label_filter`) lands with the webhook, in the activation slice. Until then this reads as absent and behaves exactly as before, so the two halves are safe to land apart in this order — consumer first, and never the reverse. Verified by deleting the parse and watching the assertion fail.
…raph EXECUTION With decomposition staying on the development branch, the reconciler no longer needs to build a graph — a graph on main is human-authored and seeded by the webhook. This removes the plan-consuming half and keeps the executing half. Gone: the planning-task event shape and its parser, the plan reconciler, the artifact fetch, the :auto seed path, and the seed-time attachment hydration. 656 contiguous lines, plus the six imports of the deleted planning modules and the stream-handler branch that dispatched to them. Kept, and worth naming because the file is now much smaller: terminal-child reconciliation, dependent release, the restack cascade, panel refresh and settle, failed-node recovery, and the iteration reply. Those are what the slice is for. The removal also made three things dead that were only ever used by seeding — the S3 client, the Bedrock screening config, and the artifacts-bucket env — so they went too. Net effect on IAM is a least-privilege improvement: nothing in this slice reads S3 any more. Two consumers needed repointing rather than deleting, since they were never about decomposition: - `orchestration-rollup` imported DEFAULT_LABEL_FILTER from the deleted label module; it now takes it from `trigger-label`. - A test asserted that a planning task falls through `parseTerminalTaskRecord`. The property it was really pinning is that the gate is the presence of orchestration_id, not the workflow id — a task outside a graph must not be mis-gated as a child and released against a graph it has nothing to do with. Rewritten around a plain terminal record so it still guards that. Also drops a probe mock that existed because the seed path failed closed on an attachment probe. With the seed gone the reconciler never probes, so the mock was asserting nothing.
…integration node The epic panel's preview came only from the synthetic integration node, so a chain got no preview at all — the case where it is most obviously wanted, because the reviewer is looking at the parent and a finished node holds exactly the artifact they want. An integration node exists only when a graph has SEVERAL leaves; it is there to merge them. With one leaf there is nothing to merge: everything already converged on the final node, so a synthetic node would re-run work that node just did and its "combined" preview would duplicate the leaf's own. That gate is correct. What was wrong is concluding "no integration node" means "no preview". The panel now falls back to the sole leaf. Deliberately only the SOLE leaf: with several leaves and no integration node (mid-flight, or an older row set) it still shows nothing, because picking one of several would present a single branch's result as the whole epic's — worse than showing nothing. Leaf-finding reuses `computeLeaves`, the same function the seeder uses to decide whether an integration node is needed, so the panel's idea of the final node cannot drift from the seeder's. The label is now conditional. "Combined preview" is only true when leaves were actually merged; on a chain it reads "Preview". Calling a chain's final-node preview "combined" tells a reviewer several branches were merged when none were. Both variants were RENDERED and read as output, not reviewed in source. Verified by mutation, three ways: reverting to integration-only, picking the first child instead of the leaf, and hardcoding the "Combined" label each fail the test and nothing else. Three existing panel tests changed expectations, which is the honest signal that this alters user-visible output rather than only adding a path.
…n child A released child was submitted with no workflow_ref, so it resolved to default/agent-v1 — the repo-OPTIONAL generic workflow. Every other channel (Linear, Jira, Slack) pins CODING_WORKFLOW_ID at its own call site precisely because of this; create-task-core.ts even says so in a comment. The orchestration release path was the one caller that never did. The consequence is not a clean failure. default/agent-v1's setup skips the stacked-base and predecessor-merge path entirely, so: - A CHAIN survives by luck. Its child needs no merge, works from the base it was handed, and the result looks correct. That is why this went unnoticed. - A DIAMOND does not. The integration node exists to merge two predecessor branches; it was handed both in channel_metadata, then given a clean default branch instead. The agent read a task description asking it to integrate, could not see either branch, and hand-wrote an approximation of ONE arm — silently dropping the other's work while producing a plausible-looking PR. Found live: a two-leaf epic's integration PR contained only the first arm's change, and the second arm's was absent from the merged file entirely. Everything upstream was correct — the node was seeded with both dependencies, selectBaseBranch returned shape 'diamond' with both branches, the task record carried both, and the orchestrator's payload wiring was deployed. Only the workflow was wrong. Two tests, one for a normal child and one for an integration node, asserting the REQUEST body rather than the context — nothing had asserted the body before, which is why the whole suite passed with the bug in place. Verified by deleting the pin and watching both fail.
… comment target the combined result Two fixes for one incident. An epic fanned out an API child and a UI child in parallel; each saw only its own sub-issue text and chose its own names — `kyoto` with `checkIn`/`checkOut` on one side, `wander-kyoto` with `startDate`/`endDate` on the other. Both passed their own tests, the branches merged with no conflict, and the deployed flow was broken. Neither child was wrong in isolation. The agreement existed only in the parent, which neither of them could see. Then the follow-up comment about the failure landed on the UI child, where the backend code was not present, so the agent adjusted how the error was displayed. SHARED CONTEXT. The epic's title and body are captured at seed time onto the meta row and prepended to every child's task description, delimited and labelled, with an explicit statement that siblings are working in parallel from the same text and that any name or shape it specifies is a contract rather than a suggestion. Seed time is the only point where the issue body is in hand — the reconciler releases from the stored row with no token to re-fetch it. Truncated at the single write site (4000 chars/field) for two independent reasons that both bind: the 400 KB DynamoDB item limit, and the fact that this text is guardrail-screened and counted against every child's prompt budget. Truncation is marked, so a reader never mistakes half a spec for the whole. The integration child gets it too — it is where a cross-boundary mismatch actually surfaces, and without the contract it cannot tell a real mismatch from two equally plausible conventions. TARGETING THE COMBINED RESULT. `parseParentNodeReference` already looked for `integration`/`combined`, but the keyword only ADMITTED the synthetic node and a title word still had to match. So `integration` worked by accident (it appears in the node's title) while `combined` — the word the panel itself shows, in "combined result", "Combined PR", "Combined preview" — matched nothing and fell through to a "which sub-issue?" reply. The node carries no Linear identifier, so that keyword is the only handle a user has. The keyword is now the match. The disambiguation reply also lists it, with the exact phrasing to type. It is listed but never auto-selected: the combined branch holds every sibling's work, so a misrouted change there is the most expensive kind to unpick, and an ambiguous comment must still ask rather than guess. A test asserts the advertised keyword and the accepted keyword are the same constant, because the failure mode of drift is a reply that tells someone to type a word the parser ignores. Verified by mutation: dropping the context, reverting the keyword to a gate, and hiding the hint each fail their own test and nothing else. Note the pre-existing keyword test passed against the gate behaviour — it asserted `integration` routes, which it did, for the wrong reason.
…l admits the child Caught on the first live run of the change: one child failed at admission with "Task description was blocked by content policy". The shared-context preamble was written as instructions to the agent — "use it exactly", "do not invent an alternative" — and stacked with the epic's own imperatives the whole task description trips the guardrail's PROMPT_ATTACK filter at MEDIUM confidence. The child never ran. That is a worse failure than the bug this feature fixes: the epic previously produced two children with mismatched contracts, and this version produced one child and one that could not start. Isolated it against the deployed guardrail rather than guessing. Each piece passes alone — the delimiters, the epic's own text, the child's own text, and every individual sentence of the preamble. It is the accumulation of instruction-shaped prose that trips it, and swapping only the preamble's voice flips the same assembled description from GUARDRAIL_INTERVENED to NONE with the epic text unchanged. Now descriptive: it says several sub-issues are being built in parallel, that each was given this text, and that the names in it are shared with those siblings — so an alternative chosen here would not match theirs. That carries the same constraint without addressing the model in the second person, and a model acts on it just as readily. A test asserts the VOICE, not the wording: no "use it exactly", no "do not invent", no "you must", while still requiring the parallel-siblings and shared-with framing. The failure mode this guards is subtle — imperative phrasing reads as an improvement and silently makes children unadmittable.
c5a6d3c to
e48273b
Compare
4415f8f to
e8aa93c
Compare
Slice 7 of the staged carve. Stacked on #657 (
carve/s6-decompose-iteration) — review that first; this PR's diff is only its own commit.What this adds
The engine that actually runs a sub-issue graph. It watches child tasks reach a terminal state, releases whatever they unblocked, keeps the parent's status panel current, and re-stacks dependents when a branch moves.
orchestration-discovery— the composer that turns a trigger into a seeded run: read the graph, validate it, append the integration node if it fans out, persist it, and hand back the root set to start.orchestration-reconcile— pure gating. Given a child that just went terminal, decide which dependents are now releasable, which must be skipped, and when the parent itself is done. Two recovery shapes are covered too: comment-fixing a failed child un-fails it and re-releases the dependents that were skipped behind it, and re-triggering a finished epic retries only its failed/skipped children.orchestration-release— create a child's task, behind a claim so two concurrent releasers can't both launch it. A guardrail rejection or an un-onboarded repo is terminal with a reason the user can act on; a 5xx or a duplicate replay rolls back to ready so the sweep retries.orchestration-rollup— the parent's single maturing panel: one comment that matures in place rather than a stream of updates. A failed row carries an indented sub-line saying what failed and where to read it.orchestration-restack— when a child's branch changes, work out which dependents need their base moved.orchestration-parent-comment— route a comment on the epic to the sub-issue it's actually about, and say so plainly when it looks like new work rather than a change to existing work.orchestration-reconciler(handler + construct) — the TaskTable-stream consumer that drives all of the above. A DLQ for poison records, a FilterCriteria so only terminal-status writes invoke it (RUNNING/heartbeat churn is the bulk of TaskTable writes and would otherwise wake it constantly), and partial-batch reporting so one bad record can't re-drive its healthy siblings.reconcile-stranded-orchestrations(handler + construct) — a scheduled backstop for a run whose stream event was lost.TaskTablegains aLinearIssueIndexGSI and has its stream enabled (NEW_IMAGE).TaskTableis instantiated in the stack, so these two changes do land on deploy.Both are required by this slice and can't be deferred: the reconciler consumes that stream, and it queries that index to total an issue's iteration cost. Notes for review:
pr_url,pr_number,status,repo,user_id,channel_metadata). A GSI's projection cannot be changed in place afterwards, which is why the cost query does a per-taskGetItemforcost_usdrather than widening it.TaskTablerather thanTaskEventsTablebecause the latter is already at its 2-consumer limit.Verification
tsc --noEmitcleaneslintclean🤖 Generated with Claude Code
Tracking
Slice S7 of the linear-vercel → main carve. Tracking issue: #668 (slice table, why it is sliced rather than merged, and review guidance).
Lands part of #247 (parent/sub-issue orchestration). Deliberately not
Closes— no single slice closes it; it closes when S8 activates the arc. The auto-decomposition issue (#299) is no longer part of this carve — that feature stays on the development branch as experimental.Stacked on #657 — review that first; this PR's diff is against its branch.