feat(carve S8): wire the orchestration arc into the stack and activate it - #662
feat(carve S8): wire the orchestration arc into the stack and activate it#662isadeks wants to merge 14 commits into
Conversation
Automated review — carve S8 / activation (#662)Reviewed as its own diff only ( Slice standalone-ness: PASS
Activation is unconditional — worth a deliberate decision
Reverts: one real one, found below. #643 is clean.I checked the #643 Jira app-actor work line-by-line since the fork was behind 1. BLOCKING — Jira fan-out final-status comments are silently disabled (a genuine revert of main's #603)This PR deletes Verified per-slice — s8 is the only ref where they are absent:
The Failure: an operator with Jira onboarded merges this stack. A Jira-labelled issue runs a task that dies before the agent posts its own terminal comment (e.g. This is the highest-priority fix in the stack: it is a silent capability regression on a path Note on the class: this is the second instance of main-side work being dropped by a fork-derived file — and it is not the #643 work that was being watched. Worth a mechanical sweep of 2. MAJOR — the log-delivery pin shim fires on the DEFAULT stack name
The code comment asserts the shim is "OFF by default" and that "A fresh stack (a new env, CI, this PR on a clean account) has NO pre-existing resources to collide with and MUST synth the current alpha's natural ids." That premise is false: a new operator who runs 3. MAJOR — activation turns on a GSI-backed lookup that no pre-existing task row can satisfy, and the miss is silentThis PR is the first to set So no task created by the currently-deployed code carries it, there is no backfill, and Failure: a user labelled 4. MAJOR —
|
bc71750 to
c9ab31c
Compare
9f4b2a2 to
27309a6
Compare
Verdicts on the three previously-flagged, deliberately-unfixed defectsEach was independently traced rather than restated. Two of the three land differently than described. 1.
|
| bail path | what the user sees |
|---|---|
!taken — pending plan raced/expired |
log only, then ✅ |
!singleHydrated.ok — attachment screening failed |
❌ <message> and then ✅ on the trigger comment |
result.statusCode !== 201 — task creation failed |
failure message and then ✅ |
So the originally-flagged case (raced/expired) is real, and the attachment-screening path is worse than flagged: the user gets an explicit failure comment and a success tick on the comment they are watching. The third path does the same for a failed createTaskCore.
The finally is deliberate and its comment explains why ("a verdict is fully resolved by the time these paths return … Leaving 👀 made the comment read as 'still thinking about it' forever") — that reasoning is sound for the paths it was written for. The gap is that handleSingleTaskVerdict's early returns are not "fully resolved verdicts". The fix is for it to report its outcome (return a discriminated result) and for settleVerdictMarker to choose the marker from that, rather than from verdict alone.
2. sumIterationCostForIssue unpaginated Query + serial GetItem in a stream consumer — LARGELY REFUTED
The concern as framed does not hold, because the expensive path is not per-record:
- The Query is unpaginated (no
LastEvaluatedKeyloop) and theGetItemloop is serial — both confirmed. - But three gates precede it in
replyToStandaloneTrigger: it requires a trigger comment id; it excludes orchestration iterations (cm?.orchestration_iteration === 'true'→ the reconciler owns those); and it takes a one-shot claim —UpdateExpression: 'SET ack_replied_at = :now'withConditionExpression: 'attribute_not_exists(ack_replied_at)'. So a redelivered batch (fan-outbatchSize: 100, reconcilerbatchSize: 10) is claimed away and the sum runs once per terminal iteration task, not once per stream record. - Realistic N is the number of iterations a human drives on one issue — single digits. Serial
GetItemat that size is milliseconds. - Both consumers set
reportBatchItemFailures: true, so a poison record cannot block the shard.
Residual issue, small but real: at the 1 MB Query page limit the function silently undercounts — total is summed over a truncated Items list and returned as if complete, so the user is shown a cost number that is quietly wrong rather than an error. That needs roughly thousands of tasks on one issue, so I would not block on it. Cheap fix if you want it: a LastEvaluatedKey loop, or an explicit "first N iterations" caveat in the copy.
Also worth noting: sumIterationCostForIssue exists twice with the same shape — fanout-task-events.ts and orchestration-reconciler.ts:1252. Two copies of a cost-accounting function will drift.
3. 422 REPO_NOT_ONBOARDED treated as terminal — REFUTED; terminal is correct, and the two updated comments are now accurate
Terminal is the right call, for the reason isDeterministicCreateFailure documents: rolling back to ready hands the row to the 10-minute stranded-orchestration sweep, which would re-release it forever with no terminal state, no ❌, and the epic stuck on 👀 — an infinite silent strand. A repo does not onboard itself, so the retry can never succeed on its own.
The three properties that make terminal safe here are all present:
- The user is told, specifically.
deterministicFailureReason(422)renders"Couldn't start — this repo isn't onboarded to ABCA. Onboard it, then reply@bgagent retryon the epic to re-run." - The remedy is real.
computeEpicRetryPlan(orchestration-reconcile.ts:327) resetsfailed → ready/blockedbased on predecessor state and re-releases, so@bgagent retrygenuinely re-runs a terminally-failed child after onboarding. The work is recoverable, not stranded. - 409 is correctly excluded from the deterministic set, so an idempotent replay still rolls back and finalises.
No change needed. The distinction the code draws — deterministic-but-user-fixable → terminal with a remedy, versus transient → rollback — is the right one, and the updated comments now match the behaviour.
c9ab31c to
f28f5ff
Compare
27309a6 to
7ca2039
Compare
f28f5ff to
007e462
Compare
7ca2039 to
0434e5f
Compare
007e462 to
8f7f73f
Compare
0434e5f to
5209d55
Compare
Review findings addressedBLOCKING #1 — Jira fan-out final-status comments silently disabled. Confirmed and fixed. Verified independently: Your note on the class was the important part — this was not the Jira work that was being watched, so the watching was mis-targeted. I have added a test asserting both surfaces' registry env vars and both OAuth ARN patterns on the fan-out consumer, and mutation-tested it: removing the props now fails the suite. That guard, not the restore, is what stops a silent optional-prop loss recurring. I also swept MAJOR #2 — the log-delivery pin shim fires on the DEFAULT stack name. Confirmed and fixed. MAJOR #4 — MAJOR #5 — published docs leak private context. Confirmed and fixed, in both the source docs and their generated mirrors. The Engram pointer was the worst of the three for exactly the reason you gave: an adopter evaluating the credential model finds the decision's central rejection unauditable. The fork-divergence section and the workspace slug are gone too. MINOR #7 — reconciler granted read on the entire trace/artifacts bucket. Confirmed and fixed. Scoped to Activation is unconditional — your framing accepted, not changed. You are right that the repo has a MAJOR #3 — activation turns on a lookup no pre-existing task row can satisfy. Confirmed; NOT fixed, and this is the one I most want a second opinion on. Your failure walkthrough is correct: I have not built it because it is a new behaviour rather than a correction, and it needs a deliberate choice about how far back to look and what to say when the fallback also misses. Tracked as the top follow-up. If you would rather this block the merge, say so — it is the one finding here where "silently drops user input" argues for fixing before adoption rather than after. Gates at the tip: 3653 CDK tests / 180 suites, 680 CLI, 1440 agent; |
8f7f73f to
a510000
Compare
5209d55 to
317ca6b
Compare
Correction: the log-delivery pin shim is NOT obsolete — I was wrong, and the deploy proved itI deleted the shim, reasoning that it was dead code. That was wrong. Reverted. My analysis compared the resource NAMES. Those genuinely no longer overlap — the alpha naming scheme moved on, live is The stack rolled back: A So the shim stays, opt-in as this PR had it. On the "you now need a flag on every deploy" objection — that is solved outside the repo, which is the right place for it. A pin is only ever correct for the account that already owns those resources, so it belongs in that account's own gitignored { "pinnedLogDeliveryStack": "backgroundagent-dev" }A bare Deployed and live-verifiedDeployed the full stack (S8 tip, whole arc active) to dev and verified each fix by artifact, not exit code — the first two deploy attempts reported exit 0 while failing at ECR asset push, so this matters:
One thing I could not verify end-to-end: a full Linear-triggered orchestration. The |
62e29fc to
13338b8
Compare
dc1a4dd to
b1cdce4
Compare
13338b8 to
3b1863f
Compare
3d0e97f to
c952e58
Compare
scottschreckengaust
left a comment
There was a problem hiding this comment.
Review — carve S8 / activation (#662)
Reviewed as a Principal AWS Solutions Architect, scoped only to this slice's incremental diff (carve/s7-orchestration-plane..carve/s8-activation, 75 files, +13687/−565). This is the activation slice — the one PR in the stack that flips deployed behaviour from dormant to live — so it gets the highest bar. I read the author's own automated-review thread and independently re-verified every prior blocking claim against the current tip before finalizing.
1. Verdict — Request changes
The five previously-flagged blocking/major items (Jira fan-out revert, log-delivery shim default-name, linear_epic.py private workspace, doc leaks, over-broad S3 grant) are genuinely fixed at this tip and I confirmed each. Two confirmed defects remain unfixed, both on the exact path this PR turns on, and both fail silently or misleadingly — the failure mode this repo's tenets ("fail closed", "surface failures", "reviewable outcomes") exist to prevent. The author explicitly left #3 open asking whether it should block; for an activation slice that regresses in-flight user issues on first deploy, it does.
2. Vision alignment — fits, with a caveat
Activating the orchestration arc directly advances the VISION north star (fire-and-forget, escalate-by-policy) and lands approved P0 issues #247 and #299. Blast radius is real but bounded (concurrency table, HITL plan gates, terminal-state routing). The unconditional activation — no tryGetContext gate, every existing deployment flips live on first cdk deploy — is a deliberate, documented decision (the carve exists to end the merged-but-unreachable state); I accept the framing but it raises the bar on the two silent-failure defects below, because they land on every operator's existing issues at the moment of adoption.
3. Blocking issues
B1 — Comment-verdict success marker (✅) is set on paths where nothing succeeded (cdk/src/handlers/linear-webhook-processor.ts:2340, :2412, and handleSingleTaskVerdict at :2856).
The pending && verdict∈{approve,reject} block wraps verdict handling in try { … } finally { await settleVerdictMarker(); }, and settleVerdictMarker sets the trigger comment's reaction to 'succeeded' for any non-reject verdict. handleSingleTaskVerdict runs inside that try and returns with a bare return on two failure bail paths that are new in this PR:
!singleHydrated.ok(attachment screening failed →safeReportIssueFailure('❌ …')) then thefinallystamps ✅ on the same comment.result.statusCode !== 201(task creation failed → failure message) then ✅.
The user watching the comment sees a ❌ failure message and a ✅ success tick — contradictory, and the tick is the durable signal. The finally's own comment justifies settling for "fully resolved verdicts", but these early returns are not that. Fix: have handleSingleTaskVerdict return a discriminated outcome and let settleVerdictMarker choose the marker from the outcome, not from verdict alone. (Independently confirmed; matches the author's own "CONFIRMED, and worse than described" trace.)
B2 — Activation silently drops the first follow-up comment on every in-flight Linear issue at adoption (cdk/src/handlers/shared/linear-task-by-issue.ts:49-80; caller linear-webhook-processor.ts:3457).
This PR is the first to set ORCHESTRATION_TABLE_NAME, which turns on the standalone comment path. That path resolves issue→newest-task via resolveTaskByLinearIssue, a Query on the sparse LinearIssueIndex GSI keyed on the top-level linear_issue_id attribute — an attribute written for the first time by the hoist added in this same PR (create-task-core.ts:720-725). No task created by the currently-deployed code carries it and there is no backfill, so the Query misses; on both miss and error resolveTaskByLinearIssue returns null, which the caller logs at info and treats as "not an ABCA issue — ignore". Result: a user comments @bgagent … on an issue ABCA opened a PR for yesterday, and after this deploy the comment is dropped with no reply and no reaction. This affects every issue in flight at the adoption boundary. Minimum fix: tell the user (a reaction/comment) instead of a silent info log; better: fall back to extractLinearIdentifier over recent tasks so pre-hoist rows still resolve. The author confirmed this and left it as a tracked follow-up — for an activation slice that silently drops user input on adoption, resolve it (even just the "tell the user" minimum) before merge.
4. Non-blocking suggestions / nits
- N1 —
MAX_TURNSdefault drift.agent.ts:353raises the runtime envMAX_TURNS100→200 (and agentconfig.py/server.pyfall back to"200"), butcdk/src/handlers/shared/validation.ts:34still exportsDEFAULT_MAX_TURNS = 100(what the orchestrator writes into dispatch payloads) anddocs/guides/DEVELOPER_GUIDE.md:251still documents100. The effective ceiling now depends on which path supplies the value; reconcile the two defaults and the doc. - N2 —
sumIterationCostForIssuesilently undercounts at the 1 MB Query page boundary (fanout-task-events.ts,orchestration-reconciler.ts:1252). The Query is unpaginated andtotalis summed over a possibly-truncatedItemslist, then shown to the user as if complete. Needs thousands of tasks on one issue, so not blocking, but the reported cost is quietly wrong rather than an error. Also: the function exists twice with the same shape — two copies of a cost-accounting routine will drift; extract one. - N3 — file size / proportionality.
linear-webhook-processor.tsis now 4411 lines. It is coherent and well-commented, but it is well past the 800-line heuristic; consider splitting the verdict/revision/standalone-trigger flows into siblings for reviewability.
5. Documentation
Docs are thorough and the prior private-context leaks (Engram pointer in ADR-016, the linear-vercel fork-divergence section, workspace slug) are gone at this tip — confirmed in both docs/design|decisions/ and the generated docs/src/content/docs/ mirror, and the mirror is in sync (both sides updated in the diff). New ADR-001/016/018 and design notes are appropriate. Only doc gap is N1 (DEVELOPER_GUIDE.md:251 MAX_TURNS). Issue tracking is correct: epic #668 plus approved P0 #247/#299.
6. Tests & CI
CI green (build agentcore, secrets/deps, dead-code, title). Strong new coverage: linear-webhook-processor-orchestration.test.ts (+1199), linear-webhook-plan-command.test.ts, mode-a attachments, orchestration-e2e.test.ts, agent.test.ts, and a mutation-tested guard that the Jira fan-out props can't silently vanish again. Gap: no test asserts the marker outcome on the handleSingleTaskVerdict failure bail paths (B1) — a test there would have caught the ❌+✅ contradiction. Bootstrap synth-coverage: not applicable — this slice instantiates constructs (Lambda, DynamoDB SlackChannelMappingTable, events.Rule) whose CFN resource types already exist in the stack; no new resource type is introduced, and the bootstrap suite runs in the passing build.
CDK test perf: spot-checked new suites — no re-enabling of aws:cdk:bundling-stacks; jest worker limits added to cdk/package.json to bound memory. Good.
7. Review agents run
Operating in a subagent context without the interactive pr-review-toolkit agents; I applied each agent's lens by hand over the in-scope diff and cite findings accordingly: code-reviewer (B1, N3, duplication in N2), silent-failure-hunter (B1, B2, N2 — the core of this review), type-design-analyzer (LinearIssueTask/resolveTaskByLinearIssue return shape reviewed — clean, though null-on-both-miss-and-error conflates two cases, feeding B2), comment-analyzer (the settleVerdictMarker and log-delivery-shim comments were re-checked against behaviour — shim comment now accurate; verdict finally comment is accurate for its intended paths but doesn't cover the bail returns), pr-test-analyzer (§6 gap), /security-review lens (S3 grant now scoped to artifacts/* with in-handler prefix rejection — confirmed; Jira/Linear OAuth secret ARN grants scoped to bgagent-*-oauth-*; no new IAM over-grant found). None omitted for scope.
8. Human heuristics
- Proportionality — concern:
linear-webhook-processor.ts:4411 lines(N3). Otherwise complexity matches an activation slice. - Coherence — concern:
sumIterationCostForIssueduplicated across two handlers (N2). Naming/structure otherwise consistent. - Clarity — concern: B1/B2 — error handling hides failures behind a success marker (B1) and a silent ignore (B2);
MAX_TURNSmagic value diverges fromDEFAULT_MAX_TURNS(N1). - Appropriateness — pass: integration paths are covered by e2e/orchestration suites and the author reports live artifact-verification (Jira env var, scoped IAM simulation, GSI addition without table replacement); tests assert intended behaviour, with the B1 outcome-assertion gap noted.
Reviewed with Claude Code. Prior self-review blocking claims re-verified against the tip at 844b27e; B1/B2 confirmed present and unfixed.
a410f44 to
784dcda
Compare
a362183 to
f8db2d3
Compare
784dcda to
8fb38f5
Compare
Both blocking defects fixed, plus N1 and N2Head is now B1 — verdict marker claimed success on its own failuresConfirmed exactly as described, including the third path you did not list: the raced/expired-plan bail also fell through to the ✅ stamp.
The third case is worth calling out: Three tests, one per bail path. Verified by reverting the marker to verdict-keyed and watching all three fail while the pre-existing "marker still settles when handling THROWS" test kept passing, so the B2 — activation dropped the first follow-up on in-flight issuesFixed properly rather than with the minimum. Your type-design note fed straight into the fix:
Reaching that code requires an explicit
Both were read as rendered output, not reviewed in source. It says what to do next, not only what failed. Redelivery-guarded with the same ack-claim the near-miss path uses, so a Linear retry does not post it twice. I did not add the One existing test asserted the old silent behaviour; it now asserts the notification. N1 — MAX_TURNSReverted to N2 — sumIterationCostForIssueExtracted to one implementation. The duplication had already caused drift, which makes your "two copies will drift" point concrete: one copy parsed a string The shared version paginates. Both copies summed a single 1 MB page as if it were everything, and a capped or partial sum now reports itself as partial rather than presenting a short number as the total. N3 — file size: not doing itReal (4497 lines, more than 2x the next-largest handler) but I am declining it in this PR. Splitting it would rewrite a diff reviewers have already read, for zero behavioural change, on the slice with the highest blast radius. Filed as follow-up work instead. CI green: 3688 CDK + 1444 agent tests at the tip. Not yet redeployed — B1/B2 are live behavioural changes on the activation path, so deploy + live-verify is next before I would consider this mergeable. |
f8db2d3 to
4f90e00
Compare
49fa0d7 to
e738f6d
Compare
4f90e00 to
836c3d0
Compare
0d68bf3 to
db3ad39
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
|
3c0899e to
158eac8
Compare
…e it The final slice: instantiates everything the previous slices added and turns it on. Before this, S5-S7 shipped modules and constructs nothing referenced; after this, a labelled Linear issue actually runs. Stack wiring (cdk/src/stacks/agent.ts): - instantiate OrchestrationTable, OrchestrationReconciler and the scheduled StrandedOrchestrationReconciler, and grant each what it needs. - pass ORCHESTRATION_TABLE_NAME to the Linear webhook processor. That variable IS the activation switch: absent, the processor skips the orchestration path entirely, which is what kept S5-S7 dormant. Webhook processing (cdk/src/handlers/linear-webhook-processor.ts): - route a labelled parent issue to graph discovery, a plain issue to a single task, and a decompose-suffixed issue to the planner. - route an @bgagent comment to the right place: a plan verdict, a plan revision, an iteration on a sub-issue, or a nudge when it's ambiguous. - dispatch the decompose-planning agent task and consume its plan artifact. Also here: - github-webhook-processor: fold a PR preview screenshot into the iteration's maturing reply instead of posting a standalone comment. - fanout-task-events: route terminal-state feedback through the orchestration panel when the task belongs to an epic. - linear-task-by-issue: resolve a Linear issue to its newest task and PR, for a comment on an issue with no orchestration row. - Slack: channel-to-repo mapping so a channel has a default repo (CLI + table). - jest worker limits in cdk/package.json, so the full suite doesn't exhaust memory on a developer machine. - docs: ADR-001 (stacked PRs), ADR-018 (agent-session interaction), the compute and rightsized-planning design notes, the surface-agnostic orchestration design, the Linear setup guide, and the reconciler-correctness worksheet. - operator scripts for inspecting and tearing down an orchestration run. Three files carry a change from BOTH sides of the fork and were merged rather than replaced, so the Jira app-identity work is preserved alongside this arc: jira-integration.ts (keeps the app-actor wiring AND adds the pdf-parse bundling carve-out its PDF-screening handler needs), ADR-016, and the user guide.
…rant, de-opinionate Five fixes from the automated review of this slice. **Jira final-status comments were silently disabled.** The FanOutConsumer call had lost `jiraWorkspaceRegistryTable` and `jiraOauthSecretArnPattern`. Both props are optional on the construct, so their absence produced no synth error and no test failure — the capability just went away, and the construct still gates the registry grant, the table-name env var and the OAuth secret grant on them. A Jira task that died before the agent posted its own terminal comment would have left the user's issue with no final status. Restored, with a test asserting both surfaces' registry env vars and both OAuth ARN patterns, so an optional prop cannot go missing quietly again. **The reconciler could read other users' agent traces.** Its S3 read was granted on the whole trace/artifacts bucket, but the bucket holds two disjoint key spaces: `artifacts/<task_id>/`, which is all the reconciler needs, and `traces/<user_id>/`, which holds full agent trajectories including tool input and output and is authorized per-user elsewhere. Since the reconciler takes the artifact URI off the task record, a bucket-wide grant made a bad or tampered URI able to reach a trajectory. The grant is now scoped to the artifacts prefix, the prefix is a named constant beside its `traces/` sibling, and the handler rejects a URI pointing outside it so the failure is a clear log line rather than an AccessDenied. **The log-delivery pin shim fired on the default stack name.** It overrides CFN logical ids and account-unique resource Names with values captured from one specific pre-existing stack, and it keyed off `stackName` — but the default stack name is itself a key in its table, so any operator deploying without a `stackName` override silently inherited another account's hardcoded ids. It now requires explicit `-c pinnedLogDeliveryStack=<name>` opt-in. The doc comment that claimed it was "OFF by default" is corrected. **`scripts/linear_epic.py` hardcoded a private workspace.** Team and project ids were module constants pointing at one Linear workspace, and the docstring promised flags that did not exist. Team id is now required via `--team` or `$LINEAR_TEAM_ID` with a message telling you where to find it; project and label are optional with the platform default. The credential is read only from the environment, not from a world-readable /tmp path. **Published docs leaked private context.** An ADR grounded its central rejection in a pointer to a private memory store, making the decision unauditable to an adopter reading it. A design doc carried a section about diverging from the private fork branch and verifying against it — process notes, not architecture. A workspace slug appeared in another ADR. All removed, in both the source docs and their generated mirrors.
Making the shim opt-in fixed the wrong-default problem but left a worse one: the one stack that legitimately needed the pin now had to pass `-c pinnedLogDeliveryStack=<name>` on every deploy, forever. It turns out the shim is obsolete. It existed because an agentcore-alpha rename churned both the CFN logical ids and the account-unique `Name` of an already-deployed stack's log-delivery resources, and because those Names are account-unique, create-before-delete on the new ids would collide with the live ones and roll the stack back. But the alpha's naming scheme has moved on again: the names the current code generates share no prefix with the ones CFN is holding. Checked against the live stack — live: cdk-applicationlogs-source-…, cdk-cwl-Destapplication-logs-dest-… new: backgroundagentdevRuntimeApplicationLogsDeliverySource…, …Dest… — zero overlap, so the collision the shim prevents can no longer happen. An unpinned deploy replaces those six resources once, cleanly, which is what the code comment always said should happen "once the affected stack is migrated". So: shim deleted, no flag needed, and nobody inherits another account's hardcoded resource ids. The test that pinned the opt-in behaviour is replaced by one asserting no synthesized log-delivery resource carries a name from the hardcoded era, so neither the shim nor anything like it comes back unnoticed.
…ting it" Deleting the shim was wrong, and deploying it proved so: the stack rolled back with AWS::Logs::DeliverySource ... "This ResourceId has already been used in another Delivery Source in this account." (HandlerErrorCode: AlreadyExists) My analysis had compared the resource NAMES — which genuinely no longer overlap — and concluded the collision could not happen. But a DeliverySource is keyed on the resource it delivers from, not on its name: both sources point at the same AgentCore runtime ARN, and AWS refuses a second source for a resource that already has one. So create-before-delete collides regardless of naming, which is exactly what the shim exists to prevent. The rollback left the live resources intact. The shim stays, opt-in as before. Needing a flag on every deploy is solved outside the repo: the pin belongs in the deploying account's own gitignored `cdk.context.json`, since it is only ever correct for the account that already owns those resources. That gives a bare `cdk deploy` with no churn, and keeps one operator's hardcoded ids out of everyone else's default — verified by deploying this stack flag-free with the log-delivery resources untouched.
…licy The guard added with the Jira fan-out restore stringified EVERY synthesized IAM policy and grepped for the two OAuth ARN prefixes. Those prefixes are also granted to the orchestrator and the webhook processors, so the assertion passed even with the fan-out's own grant removed — it proved nothing about the resource it was named after. Mutation-tested: removing the Jira props from the FanOutConsumer call used to fail one of the two new guards; it now fails both.
…nd had no test Two things a second review pass got right about my own fix. **The comment claimed IAM would fail closed. It does not.** I wrote that a stray artifact URI "would fail closed anyway" because the grant is scoped to `artifacts/*`. S3 does not normalize keys, so `artifacts/../traces/u/x` is a literal key that an `artifacts/*` resource matches by string prefix. Confirmed against the live policy — `iam simulate-principal-policy` returns **allowed** for exactly that shape. So a bare `startsWith` was the only thing standing between a tampered task record and another user's agent trajectory, and my comment said otherwise. The check now rejects any traversal segment, raw or percent-encoded, and the comment states what actually holds rather than what I assumed. **It had no test.** Deleting the whole check left all 64 reconciler tests passing. Now covered, including the lookalike prefix (`artifactsX/`), a bare `artifacts`, and a filename that merely contains dots — mutation-verified against the previous `startsWith` form.
…er uses Found deploying this arc to a second environment: `bgagent repo show` printed "platform default us.anthropic.claude-sonnet-4-6" while every agent turn actually ran on opus-4-8. The file matches main, but this arc is what made it wrong — S3 flips the agent's fallback model, so the CLI's copy of that value went stale the moment the two shipped together. Not a display-only string, which is why this matters more than a wrong label: `platform doctor` derives the model it probes for ACCESS from this constant. Naming a different model than the runtime invokes means doctor can report a healthy stack while every task fails at turn 0 with AccessDenied — the exact failure the model grant fixed, hidden behind a green check. `max_turns` had drifted the same way: the CLI said 100, the deployed runtime env says 200. Both corrected, and a drift guard reads the agent's own fallback out of `config.py` and asserts the CLI constant matches — the same shape as the guard on the Bedrock grant list, so the three copies of this value cannot diverge silently again. The `max_turns` assertion now derives from the constant instead of a hardcoded literal, which is what let it drift unnoticed.
`COMPUTE.md` still advertised a 16 vCPU / 64 GB build def against a code default of 4 vCPU / 16 GB, and justified it with a 31.6 GB OOM measurement. Both numbers are real; they measure different configurations, which is the part worth writing down. A fully parallel `mise run build` of this repo does peak ~31.6 GB and did OOM-kill a 32 GB task. But the build tier sets `MISE_JOBS=1`, which serialises the per-package legs so peak becomes max-single-package rather than sum-of-all — measured at ~3.1 GB on the same repo and the same build. That is a 10x difference produced entirely by the parallelism setting, so quoting the parallel figure next to a serialised default made the default look reckless. Now states both, names which one the shipped configuration produces, and records the disk figure (~14.7 GiB peak against a 50 GiB default) since disk turned out to be the tighter of the two margins. Also points at the context flags rather than implying the numbers are fixed.
… turns on Addresses the two confirmed blocking defects from review, both on the path this slice activates, plus two of the three nits. B1 — the verdict marker claimed success on its own failures. The trigger comment's reaction was chosen from the verdict that was REQUESTED, so any non-reject verdict settled to ✅, including the bail paths that had just posted a ❌ message. The user saw a failure and a success tick on the same comment, and the tick is the durable signal. handleSingleTaskVerdict now returns a discriminated outcome and the marker is derived from it. A raced or expired plan leaves the marker untouched rather than re-stamping a comment someone else already settled. Three tests, one per bail path; verified by reverting the marker to verdict-keyed and watching all three fail. B2 — an addressed @bgagent comment was dropped in silence. The issue→task link comes from a sparse GSI on an attribute this PR is the first to write, with no back-fill, so on the deploy that enables this path every issue already in flight misses the lookup. Reaching that code requires an explicit mention, so the user IS waiting; logging at info and returning is indistinguishable from being ignored. The lookup now reports "no task" and "the lookup broke" as distinct outcomes, because they are different facts: the second cannot conclude anything about the issue, and reporting it as the first is a guess presented as a conclusion. Each gets a nudge that says what to do next, and the comment settles to ❓ rather than sitting on 👀. Both messages were read as RENDERED output, not just in source. N1 — MAX_TURNS was raised 100→200 in three places (stack env, agent config, agent server) while DEFAULT_MAX_TURNS and the developer guide still said 100, so the effective ceiling depended on which path supplied it. main is uniformly 100. Reverted to match: the bump is a real intended change but it is a behavioural one that belongs in its own PR, not smuggled into an activation slice. All five sites now agree. N2 — sumIterationCostForIssue existed twice and the copies had already drifted: one added a string cost_usd without a finite check, so a stringified value poisoned the total to NaN, while the other guarded correctly. Cost accounting that disagrees with itself depending on which handler ran is worse than either behaviour alone. Extracted one implementation, which now PAGINATES — both copies summed a single 1 MB Query page as though it were everything, and truncation here surfaces as a quietly short number rather than an error. A capped or partial sum now says so. N3 (file size, 4497 lines) not addressed — splitting it would rewrite a diff reviewers have already read, for no behavioural gain. Filed as follow-up work. Also stamps decompose_trigger_label, the producer half of the retry-hint fix in the compute-plane slice, with a test pinning the key both sides use.
Decomposition stays on the development branch as experimental. This removes the
half of the webhook that proposed a breakdown and waited on a human verdict:
the :decompose/:auto dispatch, pending plans, verdicts, revisions, the plan
commands ("drop 3", "merge 1 and 2"), the caps, and the single-task-verdict path
(a "single task" here only existed as the outcome of a decompose that declined to
split). The webhook drops from 4502 to 3156 lines.
What stays is the part that does not presuppose a workstyle: running a sub-issue
graph a human already authored in Linear, iterating on a PR from an @bgagent
comment, clarify-resume, the epic panel and retry, the :help explainer, and the
wrong-handle nudge.
Seven renderers were stranded in the decompose-named render module but are still
used, so they moved to `linear-notes.ts` and the old module is gone. Two of them
are the no-linked-task and lookup-failed nudges from the previous commit — the fix
for an @bgagent comment being silently dropped on an issue with no linked task.
Verified that fix still fires by deleting its call site and watching the tests fail.
`renderMultiPartHint` was dropped rather than reworded: its entire content was
"add :decompose instead", so with that label gone it would tell a user their issue
looks multi-part while offering nothing to do about it. `renderWrongMentionNudge`
and `renderLabelHelp` were reworded instead, since the mistakes they catch (a label
name used as a mention handle; not knowing what the labels do) still happen.
Removing the plan surface also made real code dead: the verdict parser and its
phrase tables in the comment trigger, and the webhook's bedrock:InvokeModel grant,
which existed only to interpret a revision request. Dropping that grant is a
least-privilege gain.
Two things that LOOKED decompose-only and were not:
- `trigger_label`. The retry hint names the project's own trigger label, and the
producer had been the planning task. Removing the planner would have silently
re-broken it, sending every epic back to advertising the default label. The
webhook seed is now the only seed site, so the stamp moved there. Confirmed by
deleting the stamp and watching the assertions fail.
- `sweepDecompositionNotes` / `PLAN_PROPOSAL_PREFIX`. Both are decompose-NAMED but
general: the sweep deletes any transient bot note by prefix, and the surviving
nudges use those prefixes. Renamed to `sweepTransientNotes` / `BOT_NOTE_PREFIX`.
Decomposition stays on the development branch as experimental, so the shipped docs and the CLI must stop offering it. A doc describing configuration the code does not honour is worse than no doc: an operator sets the flag, waits for behaviour that never comes, and has no way to tell which of the two is wrong. The Linear setup guide advertised a four-label taxonomy; two of those labels no longer do anything, so the table is down to the trigger label and `:help`. The notes that explained the approval conversation, the multi-part hint, and the per-project caps are replaced by one line stating what is actually true now: you declare the breakdown by writing sub-issues yourself, and a plain label on a multi-part issue runs it as one task. The CLI kept `--decompose-allowed`, `--max-sub-issues`, and `--max-parent-budget-usd` on `linear onboard-project`. Those wrote fields nothing reads any more — worse than a no-op, because the command printed "Auto-decomposition: ENABLED" and named the labels to trigger it. Removed. Found by widening the search beyond `cdk/` and `agent/`, which is where I had been looking. Two design docs are deleted rather than rewritten. `PLAN_MODE_REFACTOR.md` is entirely about the planning flow, and it is also a private working note — branch names, session ids, a HOLD instruction to another session — so it should not have been in a public slice regardless. `ECS_RIGHTSIZED_PLANNING.md` describes something that survives (the smaller read-only task def) but used the planner as its only example; it now uses `coding/pr-review-v1`, the read-only workflow that remains. Its sizing figures were also stale, quoting a 64 GB build tier and a 31.6 GB peak: both real, but that peak is the FULLY PARALLEL build, and the build tier serialises with MISE_JOBS=1, which measures at ~3.1 GB. Now states both and which one the shipped default reflects. Left alone deliberately: ADR-001, ADR-002, ADR-012, and ADR-020 use "decompose" as ordinary English — splitting a PR, splitting a procedure into layers. Those are not this feature and rewriting them would be a false positive. Every doc edit is mirrored in docs/src/content; verified in sync, and the link check passes across all 58 markdown files with no dangling reference to the two deleted files.
The orchestration reconciler held three object grants that no longer have a caller: a scoped read on the artifacts prefix, and put + delete on the attachments bucket. All three existed for the plan-seeding path — reading the artifact a planning task uploaded, and hydrating the parent issue's attachments so seeded children inherited them. The compute-plane slice removed that code; this is the wiring that was left behind. A permission with no caller is the kind that survives review, because nothing fails when it is wrong. Removing them is a real least-privilege gain: that bucket also holds traces/<user_id>/ — full agent trajectories including tool input and output, authorized per-user by the presign handler — and the reconciler handles no user identity at all. The test that asserted the artifact grant was correctly scoped now asserts the stronger property: the reconciler has no object statement anywhere. That is also the safer claim, since S3 does not normalize keys, so `artifacts/../traces/u/x` is a literal key an `artifacts/*` resource matches by prefix. It checks the reconciler HAS other policies first, so an id filter that stops matching fails loudly instead of passing as a vacuous absence. That guard paid for itself immediately — the assertion caught the attachments-bucket grants, which I had not noticed were also seed-only.
A dead-code pass over the chain — knip plus vulture — found three exports the decomposition removal orphaned, each with exactly one declaration and zero uses: - `MENTION_TOKEN`. Its only consumer was the plan-verdict parser, which went with the feature. - `SLACK_OWN_REACTION_EMOJI`. The comment said it was re-exported for the adapter's own tests; no test imports it, and none did. A stated purpose is not evidence. - `MAX_COST_TASKS_PER_ISSUE`. Mine, from the shared cost module. Used inside the file, so it stays — just not exported. Deliberately NOT touching the remaining knip findings. They are exported types that form the public shape of an interface and are used throughout their own modules (`CommentRef` appears ten times in the Channel contract). Un-exporting a type that describes a parameter of an exported function trades a knip line for a worse API, and knip counts an exported type as unused when no OTHER module imports it by name — which is expected for a structurally-typed contract. Python side is clean: vulture reports nothing, which matters here because the removal deleted a prompt module, a workflow, and two prompt-injection helpers. For the record, the dead-code ratchet was already failing before this work: its baseline is 78 and `main` measures 91. The carve is at 106. That gate is advisory in CI (`continue-on-error`), which is why the PRs show green — not something this change papered over. Lowering the baseline to lock in the gain belongs in its own PR once the arc settles, per the note in knip-baseline.json.
… the synthetic id reaching Linear Completes the incident fix on the webhook side. An epic's API and UI children disagreed on a contract, the symptom appeared only once merged, and the reviewer's comment on the epic was routed to the UI child — whose PR does not contain the backend code they were describing. The agent, on a branch that built and whose tests passed, did the locally-sensible thing and changed how the error was displayed. ROUTING. Naming the integration node now reaches the combined PR. Most of this already worked once the parser was completed: the routing takes a row and a PR number and pins coding/pr-iteration-v1, so the integration node flows through the same path a child does, with the combined branch already holding every sibling's merge. No second routing mechanism was added. THE SYNTHETIC ID. Two things did need fixing. `linear_issue_id` was set to the matched node's `sub_issue_id`, which for an integration node is a derived string (`<orchestrationId>__integration`) and not a Linear issue at all — the agent uses that id to react and comment, so it would have called Linear with an id that cannot resolve. It now falls back to the issue the trigger comment lives on, which for a routed parent comment is the epic. The reply target already used the parent id; this makes the agent's own feedback match. THE PROMPT. An iteration on the combined PR gets an instruction that says what the default one cannot: reproduce against the merged code before changing anything, read BOTH sides of every boundary the symptom crosses, fix the mismatch at its source rather than adjusting an error message or fallback, and leave a test that crosses the boundary. It states explicitly that a clean merge and green per-component tests are not evidence — the broken version had both, which is why "looks fine locally" was the wrong conclusion. Five handler tests: the integration comment targets PR 560 and not the child PRs 557/558; `combined` works as well as `integration`, since that is the word the panel shows; Linear feedback uses the parent id and never the synthetic one; a redelivered webhook creates one iteration, not two; and the disambiguation reply offers integration only when the node exists. Ambiguous comments still ask rather than routing to the combined branch on a guess — a misrouted change there is the costliest kind to unpick. Verified by mutation: reverting the id guard and swapping in the default prompt each fail their own test.
158eac8 to
00bb555
Compare
The final slice of the staged carve. Stacked on #659 (
carve/s7-orchestration-plane) — review that first; this PR's diff is only its own commit.What this does
Instantiates everything the previous slices added, and turns it on. Before this, S5–S7 shipped modules and constructs that nothing referenced; after this, a labelled Linear issue actually runs as an orchestrated sub-issue graph.
Stack wiring —
cdk/src/stacks/agent.tsOrchestrationTable,OrchestrationReconciler, and the scheduledStrandedOrchestrationReconciler; grant each what it needs.ORCHESTRATION_TABLE_NAMEto the Linear webhook processor. That variable is the activation switch — absent, the processor skips the orchestration path entirely, which is exactly what kept S5–S7 dormant. This is why stack wiring and webhook activation could not be split into two PRs: either one alone ships a half-wired system.Webhook processing —
cdk/src/handlers/linear-webhook-processor.ts@bgagentcomment to the right destination: an iteration on a sub-issue, new work on a PR-less task, an epic retry, or a nudge when the intent is ambiguous or the issue has no linked task.Also here
github-webhook-processor— fold a PR preview screenshot into the iteration's maturing reply instead of posting a standalone comment.fanout-task-events— route terminal-state feedback through the orchestration panel when the task belongs to an epic.linear-task-by-issue— resolve a Linear issue to its newest task and PR, for a comment on an issue that has no orchestration row.cdk/package.json, so the full suite doesn't exhaust memory on a developer machine.Three files were merged, not replaced
jira-integration.ts,ADR-016, andUSER_GUIDE.mdcarry a change from both sides of the fork: this repo added the Jira app-identity work while the source branch added its own change to the same file. Taking either version wholesale would have silently reverted the other. They were 3-way merged against the common ancestor, so both survive — notablyjira-integration.tskeeps the app-actor wiring and gains thepdf-parsebundling carve-out its PDF-screening handler needs (cdk/test/contracts/pdf-parse-bundling.test.tsenforces that every construct bundling such a handler has it).Verification
tsc --noEmitclean oncdkandcliruff check,ruff format --check,ty checkall cleaneslintclean oncdkandcliNot yet deployed or live-verified. This is synth-and-test-green only. Since this slice is the one that actually activates the path, a dev deploy and a live run are worth doing before it merges.
🤖 Generated with Claude Code
Tracking
Slice S8 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 #659 — review that first; this PR's diff is against its branch.