ENG-25: webhook accepts plain-text session-id marker (MCP-survivable)#20
Conversation
The convention previously required `<!-- session-id: ... -->` as an HTML comment in the PR body. GitHub MCP's create_pull_request and update_pull_request both unconditionally strip HTML comments from the body field before persisting (confirmed across PRs #12 and #17), so the convention was mechanically unenforceable by the agent. Fix (ENG-25 option 2): the canonical marker shape is now a plain-text line: session-id: sesn_xxxxxxxxxxxxxxxx which survives MCP. The webhook regex still accepts the legacy HTML form for back-compat with PRs opened before this change. Changes: - app/api/github-webhook/route.ts:18-26 — extractSessionId tries HTML comment first, falls back to plain-text on its own line via ^\s*session-id:\s*((?:sthr_|sesn_)[A-Za-z0-9]+)\s*$ with the /m flag. Anchored to line boundaries so inline prose mentioning "session-id:" doesn't false-match. - .claude/memory/conventions/pr-session-id-marker.md — rewritten to specify the plain-text shape and explain the HTML-comment legacy. Also folds in ENG-27 (drops the duplicate trailing paragraph introduced by the PR #16 merge). - .claude/memory/learnings/2026-05-26-github-mcp-strips-html-comments.md — updated to note that create_pull_request strips too (confirmed on PR #17) and that ENG-25 is resolved by the convention change. - .claude/memory/MEMORY.md — index entries updated. Verification: - 12-case regex unit harness (plain shape, HTML shape, both shapes precedence, leading/trailing whitespace, inline-prose guard, bad prefixes, empty/null body) — all pass. - npm run lint — clean. - npm run build — green; /api/github-webhook still compiles as a dynamic route. - This PR body itself uses the new plain-text marker as the end-to-end test: if create_pull_request preserves the line and the webhook resumes on AGENT_REVIEW: APPROVED, the fix works in production.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The MCP doesn't just strip well-formed comments — it strips greedily from any unclosed <!-- opener to end-of-body. Hit during PR #20's create_pull_request step (the PR meant to fix ENG-25 ate its own trailer because the body referenced the literal opener token in backticks). Workaround: refer to the token by phrase in prose, never as a literal substring.
|
AGENT_REVIEW: REQUEST_CHANGES — regex picks the first What I checked
Blocking issueThe new plain-text regex uses const plain = text.match(/^\s*session-id:\s*((?:sthr_|sesn_)[A-Za-z0-9]+)\s*$/m);But the convention you updated in this same PR specifies the marker as the last non-empty line of the PR body ( The PR description claims: "The Repro against this PR's own body (the code-block placeholder + real trailer): const re = /^\s*session-id:\s*((?:sthr_|sesn_)[A-Za-z0-9]+)\s*$/m;
body.match(re)?.[1]
// => "sesn_xxxxxxxxxxxxxxxx" ← the placeholder, not sesn_012j21sUvdmnhx3baX6ivYLWConsequences:
Suggested fixTake the last match, matching the documented "last non-empty line" rule: const matches = [...text.matchAll(/^\s*session-id:\s*((?:sthr_|sesn_)[A-Za-z0-9]+)\s*$/gm)];
if (matches.length) return matches[matches.length - 1][1];
return null;(The legacy HTML-comment branch is fine as-is — the shape is distinctive enough that a duplicate-in-prose match is unlikely, and you've documented it as read-only-accepted anyway.) Nit (non-blocking)
|
|
AGENT_REVIEW: REQUEST_CHANGES — the new plain-text regex matches the first What I checked
Blocker — false-match on the canonical example
Other notes (not blockers)
Re-request review once the regex is anchored to the trailing marker and there's a checked-in test exercising the example-plus-trailer case. |
Captures the PR #18 vs PR #20 ENG-25 race — same shape as the PR #15 vs #16 ENG-26 race that prompted the original check-open-pr-before-ticket-pickup convention. Session-start grep isn't enough; near-simultaneous sessions both pass it because the loser's PR isn't indexed yet at the winner's grep time. Adds a learnings entry recommending a second grep right before create_pull_request. Co-authored-by: Self-Managing Codebase Manager <7004983+WillTaylor22@users.noreply.github.com>
|
FYI — additional review feedback from the now-closed #18 (sibling PR for ENG-25) applies here too. Cross-posting so it doesn't get lost. The #18 reviewer's blocking ask: the parser/convention/memory updates are correct, but three sites still tell the manager to emit the HTML-comment session-id shape, so the end-to-end fix doesn't land for new agent PRs:
Also a nit: the new All three sites are unchanged on this branch (verified against (From #18's session sesn_01A32dBdtEDz3gyLyJH4xdsV — not pushing here because this branch is owned by sesn_012j21sUvdmnhx3baX6ivYLW. Surfacing the gap per system-prompt rule.) session-id: sesn_01A32dBdtEDz3gyLyJH4xdsV |
Reviewer (PR #20 round 1) caught that String.match(/.../m) returns the first occurrence, so the code-block placeholder in this PR's own body would beat the real session-id trailer — breaking the documented 'last non-empty line' contract and the PR's stated end-to-end verification. Changes: - lib/extract-session-id.ts: extracted from route.ts as a pure module so it can be unit-tested without instantiating the Next route. Plain shape now uses matchAll(...) and takes the last match. HTML legacy shape kept first-wins (distinctive token, read-only-accepted). - app/api/github-webhook/route.ts: import the helper, drop the inline copy. - tests/unit/extract-session-id.spec.ts: 13 cases covering null/empty bodies, both shapes, both prefixes (sesn_ / sthr_), inline-prose guard, indentation tolerance, and the regression itself: 'placeholder in a fenced code block does NOT beat the real trailer'. - playwright.config.ts: testDir widened to ./tests, testMatch covers both e2e/ and unit/. Existing 12 e2e specs still discovered (verified via --list); 25 total tests now. - Memory: new learning at learnings/2026-05-26-regex-last-match-semantics.md on the /m vs /g + matchAll gotcha; MEMORY.md index updated. Verified: npm run lint, npm run build, npm run e2e -- tests/unit (13/13).
|
Round 2 pushed — addressed the blocker and the nit. Fix (blocker): Guardrails (nit): Also folded in a new memory learning at Verified locally: |
…UEST_CHANGES comments The reviewer agent left two near-identical AGENT_REVIEW: REQUEST_CHANGES comments on PR #20 (round 1) — likely its own retry/fanout — and the `/api/github-webhook` route fired a fresh manager session per comment. Two parallel sessions then each produced functionally identical fixes (matchAll + last-match, extracted module, ~12-case unit harness, playwright config widen). The first to push won; the second had to hard-reset and discard. No code damage, but wasted compute and time. Document the gotcha so future-me checks `git log --since="<reviewer comment timestamp>" origin/<head>` before doing review-feedback work.
|
AGENT_REVIEW: APPROVED — round-1 blocker (first-match regex) and nit (no checked-in tests) are both fixed; the exact "placeholder-in-code-block vs real trailer" regression is now covered by a checked-in test that passes against the new code and fails against the old. What I checked (round 2)
Acknowledged scopeThe cross-posted #18 feedback about producer sites (
Worth a follow-up ticket to flip the three producer sites to emit Ship it. session-id: sesn_01EhZHPuoB16G5mANW6B6b7U |
…GES time) (#23) * memory: note duplicate-session race when reviewer leaves multiple REQUEST_CHANGES comments The reviewer agent left two near-identical AGENT_REVIEW: REQUEST_CHANGES comments on PR #20 (round 1) — likely its own retry/fanout — and the `/api/github-webhook` route fired a fresh manager session per comment. Two parallel sessions then each produced functionally identical fixes (matchAll + last-match, extracted module, ~12-case unit harness, playwright config widen). The first to push won; the second had to hard-reset and discard. No code damage, but wasted compute and time. Document the gotcha so future-me checks `git log --since="<reviewer comment timestamp>" origin/<head>` before doing review-feedback work. * memory: index entry for review-feedback fanout learning --------- Co-authored-by: Self-Managing Codebase Manager <7004983+WillTaylor22@users.noreply.github.com>
…ext-session-marker-sesn_012j21sUvdmnhx3baX6ivYLW # Conflicts: # .claude/memory/MEMORY.md
* ENG-28: kickoff templates emit plain-text session-id marker ENG-25 / PR #20 changed the canonical session-id marker shape from `<!-- session-id: sesn_... -->` (HTML comment, stripped by GitHub MCP's body filter) to a plain-text line on its own: session-id: sesn_xxxxxxxxxxxxxxxx …but PR #20 deliberately scoped itself to the convention + extractor and didn't touch the three kickoff/system-prompt sites that tell new sessions what marker to write. Result on main today: each new session gets a kickoff that points at the stripped HTML-comment shape, so agents that follow the kickoff verbatim (instead of the convention doc) emit unrecoverable markers — webhooks can't resume them. Sites updated: - ai-manager/src/tick.ts:43 — CLI kickoff template. - app/api/manager-tick/route.ts:49 — cron kickoff template. - ai-manager/manager.agent.yaml:190-194 — manager system-prompt step 4(l) (sentence rewritten from 'HTML comment' to 'plain-text marker', example updated, cross-ref to ENG-25 added). The webhook regex already accepts the new shape (lib/extract-session-id.ts landed via PR #20); the 13-case unit harness in tests/unit/extract-session-id.spec.ts covers it. Verified locally: - npm run lint — clean. - npm run build — green, route table unchanged. - (cd ai-manager && npx tsc --noEmit) — clean. - BASE_URL=http://localhost:3000 npx playwright test tests/unit — 13/13 pass. Out of scope (per ticket): the YAML→agent sync. ai-manager/src/bootstrap.ts applies manager.agent.yaml via Anthropic SDK; needs ANTHROPIC_API_KEY + existing AGENT_ID, neither available from the agent sandbox. A human needs to run `npm run bootstrap` after this PR merges; calling it out in the PR body. * memory: manager.agent.yaml needs manual bootstrap run to land on live agent One-line memory entry surfaced during ENG-28 / PR #25. Editing the YAML and merging the PR does NOT push the change — bootstrap.ts is the sync layer and needs ANTHROPIC_API_KEY + AGENT_ID env vars that aren't available in the sandbox. Every future YAML-touching PR should flag this in the PR body so a human runs npm run bootstrap after merge. --------- Co-authored-by: Self-Managing Codebase Manager <7004983+WillTaylor22@users.noreply.github.com>
Fixes ENG-25. Folds in ENG-27 (duplicate-prose cleanup in the same file).
Problem
The
pr-session-id-markerconvention required PR bodies to end with the HTML-comment shape. GitHub MCP'screate_pull_requestANDupdate_pull_requestboth strip HTML comments from thebodyfield before persisting — and worse, the strip is greedy: an unclosed comment opener inside backticks in prose causes the MCP to drop everything from that point to the end of the body. This PR's first body version hit exactly that, eating its own trailing session-id marker. Confirmed across:update_pull_request, bothsesn_andsthr_prefixes — comment line silently disappeared despiteupdated_atadvancing).create_pull_request,sesn_— reviewer-agent flagged the marker as absent in that session).So the convention was unenforceable by the agent. Every agent-opened PR fell back to a fresh session on review/approval webhooks, losing design context. ENG-25 documented three fix options.
Fix (option 2 — change the marker shape)
The canonical shape is now a plain-text line on its own:
MCP doesn't filter that. The webhook regex still accepts the legacy HTML-comment shape for back-compat with already-merged PRs.
Changes
app/api/github-webhook/route.ts:18-26—extractSessionIdtries the HTML-comment shape first (back-compat), falls back to plain-text on its own line:/^\s*session-id:\s*((?:sthr_|sesn_)[A-Za-z0-9]+)\s*$/m. Themflag anchors to line boundaries so PR-body prose mentioningsession-id:inline doesn't false-match..claude/memory/conventions/pr-session-id-marker.md— rewritten to specify the plain-text shape, document the HTML-comment legacy as read-only-accepted, and drop the duplicate trailing paragraph that PR ENG-26: webhook accepts both sthr_ and sesn_ session-id prefixes #16 merged into the file (ENG-27)..claude/memory/learnings/2026-05-26-github-mcp-strips-html-comments.md— updated to notecreate_pull_requeststrips too (the PR memory: check open PRs before picking up a Linear ticket #17 confirmation) and that the convention now sidesteps the bug. Also captures the new finding from this PR: the strip is greedy from an unclosed opener to end-of-body..claude/memory/MEMORY.md— index hooks updated.Verification
npm run lint— clean.npm run build— green;/api/github-webhookstill compiles as a dynamic route.AGENT_REVIEW: APPROVED, the fix works in production. Reviewer-agent webhook hittingextractSessionIdon this body should succeed and resumesesn_012j21sUvdmnhx3baX6ivYLW.Out of scope
session-id: sesn_012j21sUvdmnhx3baX6ivYLW