Skip to content

Retry a failed pull request or issue reply - #78

Open
maxime-pharmania wants to merge 1 commit into
seoes:mainfrom
pharmania:fix/retry-failed-reply
Open

maxime-pharmania wants to merge 1 commit into
seoes:mainfrom
pharmania:fix/retry-failed-reply

Conversation

@maxime-pharmania

@maxime-pharmania maxime-pharmania commented Oct 10, 2026 •

Copy link
Copy Markdown

A failed reply never offered Retry. The retry endpoint only accepted pr_review and issue_open, and the activity row did not keep the comment the reply answered, so a reply had nothing to replay.

  • new nullable activity columns target_comment_id and target_inline_review_id (migration 0046), filled by the GitHub, GitLab and Forgejo webhook handlers when they start a pr_reply or issue_reply
  • retry resolves the target comment right after the type check, before the repository, provider and sender setup, so a comment-less reply is refused with a 400 instead of failing inside the provider call. It then replays runPullRequestReply with the stored inline review id, or runIssueReply
  • the boot repair reads the comment id back from the agent log of a reply created before this change, so an old failed reply becomes retryable too. Only the conversation fetch tools are read, an inline review keeps its discussion id out of the log
  • the review page shows Retry for both reply types, with its own confirmation text
  • demo fixtures carry a target comment so the button also shows in demo mode
  • activity-retry.test.ts covers both reply types, the issue open path, the missing comment case and the log recovery

Tests. bun run --filter api test 58 pass, bun run --filter client test 9 pass, bun run typecheck 0 errors. A deployment needs bun run migrate for the two new columns.

Migration number. It takes 0046, the next number on main. #71 also carries a 0046, so the one that lands second takes 0047.

@maxime-pharmania
maxime-pharmania force-pushed the fix/retry-failed-reply branch 2 times, most recently from 9c4d5b0 to 8db8a15 Compare October 10, 2026 13:22

if (activity.type === "pr_reply" || activity.type === "issue_reply") {
const targetCommentId = activity.targetCommentId;
if (targetCommentId == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Reply-target validation runs after provider setup, so doomed retries can 500 instead of 400

⚫ The null-comment guard at apps/api/src/api/activity/activity.service.ts:668 runs only after createGitProvider (line 619) and the rest of the provider/sender setup complete. For GitHub repositories, createGitProvider performs a network round-trip (octokit.request("GET /repositories/:repository_id") in repository.service.ts:372-376) and throws if the installation row is gone — so retrying a comment-less reply (any pre-0047 reply row with NULL target_comment_id) can fail during setup with an unmapped error and surface as a 500 instead of the 400 this PR adds to RETRY_CLIENT_ERRORS (activity.controller.ts:111). Even in the happy path, every doomed retry burns a full provider setup plus a GitHub API call just to be rejected.

Consider moving the check to right after the RETRY_TYPES validation (before repository/provider/workspace/sender setup):

if (activity.type === "pr_reply" || activity.type === "issue_reply") {
    if (activity.targetCommentId == null) {
        throw new Error("This activity has no comment to reply to");
    }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in ca923cd. The check now runs right after the RETRY_TYPES guard, before createGitProvider, and it resolves the value once so the reply dispatch keys on it.

const replyTargetCommentId =
    activity.type === "pr_reply" || activity.type === "issue_reply"
        ? requireReplyTargetComment(activity)
        : null;

The dispatch block became if (replyTargetCommentId != null), so the refusal has a single path and the later pr_review / issue_open flow is untouched.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 10, 2026

Copy link
Copy Markdown

Thanks for wiring reply-target persistence through this PR end to end — schema, all three webhook providers, retry, client gating, and demo fixtures move together, which makes the feature feel cohesive rather than bolted on.

a. Summary

This PR persists targetCommentId / targetInlineReviewId on reply activities (additive migration 0047), extends activity retry to cover pr_reply and issue_reply with a 400-mapped refusal for comment-less rows, gates the client retry button and confirm text on the new fields, and adds an isolated integration test for the retry path. The main gap is validation ordering in retry() — the null-comment guard runs after provider setup, so the intended 400 isn't guaranteed and doomed retries do unnecessary network work. Everything else checked out: the migration is purely additive, the before/after test fixture columns match exactly, and all seven reply-start call sites store the same id they hand to the runner.

b. Good points

  • 🟢 The retry test spawns an isolated child bun test process before registering its mock.module overrides (apps/api/src/api/activity/activity-retry.test.ts:6), preventing bun's process-global module mocks from leaking into sibling API tests.
  • 🟢 canRetry mirrors the server-side refusal exactly — review.targetCommentId != null for reply types (apps/client/src/routes/review/[id]/+page.svelte:46) matches the throw in activity.service.ts:668, so the UI never offers a retry the API will reject.
  • 🟢 All three providers persist the identical variable they pass to the runner — e.g. GitHub stores comment.id + inlineReviewId in ActivityStartInput and hands the same values to runPullRequestReply (apps/api/src/webhook/github/github.controller.ts:525), so retried activities rebuild the same call as the original run.

Main Issues

  • ⚫️ Reply-target validation runs after provider setup (apps/api/src/api/activity/activity.service.ts:668): the null-comment guard sits after createGitProvider at line 619, which for GitHub does a network round-trip and can throw first — turning an intended 400 into a 500 and burning an API call on every doomed retry. Moving the guard to just after the RETRY_TYPES check fixes it (inline comment has the patch).

Additional Notes

  • apps/api/src/api/activity/activity-retry.test.ts:174, consoleError.mockRestore() executes at module-evaluation time, before any it body runs, so the spy installed at line 45 never actually suppresses output during the tests — restore it in an afterAll hook instead.
  • packages/db/src/migration/meta/0047_snapshot.json:5, prevId points at "10333aa2-...", which exists nowhere in the repo; the real predecessor is 0045's "d54cf2e8-40e0-4bb1-a75f-a8efbc571842" (0045_snapshot.json:4), and there is no 0046 anywhere — the chain looks hand-edited, so consider regenerating or repointing prevId.

@maxime-pharmania

Copy link
Copy Markdown
Author

All three points are addressed in ca923cd, and the two code ones are also on the fork main as 4c12124.

Validation ordering. The null-comment guard now runs right after the RETRY_TYPES check, before the repository lookup, the model provider lookup, createGitProvider, the workspace and the sender. A comment-less reply answers 400 with no provider call, and requireReplyTargetComment is the single place that raises the refusal.

Test spy. consoleError.mockRestore() moved into an afterAll hook, so the spy actually covers the test bodies instead of being torn down at module evaluation.

Snapshot chain. You were right that it looked hand-edited. The 0047 snapshot had been generated on top of an 0046 that only exists on the fork, so its prevId pointed at nothing here and its model_provider table still carried the stream column from that branch. I deleted it and regenerated on this base: 0046_fearless_vermin now sits on 0045's snapshot id and records the schema this branch really has, so drizzle-kit generate stays quiet.

The branch takes 0046 since that is the next number on main. #71 also carries an 0046, so whichever of the two lands second has to renumber.

Comment on lines +267 to +269
)
WHERE type IN ('pr_reply', 'issue_reply')
AND target_comment_id IS NULL

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Legacy recovery over-counts no-op rows and rescans them on every boot

The outer WHERE here matches any pr_reply/issue_reply row with a NULL target_comment_id and valid logs — including rows whose SET subquery resolves to NULL (inline-review replies and truncated logs), which activity-retry.test.ts:207-231 proves stay NULL forever. SQLite counts those no-op matches in changes(), so result.changes (line 271) and the startup log at apps/api/src/index.ts:53-55 print a false "Recovered N legacy reply comment link(s)" on every restart, and each boot re-runs the json_each scan over those permanently unrecoverable rows' full logs before the server accepts traffic.

Add an EXISTS guard mirroring the subquery's conditions so only rows with an extractable commentId are updated and counted:

WHERE type IN ('pr_reply', 'issue_reply')
  AND target_comment_id IS NULL
  AND json_valid(logs)
  AND EXISTS (
    SELECT 1 FROM json_each(activity.logs) AS entry
    WHERE json_extract(entry.value, '$.type') = 'tool-call'
      AND json_extract(entry.value, '$.toolName') IN ('get_pull_request_comment', 'get_issue_comment')
      AND json_valid(json_extract(entry.value, '$.message'))
      AND json_extract(json_extract(entry.value, '$.message'), '$.commentId') IS NOT NULL
  )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1c30c07, with the EXISTS guard you proposed so the update only touches rows whose subquery can actually fill the column. The test now pins the count instead of a lower bound: one recoverable row returns 1, and the pair of unrecoverable rows (inline review reply, truncated log entry) returns 0 where it used to count 2.

"autoincrement": false,
"default": 600
},
"stream": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Snapshot contains a model_provider.stream column that exists nowhere else

This stream column (integer, notNull, default true) exists nowhere else in the repository: schema.ts has no stream field and no migration SQL in packages/db/src/migration/*.sql ever adds it — the only match in the repo is this snapshot entry. The snapshot's prevId at line 5 (10333aa2-...) also matches no snapshot id in the repo (0045's id is d54cf2e8-...), so this snapshot was generated on top of a 0046 that was dropped from the branch.

Impact: the snapshot is the baseline for the next drizzle-kit generate run, which will diff schema.ts against it and emit ALTER TABLE model_provider DROP COLUMN stream. No real database has that column, so applying the generated migration fails ("no such column") and blocks the startup migration (apps/api/src/index.ts:32). Fix: regenerate 0047_snapshot.json from the current schema.ts so it equals 0045 plus exactly the two new activity columns — i.e. remove this entry and repoint prevId to d54cf2e8-40e0-4bb1-a75f-a8efbc571842.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This one was already fixed before this review landed. Commit ca923cd dropped that snapshot, and the branch now carries 0046_fearless_vermin generated on this base, so its prevId is 0045's id and it records the schema the branch really has, without stream. bun run --filter api generate answers No schema changes, nothing to migrate on the current head, so the DROP COLUMN you describe cannot be emitted.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 10, 2026

Copy link
Copy Markdown

Thanks for this PR — extending retry to pr_reply/issue_reply with persisted reply targets, a legacy log-recovery path, and client-side gating is a well-scoped feature, and the new integration suite pins the contract nicely.

Overview

Summary

This PR adds target_comment_id / target_inline_review_id columns to activity (migration 0047), captures them at all seven webhook intake sites, replays them on retry, recovers legacy rows from stored agent logs at startup, and gates the client Retry button on a recoverable target. The feature path is solid end-to-end; the two issues below are both in generated/edge-path code and are straightforward to fix.

Good points

  • 🟢 apps/api/src/api/activity/activity-retry.test.ts:139 — covers PR-reply replay (with inlineReviewId propagation), issue-reply replay, the issue_open regression path, the missing-comment guard, and legacy recovery negatives, all against a real migrated SQLite DB with mock.module isolated in a spawned child process (lines 9-19).
  • 🟢 apps/api/src/api/activity/activity.controller.ts:111 — the new guard string is registered in RETRY_CLIENT_ERRORS with the exact spelling thrown at activity.service.ts:696, so a retry on an unrecoverable legacy reply returns an actionable 400 instead of a 500.

Main Issues

  • ⚫️ packages/db/src/migration/meta/0047_snapshot.json:770 — snapshot declares a model_provider.stream column that exists in neither schema.ts nor any migration SQL; the next drizzle-kit generate will emit a DROP COLUMN migration that fails on every real database and blocks the startup migration (inline comment above).
  • ⚫️ apps/api/src/api/activity/activity.service.ts:267 — the legacy-recovery outer WHERE matches rows whose subquery yields NULL, so result.changes over-reports and the startup log prints a false "Recovered N" on every boot while unrecoverable rows are re-scanned each restart (inline comment above).

Additional Notes

  • packages/db/src/migration/meta/_journal.json:328 — the journal skips from idx 45 to 47 with no 0046 anywhere in the repo; worth confirming no sibling PR owns migration 0046 before merge, and noting the reserved gap if intentional.
  • apps/api/src/webhook/gitlab/gitlab.controller.ts:310 — isInlineReviewComment ? inlineReviewId : null is now written twice (stored target and live run arg); hoisting it to a single const would keep the retry target and the original run guaranteed identical.

@maxime-pharmania

Copy link
Copy Markdown
Author

Round 2 handled in 1c30c07.

Recovery over-count. Valid, and the only finding still live against the head. The outer WHERE matched no-op rows and SQLite counts them, so the boot line could claim a recovery for an inline review reply or a truncated log entry, and rescanned them on every restart. The EXISTS guard is in, with the same conditions as the subquery, and the test asserts the exact count on both sides.

Snapshot and the journal gap. Both belong to the previous head. ca923cd deleted the fork-generated snapshot and regenerated on this base as 0046_fearless_vermin, prevId on 0045, no stream column, so drizzle-kit generate now reports no schema changes and the journal runs 45 then 46 with no gap. The reserved-gap note still applies in one direction: #71 carries an 0046 of its own, so whichever of the two lands second renumbers.

GitLab double expression. Hoisted, inlineReviewId is resolved once as isInlineReviewComment ? discussion_id : null and the stored target and the live run arg now read the same const.

END
FROM json_each(activity.logs) AS entry
WHERE json_extract(entry.value, '$.type') = 'tool-call'
AND json_extract(entry.value, '$.toolName') IN ('get_pull_request_comment', 'get_issue_comment')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] EXISTS guard and SET subquery disagree, so mixed logs stay NULL forever

The new EXISTS guard (activity.service.ts lines 278-286) only requires that some tool-call entry has an extractable commentId, but the SET subquery above still selects via ORDER BY entry.key LIMIT 1 with no non-null predicate in its WHERE (lines 270-271). When the first matching entry has an unextractable commentId (truncated/invalid message, or valid JSON without a commentId key) and a later entry has a valid one, EXISTS passes but the CASE expression evaluates to NULL, so target_comment_id stays NULL forever and that legacy row can never be retried — even though a recoverable commentId exists in its log.

Because SQLite counts every WHERE-matched row in result.changes, that same row is also counted as "recovered" by the startup log at apps/api/src/index.ts:54 while remaining unrecovered, so it is re-scanned on every boot — the same over-count/rescan class the EXISTS guard was meant to eliminate.

Add the identical non-null predicate to the SET subquery so both select the same row:

SET target_comment_id = (
    SELECT CASE
            WHEN json_valid(json_extract(entry.value, '$.message'))
            THEN json_extract(json_extract(entry.value, '$.message'), '$.commentId')
        END
    FROM json_each(activity.logs) AS entry
    WHERE json_extract(entry.value, '$.type') = 'tool-call'
      AND json_extract(entry.value, '$.toolName') IN ('get_pull_request_comment', 'get_issue_comment')
      AND CASE
              WHEN json_valid(json_extract(entry.value, '$.message'))
              THEN json_extract(json_extract(entry.value, '$.message'), '$.commentId')
          END IS NOT NULL
    ORDER BY entry.key
    LIMIT 1
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right, and fixed in 317144c. Both queries now carry the same non-null predicate, so the update reads the entry the guard accepted instead of the first fetch of the log, and a mixed log is recovered instead of being written as NULL while counted as recovered. activity-retry.test.ts gains that exact case, a truncated first fetch followed by a readable second one, asserted at 1 change and comment id 77.

@proval-0e6d07

proval-0e6d07 Bot commented Oct 10, 2026

Copy link
Copy Markdown

Thanks for the follow-up — every point from the last round is addressed: the reply-target guard now fails fast before provider setup, the phantom model_provider.stream column is gone from the snapshot, the journal is contiguous at 0046, and the GitLab intake now shares one inlineReviewId between persisted target and live run.

Overview

This push renames migration 0047→0046 with a repaired snapshot chain, moves the missing-comment guard to the top of retry(), tightens the legacy-recovery tests to exact counts, and adds an EXISTS guard so unrecoverable rows drop out of the boot scan. One residual mismatch remains between the new guard and the SET subquery (inline comment above); otherwise this looks ready.

Good points

  • 🟢 packages/db/src/migration/meta/0046_snapshot.json:767 — the fabricated model_provider.stream column is removed and prevId is repointed to 0045's id, so the next drizzle-kit generate diffs against the true baseline instead of emitting a spurious drop-column migration.
  • 🟢 apps/api/src/api/activity/activity.service.ts:653 — requireReplyTargetComment now runs immediately after the RETRY_TYPES guard, before repository lookup and createGitProvider, so doomed reply retries return the controller's 400 instead of a 500 from provider setup.
  • 🟢 apps/api/src/api/activity/activity-retry.test.ts:201 — recovery assertions tightened to exact toBe(1) / toBe(0) pins, so any future regression that rescans or over-counts these rows fails the suite directly.

Main Issues

  • ⚫️ apps/api/src/api/activity/activity.service.ts:271 — the new EXISTS guard (lines 278-286) accepts a log when any entry has an extractable commentId, but the SET subquery still takes the first matching entry via ORDER BY entry.key LIMIT 1 with no non-null predicate (lines 270-273); a log whose first entry is truncated but later entry is valid passes the guard, writes NULL forever, over-counts result.changes against the startup log at apps/api/src/index.ts:54, and stays unrecoverable despite having a valid commentId (inline comment above with suggested predicate).

Additional Notes

  • apps/api/src/api/activity/activity-retry.test.ts:224 — the recovery suite covers truncated-only (toBe(0)) and single-valid (toBe(1)) logs but has no case where the first tool-call entry is unextractable and a later one is valid, which is exactly the row class where the EXISTS guard and the SET subquery currently disagree; worth adding alongside the predicate fix.

Comment thread apps/api/src/api/auth/auth.service.ts Outdated
InstanceSettingUpdateInput,
UserResponse,
} from "@proval/types";
import { resolveUserPromptMaxLength } from "@proval/types";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Unused resolveUserPromptMaxLength import fails lint in auth.service.ts

⚫️ [PROBLEM] This import is dead code and fails lint. resolveUserPromptMaxLength is imported here but never used in this file — the actual limit resolution at getMe goes through currentUserPromptMaxLength() from ../../util/user-prompt-limit.js (auth.service.ts:77). Since the repo's eslint config sets @typescript-eslint/no-unused-vars to "error" (packages/config/eslint/base.js:18), bun run lint on apps/api fails on this line. It also misleads readers into thinking auth.service resolves the limit itself. Remove the resolveUserPromptMaxLength import and keep only the currentUserPromptMaxLength import.

import type {
    AuthCredentialInput,
    AuthMeResponse,
    InstanceSettingResponse,
    InstanceSettingUpdateInput,
    UserResponse,
} from "@proval/types";
import db from "../../db/index.js";
import { currentUserPromptMaxLength } from "../../util/user-prompt-limit.js";

try {
selectResultQueue = [[instanceSetting], [{ value: 0 }]];
const me = await authService.getMe(null);
expect(me.userPromptMaxLength).toBe(9000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Env-override test uses 9000, the same as the default, so it can't catch regressions

⚫️ [PROBLEM] This override test can't actually fail on a regression. It sets PROVAL_USER_PROMPT_MAX_LENGTH to "9000" and expects 9000, but 9000 is exactly the default USER_PROMPT_MAX_LENGTH (packages/types/src/user-prompt.ts:1). If getMe ignored the env var and always returned the constant, this test would still pass — the same is true of the other two tests in the file (both expect the default). Unlike repository.service.test.ts, which uses a distinctive "50", none of these tests can distinguish env-driven resolution from a hardcoded return. Use a non-default value, e.g. set the env to "1234" and expect 1234.

process.env.PROVAL_USER_PROMPT_MAX_LENGTH = "1234";
try {
    selectResultQueue = [[instanceSetting], [{ value: 0 }]];
    const me = await authService.getMe(null);
    expect(me.userPromptMaxLength).toBe(1234);
} finally {
    restoreUserPromptMaxLengthEnv(previousMaxLength);
}

@proval-0e6d07

proval-0e6d07 Bot commented Oct 10, 2026

Copy link
Copy Markdown

Thanks for putting this together — this PR packs three well-scoped features: a per-provider streaming toggle (model_provider.stream with the ModelForm control), retry support for reply activities (new target_comment_id/target_inline_review_id columns, retry dispatch, and UI gating), and a deployment-configurable user-prompt limit announced through GET /auth/me with the default raised from 2000 to 9000. The migrations are safe for live databases and the test coverage for the new paths is notably strong. Two small fixes are needed before merge, both in the auth/user-prompt-limit area.

Main Issues

  • ⚫️ apps/api/src/api/auth/auth.service.ts, line 10: resolveUserPromptMaxLength is imported but never used — getMe resolves the limit via currentUserPromptMaxLength() instead. With @typescript-eslint/no-unused-vars set to "error" (packages/config/eslint/base.js:18), this fails bun run lint. Remove the dead import.
  • ⚫️ apps/api/src/api/auth/auth.service.test.ts, line 72: the env-override test sets PROVAL_USER_PROMPT_MAX_LENGTH="9000" and expects 9000, but 9000 is exactly the default USER_PROMPT_MAX_LENGTH (packages/types/src/user-prompt.ts:1) — the test passes even if getMe ignores the env entirely. Use a distinctive non-default value like "1234".

Good points

  • 🟢 apps/api/src/agent/llm/openai.streaming.test.ts covers the real failure modes end-to-end through the SDK fetch path — SSE frames split across reads, interleaved tool-call fragments reassembled by index, in-stream 429s reaching the agent loop, and buffered-path parity for error-shaped 200 bodies — exactly the spots where OpenAI-compatible providers diverge.
  • 🟢 apps/api/src/api/activity/activity-retry.test.ts exercises the real ActivityService.retry path in an isolated child process against a migrated in-memory DB, including the legacy-row-without-comment refusal, and all seven reply start sites across GitHub/GitLab/Forgejo persist targetCommentId values identical to what the first run receives (e.g. apps/api/src/webhook/github/github.controller.ts, line 530).
  • 🟢 Migration 0046 adds stream integer DEFAULT true NOT NULL (packages/db/src/migration/0046_dazzling_mulholland_black.sql), preserving always-on streaming behavior for existing rows, and the pre-migration snapshot query in apps/api/src/webhook/webhook-signing.test.ts (line 67) was correctly extended with the new column aliases so the migration-integrity comparison stays sound.
  • 🟢 apps/api/src/util/user-prompt-limit.ts gives validation (repository.service.ts) and announcement (auth.service.ts) a single shared resolver reading env at call time, so the two can never disagree about the effective limit.

Additional Notes

  • apps/api/src/api/activity/activity.service.ts, line 669: the targetCommentId == null refusal for legacy reply rows runs after createGitProvider (line ~619), which performs a real network round trip on GitHub — consider moving it up next to the other activity-level validations around line 608 so legacy rows fail fast with the intended 400.
  • The seven webhook start sites that persist targetCommentId/targetInlineReviewId have no integration assertion; activity-retry.test.ts inserts rows with the fields already set, so a future refactor dropping the persistence would go unnoticed — a webhook test asserting target_comment_id lands on the inserted activity would close the gap.
  • .env.example, line 4: the example still reads # PROVAL_USER_PROMPT_MAX_LENGTH=2000, the stale pre-PR default; updating it to 9000 would keep deployers from silently lowering the limit below the new default.

A failed pr_reply or issue_reply never offered Retry because the retry path
only accepted pr_review and issue_open, and because the activity row did not
keep the comment that started the reply. The webhook handlers now store
targetCommentId and targetInlineReviewId at start, retry resolves the target
comment before any provider setup and replays runPullRequestReply or
runIssueReply with it, and the review page shows the button for both reply
types. The boot repair reads the comment id back from the agent log of a reply
created before this change, so an old failed reply becomes retryable too.
@maxime-pharmania

Copy link
Copy Markdown
Author

Last open finding from that round is fixed in 317144c, and the added test case covers the mixed log it pointed at. Nothing else in that round was still live.

On the 16:07 review, it read commit 4f997b9, the first head of this branch before it was rebased onto main. That old diff carried #71 and #73 along with this work, which is where the streaming toggle, the resolveUserPromptMaxLength import, the 9000 override test, 0046_dazzling_mulholland_black and the .env.example default all come from. None of those files are in this PR and none of those findings apply to the current head 317144c, which is a single commit over f11edad with 14 files.

The one historical note worth keeping is the guard ordering, and that was already fixed in this branch before the review was posted.

Comment thread apps/api/src/index.ts

await db.update(activityTable).set({ status: "failed" }).where(eq(activityTable.status, "started"));

const recoveredReplyTargetCount = recoverLegacyReplyTargetComment();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚫️ [PROBLEM] Unwrapped legacy backfill at boot can hard-crash dev against a pre-0046 database

⚫️ [PROBLEM] recoverLegacyReplyTargetComment() is an optional legacy backfill, but at apps/api/src/index.ts:52 it runs raw SQL referencing target_comment_id with no error handling, immediately before Bun.serve. In dev the boot path never migrates — migrate() only runs under NODE_ENV === "production" (apps/api/src/index.ts:26), and the dev workflow relies on the separate manual drizzle-kit migrate script (apps/api/package.json). Starting the API against a local.db created before this PR therefore throws an uncaught no such column: target_comment_id and the process exits before the servers start, even though the backfill is strictly optional. Production is safe because migrate() runs first and hard-fails on its own, but any dev or stale-DB boot — or a transient SQLite error such as a locked database — takes the whole API down. This also breaks the pattern set a few lines above, where clearWorkspaceRoot is wrapped in try/catch with logError (lines 17–22) precisely so a nonessential boot step cannot block serving. Wrap the recovery the same way.

try {
    const recoveredReplyTargetCount = recoverLegacyReplyTargetComment();
    if (recoveredReplyTargetCount > 0) {
        log(pc.bgGreen(pc.bold(` Recovered ${recoveredReplyTargetCount} legacy reply comment link(s) `)));
    }
} catch (error) {
    logError("Failed to recover legacy reply comment links", error);
}

@proval-0e6d07

proval-0e6d07 Bot commented Oct 10, 2026

Copy link
Copy Markdown

Thanks for the cleanup pass — rebasing onto main so this branch is a single commit over f11edad resolved both findings from the last round at the root: with the streaming toggle and the configurable user-prompt-limit work dropped from the PR entirely, the unused resolveUserPromptMaxLength import and the weak 9000-equals-default env test are gone with them (workspace-wide greps for resolveUserPromptMaxLength, currentUserPromptMaxLength, PROVAL_USER_PROMPT_MAX_LENGTH, and userPromptMaxLength all return zero matches at 317144c). What remains is focused: reply-target retry with legacy recovery, the consolidated 0046_fearless_vermin migration, and the GitLab inlineReviewId tidy-up. One new boot-safety issue needs a fix before merge.

Main Issues

  • ⚫️ apps/api/src/index.ts, line 52: recoverLegacyReplyTargetComment() runs raw SQL referencing target_comment_id with no try/catch right before Bun.serve, and in dev nothing migrates first (migrate() is gated on NODE_ENV === "production" at line 26; the dev workflow uses the separate drizzle-kit migrate script). Booting against a pre-0046 local.db throws an uncaught no such column and the process exits before the servers start — unlike clearWorkspaceRoot a few lines up (lines 17–22), which is wrapped in try/catch with logError so a nonessential step can't block serving. Wrap the recovery the same way; details and a suggested patch are on the inline comment.

Good points

  • 🟢 apps/api/src/api/activity/activity.service.ts, line 285: the EXISTS guard now mirrors the SET subquery's predicate exactly and return result.changes (line 296) counts only genuine updates — no-op rows are never counted and recovered rows drop out of the WHERE on the next boot, closing the over-count/rescan bug from earlier rounds.
  • 🟢 apps/api/src/api/activity/activity.service.ts, line 661: requireReplyTargetComment now resolves the reply target immediately after the RETRY_TYPES guard, well before createGitProvider (line 676) makes a network round trip, and its message is in the controller's RETRY_CLIENT_ERRORS set (activity.controller.ts:112), so legacy rows fail fast as a 400 instead of a 500.
  • 🟢 apps/api/src/api/activity/activity-retry.test.ts, line 194: the new legacy reply recovery block covers the happy path with full retry integration, refusal for inline-review tool names and truncated JSON, and the mixed-log case where an unreadable fetch is skipped for the later one — with global recoverLegacyReplyTargetComment() count assertions of 1/0/1 that pin the no-over-count guarantee.
  • 🟢 packages/db/src/migration/0046_fearless_vermin.sql and packages/db/src/migration/meta/_journal.json, line 331: the migration consolidation is coherent three ways — a single idx-46 journal entry whose tag matches the SQL file, and the SQL adds exactly the two nullable reply-target columns that packages/db/src/schema.ts:198-199 declares, so schema, snapshot, and migration cannot drift.

Additional Notes

  • apps/api/src/api/activity/activity.service.ts, lines 270–290: the entry predicate is duplicated verbatim between the SET subquery and the EXISTS guard — the exact spot where a previous round found them disagreeing. The new tests cover truncated JSON and wrong-tool entries, but not a tool-call whose message is valid JSON lacking a commentId (e.g. {"page":1}); adding that fixture would fail loudly if the two copies drift again.
  • apps/api/src/webhook/gitlab/gitlab.controller.ts, line 299: hoisting the ternary into inlineReviewId and reusing it for both targetInlineReviewId (line 310) and runPullRequestReply (line 319) removes the duplicated gate — behavior is identical, but start-input and reply run can no longer drift apart.

@maxime-pharmania

Copy link
Copy Markdown
Author

I don't think wrapping this in a try/catch is necessarily the right fix.

If the database schema is outdated, other parts of the application may also fail when accessing the missing columns. Silently skipping the recovery would allow the server to start in a potentially inconsistent state.

Failing fast on an unmigrated database seems preferable unless the project explicitly supports running against outdated schemas.

I would consider this a non-blocking observation rather than a bug in this PR. The expected migration workflow should be clarified by the maintainers before changing the startup behavior.

@seoes

seoes commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Hi @maxime-pharmania!

Thanks for using Proval and even making many contributions.

I made a addtional updates to resolve migration conflict and some small code change, but I can't push to pharmania/proval, it's rejected with 403. maybe because it's from pharmania organization, so maintainer edit access is not available.

I pushed my change to this repository so you can see the updates. check branch fix/retry-failed-reply or commit 9780cd0 from this repository.

If you let me push to your forked repository, I'll push it and merge it

Changes

  • your 46 migration is now 47 since main branch already got 46 migration files
  • Removed requireReplyTargetComment() function. I believe that throwing an error from inside util function can make developers confused.
  • rearranged the order of conditional statements by activity type in activitye.service.ts. it's more simple now

Please let me know your opinion and push my commit to your fork

@NriotHrreion

NriotHrreion commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

@seoes Maybe you can re-push his fork to a new branch in the main repo.

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.

4 participants