Skip to content

feat(carve S5): orchestration DAG core, row store, and the channel abstraction (dormant) - #656

Merged
isadeks merged 22 commits into
carve/s1-foundation-contractsfrom
carve/s5-dag-core-channel
Jul 29, 2026
Merged

feat(carve S5): orchestration DAG core, row store, and the channel abstraction (dormant)#656
isadeks merged 22 commits into
carve/s1-foundation-contractsfrom
carve/s5-dag-core-channel

Conversation

@isadeks

@isadeks isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Slice 5 of the staged carve. Stacked on #655 (carve/s4-linear-surface) — review that first; this PR's diff is only its own commits.

What this adds

The dormant foundation for parent/sub-issue orchestration. Three groups, all inert:

Pure graph logic — no I/O, so it is unit-testable in isolation.

  • orchestration-dag — validate a depends_on graph (duplicate id, dangling edge, cycle) and return its topological layering (Kahn's algorithm). A reconciler releases children layer by layer.
  • orchestration-graph-source — the seam between where a graph came from and the executor that runs it. Three tiers: a tracker with native sub-issues (read them), a caller that supplies the DAG (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's one combined artifact instead of N unrelated PRs. Reuses the existing multi-predecessor merge path; no new merge code.
  • orchestration-epic-tip — where a newly-added, dependency-less node stacks: the leaf frontier, so it inherits the epic's accumulated unmerged work instead of 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 stop a duplicate webhook delivery from double-acting.
  • orchestration-log-events — structured per-run events, 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 (dropping children also drops their dependency edges, so survivors would release out of order).

Channel abstraction — the engine never branches on which tracker it's talking to.

  • orchestration-channel — the surface-agnostic interface: operations every channel must implement, plus optional ones gated by declared capability.
  • orchestration-channel-{linear,jira,slack} + factory — per-surface adapters, selected from the stored row.
  • orchestration-comment-trigger — parse a mention comment into a command; ignore the bot's own comments.
  • iteration-reply — one maturing threaded reply per iteration instead of a stream of new comments; the two async writers of a reply converge rather than overwrite each other.

Why this is safe to merge early

OrchestrationTable is not instantiated in any stack in this slice, and no handler calls into the store or the executor seam — the wiring lands in a later slice behind an env-var gate. So this is synth-and-unit-test only: it changes no deployed resource and no runtime behaviour.

Also here

One separate commit relaxes two stylistic ESLint rules (no-magic-numbers, no-shadow, max-len) inside test/** only. Table-driven tests with literal fixtures fight those rules constantly, and a lint-policy change shouldn't hide inside a feature commit — hence its own commit.

Verification

  • tsc --noEmit clean
  • CDK: 2917 tests / 150 suites passing (up from 2598/137 on the base)
  • eslint clean on cdk and cli
  • Agent: 1437 tests passing, ruff check / ruff format --check / ty check all clean

Tracking

Slice S5 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 #655 — review that first; this PR's diff is against its branch.

@isadeks isadeks changed the title S5: orchestration DAG core, row store, and the channel abstraction (dormant) feat(carve S5): orchestration DAG core, row store, and the channel abstraction (dormant) Jul 27, 2026
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 30aa161 to 0df8c2c Compare July 27, 2026 13:28
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 08bc626 to d3c250f Compare July 27, 2026 13:28
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 0df8c2c to c58d51b Compare July 27, 2026 16:05
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from d3c250f to 7af69f3 Compare July 27, 2026 16:05
@isadeks

isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Automated review — carve S5 (#656)

Reviewed as its own diff only (carve/s4-linear-surface..carve/s5-dag-core-channel, 35 files, +8742/−29). Governance is noted once for the whole stack, not repeated here.

Slice standalone-ness: PASS

tsc --noEmit clean at this tip on its own base (compiler run, not inferred).

All 29 deletions checked — no export present on main is lost from any of the three pre-existing files this slice touches (linear-feedback.ts, linear-issue-lookup.ts, slack-api.ts). The changes to those three are additive plus a docs-only ABCA-ENG- example rename, which is the right direction for a public repo.

Nothing is introduced-but-unused. I checked all 16 new source modules for a consumer in cdk/src by the top of the stack — every one has at least one. No dead modules.

Dormancy claim: CONFIRMED — but not for the stated reason

The PR description's premise does not hold: ORCHESTRATION_TABLE_NAME does not exist anywhere in the tree at this slice. It first appears at #659 and only reaches linear-webhook-processor.ts at #662. So dormancy here cannot rest on that env-var gate. It rests entirely on non-wiring, and on that basis all three checks pass:

  1. No handler imports the new modules. The only importers of the 16 new modules are each other. I checked every hit outside shared/ — none.
  2. No construct is instantiated. OrchestrationTable does not reach cdk/src/stacks/agent.ts until feat(carve S8): wire the orchestration arc into the stack and activate it #662 (verified by grepping new OrchestrationTable( at every slice tip: absent at s5, s6, s7; present only at s8).
  3. Nothing in agent/src imports it — the diff touches no agent/ file.

One thing worth flagging, because it is the only place the dormancy argument is non-obvious: this slice modifies linear-feedback.ts, which is imported by live handlers (fanout-task-events.ts, linear-webhook-processor.ts, github-webhook-processor.ts, orchestrate-task.ts). It adds imports of two new modules (iteration-reply, orchestration-comment-trigger) used at 6 call sites inside it. I traced those sites: they are inside fetchRecentComments, upsertThreadedReply and sweepDecompositionNotes — three functions that do not exist on main and whose only callers at this slice are the new (unwired) orchestration channel modules. So no live code path reaches the new code. The claim holds, but it holds by one link, not by isolation — worth stating in the PR description rather than leaning on an env var that isn't there yet.


1. MAJOR — this slice turns off four lint rules for all 152 test files, to accommodate 13 new ones

cdk/eslint.config.mjs extends the test/**/*.ts override from one rule to five:

       '@typescript-eslint/no-magic-numbers': 'off',
+      '@typescript-eslint/no-shadow': 'off',
+      'no-shadow': 'off',
+      '@stylistic/max-len': 'off',
+      'max-len': 'off',

The stated reason is this slice's own tests: "long fixture/assertion lines, and reuse small helper names (row, makeDdb) across sibling describe blocks."

But the override is repo-wide. At this slice there are 152 test files, 139 of which pre-existed this PR. So max-len and no-shadow are silently switched off across the entire existing suite in order to land 13 new files, and the relaxation persists through #662. no-shadow in particular is a correctness-adjacent rule in test code — a shadowed row or mock inside a nested describe is a common source of a test that asserts against the wrong fixture and still passes.

This is also precisely the "source file and the lint config that permits it, split across a boundary" case — except here they are in the same slice (good) with a blast radius far wider than the code that needed it (not good). Prefer scoping the override to the new files' paths, or fixing the ~13 files, or at minimum keeping no-shadow on and relaxing only max-len.

2. MINOR — extractLinearIdentifierFromBranch is added here, but its only consumer arrives three slices later

linear-issue-lookup.ts gains extractLinearIdentifierFromBranch with a substantial doc comment explaining why branch-first routing matters ("in a stacked sub-issue orchestration, an agent's PR body commonly narrates the predecessor issue … so body-first routing misattributes the screenshot").

Traced across the stack, no source file outside its own module references it at s5, s6 or s7. Its only consumer — github-webhook-processor.ts — appears at #662. So this function ships dormant for three slices with a rationale that references behaviour not yet present.

Not harmful, but it belongs next to the caller that motivates it. Same class as the ADR-016 citations in #654 and the orchestration-reconciler-correctness.md references in #659: see the stack-level note about artifacts landing on the wrong side of a boundary.

3. MINOR — a raw internal UUID can surface to a user on the label fallback path

In orchestration-rollup.ts (added at #659, but the pattern originates in this slice's channel/panel design), the child label falls back through display_id ?? title ?? sub_issue_id. When a child has neither a display_id nor a title, the user-visible line renders the raw internal id:

- ✅ issue-uuid-1 — succeeded
- ❌ issue-uuid-2 — failed

I produced that by executing the renderer, not by reading it. The specific function it appears in is dead code (see the #659 review), but the same fallback shape is used by the live panel, so it is worth checking there before activation. Prefer a human-meaningful fallback ("a sub-issue") over leaking a UUID.


Verified clean

  • No unbounded Scan. There is no ScanCommand anywhere in the new orchestration modules. All three QueryCommand call sites in orchestration-store.ts carry LastEvaluatedKey/ExclusiveStartKey handling — the DAG/row store is properly paginated. (The one unpaginated Query in the stack is sumIterationCostForIssue, addressed in the feat(carve S7): the orchestration compute plane — reconciler, release, rollup #659 review.)
  • No IAM in this slice — it adds no construct that is instantiated, so no grants take effect.
  • Channel abstraction is well tested. orchestration-channel.test.ts exercises the adapters directly — 17 tests for the Linear adapter, 5 for the capability-limited Jira surface. My initial pass flagged orchestration-channel-linear.ts as untested based on the absence of a same-named test file; that was wrong — the coverage is consolidated into orchestration-channel.test.ts. Noting it because a filename-based coverage check gives a misleading picture of this slice.

Reviewed with Claude Code. Verification: tsc --noEmit on this slice's own base; all 29 deletions checked for lost exports against main; dormancy established by import-graph + construct-instantiation grep at each slice tip rather than via the env-var gate; linear-feedback.ts's new call sites traced to their callers; test-file counts taken from the tree at s4 and s5.

Note: this review was written directly rather than by the review agent assigned to it — that agent terminated mid-run without producing a result, so I reviewed the diff myself. Same for #653.

@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from c58d51b to efd1765 Compare July 27, 2026 17:28
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 7af69f3 to 83344ef Compare July 27, 2026 17:28
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from efd1765 to 430576f Compare July 27, 2026 17:51
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 83344ef to cc75f8c Compare July 27, 2026 17:51
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 430576f to 227058d Compare July 27, 2026 18:40
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from cc75f8c to fedea84 Compare July 27, 2026 18:40
@isadeks

isadeks commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed

I had missed this review initially — apologies.

MAJOR #1 — four lint rules switched off across 152 test files to land 13. Fixed, taking your minimum ask. This was my change and your blast-radius point is right: max-len and no-magic-numbers are defensible in test code (literal fixtures are the point), but no-shadow is correctness-adjacent for exactly the reason you gave.

So no-shadow stays enforced, and I fixed the shadows rather than silencing them. It found three across the suite — and every one was the hazard you described, two different shapes under one name:

Any of those three could have had a test asserting against the wrong fixture and still passing. Worth noting the rule only surfaced the third one once the relaxation came off at the tip of the stack, which is itself an argument for your position.

MINOR #2extractLinearIdentifierFromBranch ships three slices before its consumer. Confirmed, not moved. Its consumer is in #662 and moving it there means moving the function and its tests across a boundary for no behavioural gain, whereas the branch-first routing rationale is genuinely about this module's contract. I have recorded it under the stack-level pattern you identified rather than treating it as a one-off — see the note on #659. If you would rather it travel with the caller, it is a clean move.

MINOR #3 — raw internal UUID on the label fallback path. Confirmed the same display_id ?? sub_issue_id shape exists on the live renderEpicPanel path, not just the dead one. Left as-is for now: a row carrying neither a display id nor a title is a data defect in its own right, and substituting something friendlier there would hide it. Noted rather than closed.

Gates: 2920 CDK tests, tsc/eslint clean with no-shadow on.

@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 2a8e7b2 to dadf00f Compare July 28, 2026 01:42
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from fedea84 to 99f1007 Compare July 28, 2026 01:42
@isadeks

isadeks commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Second-round review: the shadow renames hold up

A second adversarial pass attacked this fix specifically. Nothing broke, which is worth recording as explicitly as a defect would be.

The regex-driven renames are clean. The whole delta is the three intended call sites plus the definition and the deleted duplicate helper — no property access, no string literal, and no row(/child( touched outside the target block. The file-level child (a SubIssueNode) keeps all ~40 of its call sites; childRow is confined to its own describe block.

The renamed tests still assert the real thing. The reviewer mutation-tested them three ways — removing the legacy-name fallback, breaking toChildRow alone, and inverting the name precedence — and each killed the tests with the expected/received values. So there is no wrong-fixture hazard hiding behind the rename, which was the specific risk no-shadow exists to catch.

no-shadow is genuinely enforced, verified via eslint.calculateConfigForFile rather than by reading the config: @typescript-eslint/no-shadow = [2] for test/**, no-magic-numbers correctly off in test and on in src, no second override block and no inline eslint-disable in the changed files.

Worth noting the rule earned its keep immediately: re-enabling it surfaced a third shadow in #659 (a local row building an EpicPanelRow shadowing a file-level row building an OrchestrationChildRow) that only appeared once the relaxation came off at the tip of the stack. Three shadows, every one two different shapes under a single name.

The two MINOR findings stand as noted, unchanged: extractLinearIdentifierFromBranch ships three slices before its consumer, and the display_id ?? sub_issue_id fallback can surface a raw UUID on the live panel path. Both recorded rather than closed — the first is a boundary-placement judgement, and the second is a data defect in its own right that a friendlier fallback would hide.

@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from dadf00f to 1a3f6bf Compare July 28, 2026 12:17
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 99f1007 to 3c02a74 Compare July 28, 2026 12:17
@isadeks
isadeks marked this pull request as ready for review July 28, 2026 12:53
@isadeks
isadeks requested review from a team as code owners July 28, 2026 12:53

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — carve S5: orchestration DAG core, row store, channel abstraction (dormant)

Reviewed as a Principal AWS Solutions Architect, as this PR's own incremental diff only (carve/s4-linear-surface..carve/s5-dag-core-channel, 35 files, +8748/−29). This is a stacked PR; its base (#655) is reviewed separately and its code is out of scope here.

1. Verdict — Approve (with two non-blocking comment-accuracy nits)

This is exceptionally well-engineered, purely-additive foundation code: pure graph logic (Kahn's-algorithm DAG validation + layering), a DynamoDB row store, and a surface-agnostic Channel abstraction with Linear/Jira/Slack adapters. Every module is fail-closed where it matters, best-effort where feedback is advisory, and carries a rationale comment for each non-obvious decision. It is genuinely dormant and deploy-safe. The two findings are stale doc comments on dormant code — not worth blocking a merge.

2. Vision alignment

Strongly aligned. Bounded blast radius: fetchSubIssueGraph fails loud on an over-100 epic rather than silently truncating children (and dropping their dependency edges → out-of-order release) — the correct choice for the orchestration tenet. Fire-and-forget preserved: the comment-trigger parser has a robust self-loop guard (bot-authored-prefix detection) that prevents the agent talking to itself, and a deliberately-narrow near-miss allowlist so a mistyped handle nudges rather than silently drops. Reviewable outcomes: orchestration-log-events.ts centralizes greppable event names as an explicit test contract for an event-driven plane with no synchronous API. Backing issues #247 and #299 both carry approved + P0; tracking epic #668 documents the slicing.

3. Blocking issues

None.

4. Non-blocking suggestions / nits

  1. orchestration-integration-node.ts:79 — the withIntegrationNode doc says the synthetic id is `<orchestrationId>#integration`, but the code (line 53/92) produces `<orchestrationId>__integration` via INTEGRATION_NODE_SUFFIX. Worse, the # form is the exact idempotency-key-invalid shape that lines 46-52 explicitly warn against (createTaskCore validates ${orch}_${sub} against /^[a-zA-Z0-9_-]{1,128}$/, and a # would 400 the child). The code is correct; the comment misdirects toward a broken value. Fix the comment to __integration.
  2. orchestration-store.ts:836loadOrchestration's doc says it "mirrors findOrchestrationIds's Scan pagination," but no findOrchestrationIds exists at this slice (only findOrchestrationChildByBranch). It's a forward-reference to a later slice; a reader who greps for it finds nothing. Either drop the reference or note it lands later.
  3. (pre-noted by author, agree) extractLinearIdentifierFromBranch (linear-issue-lookup.ts) ships three slices before its only consumer (#662). Clean, but its motivating rationale references behaviour not yet present. Boundary-placement judgement; fine to leave.
  4. (pre-noted by author, agree) the display_id ?? sub_issue_id label fallback can surface a raw internal UUID to a user. On dormant panel code here; a friendlier fallback would arguably mask a data defect, so recording rather than fixing is defensible.

5. Documentation

No user-facing docs, ADRs, or Starlight mirror needed — this slice deploys no resource and changes no runtime behaviour or contract. The changes to live files (linear-issue-lookup.ts, linear-feedback.ts, slack-api.ts) are additive exports plus a docs-only ABCA-ENG- example rename (the right direction for a public repo). No doc drift.

6. Tests & CI

Strong. 13 new test files, table-driven, covering failure paths (cycles, dangling edges, duplicate ids, truncation-fail-loud, idempotent replay, tenant-collision refusal, diamond/linear/root base selection, self-trigger loop guard, reaction-replace incompleteness). CI build (agentcore) — the full CDK suite + eslint — passed; author reports 2917 tests / 150 suites. no-shadow is correctly kept enforced in tests (the final commit reverted the earlier over-broad relaxation and fixed the 3 real shadows it surfaced).

Bootstrap synth-coverage: N/A. OrchestrationTable is a DynamoDB table (AWS::DynamoDB::Table already in resource-action-map.ts) and is not instantiated in any stack at this slice (verified: no new OrchestrationTable( in cdk/src). No new CFN resource type, no IAM grant takes effect, so no bootstrap-policy / BOOTSTRAP_VERSION bump is required in this PR. Correct to defer to the wiring slice (#662).

7. Review agents run

  • code-reviewer / /code-review (high effort, HEAD~3..HEAD) — RAN. Surfaced the two comment-accuracy nits above; no correctness or style defects.
  • comment-analyzer — RAN (folded into above); the two findings are its output.
  • silent-failure-hunter — RAN (in scope: heavy error handling). The channel adapters and store are best-effort-with-structured-logging by design (feedback must never gate orchestration); fetchSubIssueGraph and linearGraphSource deliberately fail loud rather than degrade. No silent swallowing found.
  • type-design-analyzer — RAN (in scope: many new types). DagValidationResult, FetchSubIssueGraphResult, OrchestrationGraphResult, and Channel are well-formed discriminated unions with fail-closed defaults; ChannelKind/registry are deliberately open to satisfy the extensibility tenet. No concerns.
  • pr-test-analyzer — RAN. Coverage is comprehensive and asserts failure paths, not just happy paths; CI full suite green.
  • /security-review — RAN (in scope: DynamoDB construct, GraphQL Bearer auth, comment input-gateway). No deployed IAM/Cedar/network change (construct un-instantiated); Bearer-token handling and 8s-timeout+AbortController mirror the proven linear-feedback pattern; the comment-trigger self-loop guard and near-miss allowlist are defensive. Orchestration id derivation is documented as non-tenant-scoped but safe for workspace-unique UUIDs, with an explicit OrchestrationIdCollisionError fail-closed at seed for non-unique refs. No secrets exposure.
  • Omitted: none in scope.

8. Human heuristics

  • Proportionality — PASS. The registry-over-switch and three-tier graph-source seam are justified by the extensibility tenet, not speculative; the Slack adapter exists specifically to prove capability-gating against a non-tracker surface. orchestration-store.ts is large (954 lines) but cohesive (one table's surface) rather than accreted.
  • Coherence — PASS. Consistent vocabulary (orchestration_id/sub_issue_id/depends_on) across construct, store, DAG, and channel. The three adapters are parallel with real substance, not copy-paste boilerplate (Jira genuinely omits optional capabilities; Slack genuinely maps thread→issue).
  • Clarity — CONCERN (minor). Two stale doc comments misdescribe real values (integration-node.ts:79, store.ts:836); see nits. Naming and error surfacing are otherwise excellent.
  • Appropriateness — PASS. Integration code is written against the real Linear GraphQL shape and the documented DynamoDB BatchWriteItem 25-item / 1MB-page limits (paginated Query, chunked BatchWrite, meta-row-last partial-failure ordering, TTL-aliased reserved keyword). Tests assert intended behaviour (mutation-tested by the author's second pass), not merely current behaviour.

Reviewed with Claude Code as scottschreckengaust. Dormancy independently verified via import-graph + new OrchestrationTable( grep across cdk/src; the only caller of the new linear-feedback exports is the new dormant orchestration-channel-slack.ts; linear-issue-context-probe.ts references fetchRecentComments only in a doc comment, not a call.

Comment thread cdk/src/handlers/shared/orchestration-integration-node.ts Outdated
Comment thread cdk/src/handlers/shared/orchestration-store.ts Outdated
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 1a3f6bf to 0a2610a Compare July 28, 2026 18:52
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 3c02a74 to e777e0d Compare July 28, 2026 18:52
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from 0a2610a to c0bcadc Compare July 28, 2026 19:35
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from e777e0d to a112305 Compare July 28, 2026 19:35
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from c0bcadc to d884d51 Compare July 28, 2026 21:46
isadeks added 11 commits July 29, 2026 02:49
…lint gate, plan/restack)

Slice 3 of the carve — the full autonomous-agent runtime, landed as ONE unit.
Stacked on S2. Unlike the CDK subsystems, the agent Python evolved as a tightly-
coupled whole: the changes to pipeline.py / repo.py / post_hooks.py / shell.py /
models.py interleave WITHIN shared files (a single pipeline.py carries the no-MCP,
decompose-plan, restack, and clarify-resume hunks). Splitting it along the CDK
subsystem lines produces non-compiling intermediate states, so it lands atomically:
all 27 agent/src modules + their tests + the two workflow definitions.

What it brings:
- Linear no-MCP: strip the Linear MCP server; the platform pre-hydrates comments +
  attachments deterministically instead of the agent fetching them at runtime.
- Per-repo build/lint verification gate before opening a PR (build_command /
  lint_command), with a structured verify outcome (passed / timed-out / infra-failed).
- Stacked-child branch handling + predecessor re-merge (for the sub-issue DAG).
- Agent-native decompose + restack workflows (clone, plan/re-stack, emit artifact).
- Attachment download + screening + version-pinned integrity read.

Behavioral note for reviewers: this also moves the default agent model and a couple
of runtime defaults to their current values — intrinsic to the runtime as it runs
today; called out here rather than split out, to keep the runtime internally consistent.

Gates: agent ruff + ty + full pytest (1421 tests) green. Files are verbatim from
the source branch (combination-verified: 0 diffs vs source). Comment cleanup for
public readability follows as a separate commit on this branch.
…ble-workflow set

The agent-vs-CDK cross-check for the writeable-workflow constant matched
only a single-line `frozenset((...))`. The formatter renders the same
literal across several lines once it grows past the line limit, and the
pattern then found nothing — so the check failed on a purely cosmetic
reformat instead of on a real drift between the two lists.

Allow whitespace between the frozenset call and its inner tuple so the
assertion tests the set's contents, which is what it is for.
…t-side changes

The agent-side files in this slice were taken wholesale from the branch
that carries the orchestration work. That branch predates the Jira
app-identity feature already on the target, so copying its versions over
silently dropped that feature from all 13 agent files — the app-actor
env plumbing, the per-task credential scrub, and their tests — while the
CDK, CLI and Forge halves of the same feature stayed. The result would
have merged as a half-reverted feature.

Reapply the agent-side changes as a three-way merge against the common
ancestor instead of a wholesale copy, so both survive: the app-identity
path and this slice's own agent changes. Verified per file that the
result is exactly "branch version + app-identity delta" with no other
drift, and that a symbol removed on purpose here (the Linear MCP
endpoint) stays removed.

One docstring conflicted, describing how Jira comments get posted; kept
the target's wording since it names the app actor and is now correct.
…e model the agent defaults to

Three fixes from the automated review of this slice, plus its comment cleanup.

**The self-reclone guard let `git clone -b` through.** `-b` is `gh`'s `--body`
short form, so it sits in the free-text-argument list, but it is ALSO
`git clone`'s own `--branch`. The guard truncated the command at the first
free-text flag BEFORE scanning for the clone verb, so the cut landed ahead of
the very verb it was looking for and an ordinary
`git clone -b main <own repo>` was allowed — reopening the stranded-work
failure the guard exists to prevent. Two more shapes slipped through for the
same reason: `gh repo clone -b main <own repo>`, and a clone chained after an
unrelated `-m` (`git commit -m wip && gh repo clone <own repo>`), where the
`-m` belongs to the commit, not the clone.

Now each shell segment is checked on its own, and within a segment the verb is
located first — a verb only counts as prose if a free-text argument opens
before it in that same segment. Prose in a `--body` value is still ignored.

**The agent's fallback model was not in the Bedrock grant list.** This slice
flips the fallback to Opus 4.8 for a repo that pins no model, but the IAM grant
is derived from `DEFAULT_BEDROCK_MODEL_IDS`, and that entry was landing several
changes later. Merging this slice on its own would have produced AccessDenied at
turn 0 on every task with no per-repo model. The grant now travels with the
default, and a drift guard reads the agent's own fallback and asserts the grant
covers it, so the two cannot diverge again.

**Two workflow descriptors arrived here rather than in the base slice**, matching
where their `agent/workflows/**` definitions live. `DESCRIPTORS` is the live
admission table, so an entry without its file means a submission is accepted
with a 201 and then dies when the agent cannot load it.

Also removes private tracker ids and work-item shorthand used as the load-bearing
explanation in comments, docstrings, log messages and test names. The reasoning
is preserved in plain language — a comment that recorded a real defect still
records it, just without an id only this team can resolve.
…to protect

A second review pass on my own fix found that it closed the `-b` bypass while
opening a new one and introducing a false positive. Both are worse than the
original bug in a security guard, so both are fixed and pinned.

**New bypass: a wrapped clone walked straight through.** Splitting on a bare
newline separated the verb from the repo, so `git clone \` + newline + url never
had the slug in the verb's segment. That command was BLOCKED before my rewrite
and ALLOWED after it — including via the `-b` form the rewrite existed to catch.
Backslash-continuations are now joined before scanning, which also covers a
continuation splitting the verb itself (`git \` + newline + `clone`) — the case
that genuinely needs the joining, since no segment handling reunites those.

**New false positive: a multi-line PR body was denied.** A `--body` or `-m` value
spanning lines is how an agent normally writes a PR or commit body, and that text
routinely documents a clone command. Treating `\n` as a command separator put the
quoted line in its own segment with no preceding free-text flag, so a legitimate
`gh pr create` was refused — exactly the false positive the free-text list exists
to prevent. A bare newline is no longer a separator; the real separators still are,
so a clone chained after an unrelated `-m` is still caught.

Also removes `_CLONE_VERB_RE`, which was never load-bearing: across every segment
shape it was the sole matcher zero times, and it actually MISSES a subshell
(`( git clone …`) that the anchored pattern catches. Dead alternatives in a
security path are worse than none.

Both regressions are mutation-verified: restoring `\n` as a separator fails the
multi-line-body test, and dropping the continuation join fails the wrapped-clone
test.
Review found the streamed path returning Popen's raw return code, which for a
signal death is the NEGATIVE signal number (SIGKILL -> -9). The OOM classifier
keys on the shell's 128+signal convention (137), so the two never met.

This is reachable, not theoretical. A verify command only goes through a shell
when it contains shell operators, and `mise run build` has none — so it is
exec'd directly and its signal deaths arrive as -9. Reproduced: is_infra_failure
returns False for -9 and True for 137, on identical input otherwise.

The consequence is the exact mislabel that function exists to prevent. The
container/cgroup OOM-killer writes "Killed process" to the KERNEL log, not the
build's stderr, so an OOM'd build frequently has no stderr signature at all and
the exit status is the only evidence. Left raw, that build is reported as a
genuine failure — telling the user their code is broken when the box ran out of
memory. Given the build tier's default was just lowered, this is the wrong
moment to have OOM detection silently not fire.

Normalized at the boundary where the signal is still visible rather than
teaching each consumer both encodings, so SIGTERM (-15 -> 143) is covered too.

Also replaces `proc.returncode or 0`, which mapped an unreaped None to 0. That
cannot happen after wait(), but success is the one unsafe default for an unknown
outcome, since it would let a build that never ran report as passing. Now -1.

Tests assert 137 reaches is_infra_failure as infra, and that None maps to
non-zero. Verified by restoring the old expression and watching the first fail.
…nd slices

Decomposition is being kept on the development branch as experimental rather than
landed 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 rest of this arc is workstyle-neutral: iterating on a PR,
heartbeats, clarify-resume, and the compute substrate work regardless.

Removes from this slice the workflow YAML, its prompt, its registration in the
prompt map, its DESCRIPTORS admission entry, and the two prompt-injection blocks
that only a planning task used (the warm repo digest and the revise-round
edit-in-place directive, both keyed on channel_metadata nothing else writes).

Two things deliberately KEPT, because they are contracts rather than features:

- The repo-ful artifact branch in the pipeline. It is selected by the workflow
  contract (terminal_outcomes.primary == artifact AND requires_repo), not by a
  workflow id, and no shipped workflow currently takes it. Deleting it would
  remove a declared capability and the failure mode is bad — an empty PR opened
  for a document task, or a build run that was never wanted. Its test now
  synthesizes the workflow by copying the real coding workflow and flipping only
  those two fields, so the test cannot drift from the production contract the way
  a hand-built stub would. Verified by forcing the gate false and watching it fail.
- The trigger-label and :help helpers. Those are label plumbing, not decomposition.

Tests that used the planning workflow purely as a convenient read-only or
non-PR example now use coding/pr-review-v1, which is the surviving read-only
workflow — the properties they assert (a read-only task inherits the repo's
compute substrate; a non-PR workflow still takes the default clone path) are real
and still worth pinning.

Comments across the stack, cluster, orchestrator, and fan-out handlers described
this branch in terms of the planning workflow; they now describe artifact
workflows generally, since that is what the code actually keys on. No decompose
reference survives in this slice's source or tests.
… screening, auth health

Bring the Linear surface's issue-context and operator tooling onto main. All of
this is reachable today through the existing Linear webhook path; none of it
depends on the sub-issue orchestration arc, so it stands alone.

- linear-attachments.ts / linear-issue-context-probe.ts: pull an issue's file
  attachments and recent comments into the task context, so the agent sees the
  screenshots and discussion a human would.
- attachment-screening.ts: screen fetched attachments (images and PDFs) before
  they reach the model. This carries the pdf-parse v2 upgrade as one unit — the
  source uses the v2 `PDFParse` class, which requires pinning the dependency and
  dropping the hand-written module declaration that described the v1 function
  export. Splitting any of the three breaks the build, so they travel together.
- error-classifier.ts: distinguish transient compute faults from user-visible
  build failures, and report whether work was preserved when a run does not
  produce a pull request.
- screenshot-url.ts + github-screenshot-integration.ts: resolve screenshot URLs
  for the review path.
- CLI: `linear-auth-health.ts` + `platform-doctor.ts` surface an expired or
  revoked Linear authorization as a diagnosable condition instead of silently
  dropped events; `linear.ts` / `platform.ts` expose it.

The comments and test names in these files are rewritten to explain the what and
why directly rather than pointing at internal tracker ids, and two CLI strings
that printed an internal issue number to user-facing output are reworded.

Stacked on the agent-runtime slice. Full CDK suite 2598 passing, CLI 675
passing, both type-checks clean.
…pping an inert marker

**A quadratic regex could blow the webhook timeout.** Making the leading `!`
optional in the markdown-link pattern stopped the engine anchoring each attempt
on a literal `!`, so the unbounded label quantifier retried from every `[`.
Measured on a description of unmatched brackets: 4 KB ≈ 7 ms, 12 KB ≈ 56 ms,
50 KB ≈ 940 ms — and around 150 KB exceeds the processor's 30 s timeout. No
crafting is needed; a large pasted table does it, and the existing scan cap does
not bound it because the backtracking happens inside a single `exec()` before any
match is produced. The label quantifier is now bounded, which caps the work per
start position while still matching every real markdown label.

**Two attachments whose names differ only by `.` versus `-` silently became
one.** The upload id collapsed `.`, `-` and `/` onto the same character and then
squashed runs, so `design.v1.png` and `design-v1.png` produced an identical id —
and both de-dupe sites resolve a collision by discarding the second file. A user
who attached both got one, with nothing logged and no warning, and the agent
planned against incomplete material. The id now carries a short digest of the
full pathname, keeping the readable stem for logs.

Both fixes are pinned by tests that were mutation-verified: reverting either
change fails its own test and nothing else.

**The revoked-authorization marker was on by default and could never succeed.**
Every Lambda that resolves a token holds read-only registry access, and no slice
in this arc grants write, so the marker's write failed AccessDenied on every
revoked refresh and the failure was swallowed. The feature read as working while
being permanently inert, which is worse than being visibly absent. It is now
opt-in: a caller that holds the grant passes a recorder explicitly, and the
default is to attempt nothing. When the grant lands, flip the default in that
same change.
…ound lost attachments

A second review pass found my markdown-regex fix was wrong in both directions.

**It silently dropped attachments.** A 300-char label bound makes a link with
longer alt text stop matching entirely, so the file vanishes with nothing logged
and the agent plans against incomplete material. That is the failure this slice
argues elsewhere is the worst kind, and I introduced it while fixing a slow scan.
The bound is now 4096 — far past any real label — and a test pins that a ~780-char
descriptive label still yields its attachment.

**And it did not fix the slow scan.** BOTH variable parts of the pattern
backtrack per start position, and the URL part dominates: on `[](https://a`
repeated, a 100 KB description takes ~820 ms with or without a label bound, and
larger inputs still exceed the webhook timeout. Bounding the label only fixed the
one shape the new test happened to assert.

The real bound is on the input: descriptions longer than 64 KB have their tail
left unscanned, and the truncation is LOGGED rather than passed over in silence.
That fixes every hostile shape at once.

Both are mutation-verified — restoring the tight bound fails the long-label test,
removing the cap fails the URL-shape test.

Two timing assertions are replaced with assertions on the observable work (the
truncation warning). A wall-clock budget inside a parallel suite measures CPU
contention as much as the code: both passed alone and failed under full-suite
load, which would have been a flaky gate rather than a guard.
…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.
@isadeks
isadeks force-pushed the carve/s4-linear-surface branch from b2e4ac2 to 5d3612a Compare July 29, 2026 01:54
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 6532a1d to decd661 Compare July 29, 2026 01:58
`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.
@isadeks

isadeks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Scope change: auto-decomposition is no longer part of this carve

Heads-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 main. It presupposes one way of working: an issue split into a sub-issue graph, with a human approval gate on a proposed plan. main should not presuppose that. Everything else in this arc is workstyle-neutral — running a graph a human already authored, iterating on a PR from a comment, heartbeats, clarify-resume, the Channel abstraction, the compute plane — and those all stay.

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.

slice effect
#647 S1 unchanged
#653 S2 comments now describe artifact workflows by their contract, not by one workflow
#654 S3 workflow YAML, prompt, and admission entry removed
#655 S4 --decompose-allowed / --max-sub-issues / --max-parent-budget-usd removed from linear onboard-project
#656 S5 note-sweep renamed for what it does
#657 S6 8538 → ~2000 lines. Now purely the iteration slice; retitled
#659 S7 graph CREATION removed from the reconciler; graph EXECUTION kept
#662 S8 the plan-proposal surface removed; webhook 4502 → 3156 lines

Three things worth knowing as a reviewer

Some of the review fixes from the last round are gone with the feature. The verdict-marker fix (B1 on #662) and the retry-hint trigger_label fix (#659) were both decomposition-only. They were real fixes and they live on the development branch — they are not being dropped as wrong.

Two things looked decomposition-only and were not. DEFAULT_LABEL_FILTER and hasHelpLabel lived in a decompose-named module but are plain label plumbing, so they moved to trigger-label.ts rather than being deleted. And the note sweep is prefix-based and general, so it was renamed, not removed. Both would have been silent breakage.

Removing the feature exposed dead IAM. The reconciler held an s3:GetObject on the artifacts prefix plus put/delete on the attachments bucket, all for plan seeding. They had no caller left. Removed — a net least-privilege gain, and there is now a test asserting the reconciler has no object grant at all, which is the stronger claim since S3 does not normalize keys.

Verification

  • All 8 slices CI-green, chain integrity confirmed (each slice's base is its parent's tip)
  • 3436 CDK / 681 CLI / 1436 agent tests at the tip
  • Zero decompose references in every slice individually, not just at the tip — an earlier pass looked clean at the tip while intermediate slices still carried them, which would have landed decompose text on main temporarily during the serial merge
  • Deployed to dev and live-verified by artifact: the deployed webhook bundle contains no plan surface; coding/decompose-v1 is now rejected at admission (Unknown workflow_ref, 400); a normal coding task still runs end to end (PR opened with the requested file, exact contents); coding/pr-review-v1 still admits; the reconciler's IAM shows no S3 statements

isadeks added 4 commits July 29, 2026 16:30
…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.
@isadeks
isadeks force-pushed the carve/s5-dag-core-channel branch from 2fd6671 to 8633acf Compare July 29, 2026 15:32
Base automatically changed from carve/s4-linear-surface to carve/s1-foundation-contracts July 29, 2026 18:02
@isadeks
isadeks merged commit 86190c3 into carve/s1-foundation-contracts Jul 29, 2026
5 checks passed
@isadeks
isadeks deleted the carve/s5-dag-core-channel branch July 29, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants