action: minimize pullfrog.yml permissions and drop actions:read (#594)

* action: minimize pullfrog.yml permissions and drop actions:read

The recommended pullfrog.yml workflow asked for a permissions block that's
broader than what the action actually uses with the workflow GITHUB_TOKEN —
all real work (git push, PR comments, reviews) goes through installation
tokens that the action mints via OIDC. Customer security scanners flagged
the workflow-level block as too permissive.

- Move permissions to the job level and reduce to id-token: write,
  pull-requests: write, issues: write. contents:read is the implicit default
  and covers actions/checkout; contents:write, checks:read are unused by
  any GITHUB_TOKEN consumer; actions:read was only used by post-cleanup's
  listJobsForWorkflowRun call.
- Replace listJobsForWorkflowRun with a SIGTERM/SIGINT handler in main.ts
  that calls core.saveState("cancelled", "true"); post-cleanup reads it
  back via core.getState. Same cancel-vs-failure UX, no extra scope needed.
- Sync the docs (headless-action, getting-started, action/README) and the
  two dogfood pullfrog.yml workflows to the new minimal block. Update the
  post-cleanup wiki to describe the saveState approach.

* action: drop pull-requests/issues from required workflow scopes

Switch postCleanup.ts to mint its own short-lived installation token via OIDC
(acquireNewToken with issues:write + pull_requests:write) instead of using the
workflow GITHUB_TOKEN. Same comment-update behavior, but the workflow no longer
needs those scopes — the only permissions Pullfrog ever asks for are id-token:write
(OIDC exchange) and contents:read (actions/checkout).

Also fixes a bug from the previous commit: setting an explicit permissions block
drops every unlisted scope to none (with metadata as the only exception), so
omitting contents would have broken actions/checkout. Restored at both workflow
and job level.

* action: scope id-token:write to pullfrog job, not workflow level

id-token:write is the powerful one — it lets a job mint OIDC tokens that can
be exchanged for cloud credentials or our installation tokens. Keeping it at
workflow level means any future job added to this file silently inherits it.
Move it to the job level where it's actually used; leave only contents:read
at workflow level as a safe baseline for any future jobs.

* action: move stuck-comment cleanup server-side, drop write perms entirely

The action's post-cleanup step lived inside the runner and used the workflow
GITHUB_TOKEN to update the "Leaping into action…" progress comment when a run
failed/cancelled, requiring pull-requests:write + issues:write at the workflow
level. Move that responsibility to the workflow_run.completed webhook handler:
it already has installation-token access via the GitHub App, runs server-side
(no Pullfrog API dependency loop on failure), and lets us drop both write perms.

Recommended workflow permissions block is now truly minimal:

  permissions:
    contents: read
  jobs:
    pullfrog:
      permissions:
        id-token: write
        contents: read

Server side
- handleWorkflowRunCompleted: when conclusion != "success" and the WorkflowRun
  has progressCommentId, mint installation octokit and update the stuck comment
  in place. Try issues.getComment first, fall back to pulls.getReviewComment on
  404 (we don't store comment type — one wasted GET on the rarer review case).
- Reuses buildPullfrogFooter and updateProgressComment from pullfrog/internal,
  matching the wording the action used to write client-side.

Client side
- Delete action/utils/postCleanup.ts and action/post.ts.
- Remove post: + post-if: from action/action.yml.
- Drop runPostCleanup wiring from action/commands/gha.ts and action/play.ts.
- Remove the SIGTERM/saveState handler I added in main.ts in the previous commit
  (no longer needed; cancel/fail signal comes from the webhook hook payload).

Plumbing
- Extract isLeapingIntoActionCommentBody into action/utils/leapingComment.ts so
  the predicate can be re-exported via pullfrog/internal without dragging the
  MCP server's transitive type graph into the Next.js app's typecheck.
- mcp/comment.ts re-exports from the new location for backward compat.

Wiki
- Delete wiki/post-cleanup.md (obsolete; cleanup is now a one-liner branch in
  the workflow_run webhook handler).

* chore: ignore .worktrees in biome config

Recently-added pnpm worktree feature creates nested git worktrees under
.worktrees/, each with their own biome.jsonc declaring root. Biome's
recursive scan trips on the nested config and fails pnpm lint. Excluding
the directory matches the existing .gitignore entry.

* fix: address PR #594 review findings

Two real bugs caught by code review:

1. handleWorkflowRunWebhook.ts:323 — drop the /m flag on the stuck-comment
   detection regex. With /m, ^ matches any line start, so any finalized
   progress comment that embeds a task list (report_progress writes
   `- [x]`/`- [ ]` lines via todoTracking.ts) would be flagged as "stuck"
   and silently overwritten with the "This run croaked" boilerplate
   whenever the workflow concluded non-success after the agent's final
   summary already landed. Restores the body-start anchoring the original
   in-process postCleanup.ts:90 had.

2. action/scripts/check-entrypoint-imports.ts — drop ../post.ts from the
   esbuild entry-point list (the file was deleted in aa43b9af). The
   `pnpm check:entrypoints` step in test.yml would have failed on every
   run with an unresolvable-entry-point error.

Plus three small follow-ups:
- main.ts:580 — comment said "post-cleanup has its own verify-retry loop"
  but post-cleanup is gone. Updated to describe the new server-side path.
- mcp/comment.ts:443 — comment said "so post script doesn't think the run
  failed". Updated to describe the actual current consumers of wasUpdated.
- commands/gha.ts:84 — `--post` help text said "run post-cleanup flow" but
  with the post-cleanup path removed, --post is only valid alongside the
  `token` subcommand for installation-token revocation. Updated wording.

* fix(action): scope --post help text to gha token subcommand

Root gha help text was documenting --post, but --post only makes sense
paired with the token subcommand (it's how the post step revokes the
installation token previously acquired in the main step). Move it to a
dedicated gha token help section and add a parser layer that rejects
--post on the bare gha command.

  $ pullfrog gha --help
  usage: pullfrog gha [subcommand]
  ...
  options:
    -h, --help   show help

  $ pullfrog gha token --help
  usage: pullfrog gha token [--post]
  ...
  options:
    -h, --help   show help
    --post       revoke the previously-acquired token (post-step usage only)

* webhook: artifact-aware cleanup of stranded leaping comments on success

Previously the workflow_run.completed cleanup only handled non-success
conclusions. Extend it to also catch the rare case where a successful
run leaves a "Leaping into action…" comment stuck (in-process cleanup at
action/main.ts:723 normally handles this, but can be skipped on SIGKILL,
runner host crash, or any exit path that bypasses main()'s finally block).

New behavior in cleanupStuckProgressComment:

  - cancelled       → update with "cancelled 🛑" body  (unchanged)
  - failure (other) → update with "croaked 😵" body    (unchanged)
  - success + artifact recorded → delete the comment (the artifact is the
                                  user-facing surface; the leaping comment
                                  is just stale UI noise at this point)
  - success + no artifact recorded → delete the comment AND alert
                                     team@pullfrog.com via emailAlert

The "success + no artifact" path is "should never happen" territory: the
run claims success but produced no review, PR, issue, plan, or summary
comment. The team alert helps us catch in-process cleanup regressions or
artifact-tracking gaps. hasRecordedArtifact reads {review,pr,issue,
planComment,summaryComment}NodeId off the WorkflowRun row to make the call.

* webhook: narrow stuck-comment detection to leaping prefix only

Drop the stranded-todo-pattern branch from cleanupStuckProgressComment.
The leaping prefix is highly specific and impossible to confuse with a
legitimate summary; a leading todo line is not — the agent's
error-reporting paths can produce useful explanatory comments whose
body leads with a checklist (e.g. "here's what I was working on" + the
incomplete todo list), and we don't want to silently overwrite those
with the generic "croaked" boilerplate.

In-process cleanup at action/main.ts:723 still handles the stranded-todo
case in the common path (gated on !finalSummaryWritten with full access
to the in-memory tool state). Missing the rare runner-died-mid-todo case
server-side is a worthwhile trade vs. the false-positive risk on real
explanatory comments.
This commit is contained in:
Colin McDonnell
2026-05-07 18:59:52 +00:00
committed by pullfrog[bot]
parent e2e29a19fc
commit f87e0f878c
12 changed files with 96 additions and 325 deletions
+4 -6
View File
@@ -12,16 +12,14 @@ on:
description: Run name
permissions:
id-token: write
contents: write
pull-requests: write
issues: write
actions: read
checks: read
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
+5 -11
View File
@@ -72,16 +72,14 @@ on:
description: 'Agent prompt'
permissions:
id-token: write
contents: write
pull-requests: write
issues: write
actions: read
checks: read
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -130,11 +128,7 @@ jobs:
permissions:
id-token: write
contents: write
issues: write
pull-requests: write
actions: read
checks: read
contents: read
uses: ./.github/workflows/pullfrog.yml
with:
# pass the full event payload as the prompt
-2
View File
@@ -36,8 +36,6 @@ outputs:
runs:
using: "node24"
main: "entry.ts"
post: "post.ts"
post-if: "failure() || cancelled()"
branding:
icon: "code"
+52 -26
View File
@@ -2,8 +2,6 @@ import { dirname } from "node:path";
import * as core from "@actions/core";
import arg from "arg";
import { main } from "../main.ts";
import { log } from "../utils/cli.ts";
import { runPostCleanup } from "../utils/postCleanup.ts";
import { acquireInstallationToken, revokeInstallationToken } from "../utils/token.ts";
// GitHub Actions runs the action entry point with the node24 binary specified
@@ -31,16 +29,6 @@ async function runMain(): Promise<void> {
}
}
async function runPost(): Promise<void> {
log.debug(`[post] script started at ${new Date().toISOString()}`);
try {
await runPostCleanup();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.error(`[post] unexpected error: ${message}`);
}
}
async function tokenMain(): Promise<void> {
const reposInput = core.getInput("repos");
const additionalRepos = reposInput
@@ -73,7 +61,7 @@ async function tokenPost(): Promise<void> {
}
function printGhaUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} gha [token] [--post]\n`);
params.stream(`usage: ${params.prog} gha [subcommand]\n`);
params.stream("run the github action runtime flow.");
params.stream("");
params.stream("subcommands:");
@@ -81,10 +69,31 @@ function printGhaUsage(params: { stream: typeof console.log; prog: string }): vo
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
params.stream(" --post run post-cleanup flow");
}
function printGhaTokenUsage(params: { stream: typeof console.log; prog: string }): void {
params.stream(`usage: ${params.prog} gha token [--post]\n`);
params.stream("acquire a github app installation token, or revoke it in the post step.");
params.stream("");
params.stream("options:");
params.stream(" -h, --help show help");
params.stream(" --post revoke the previously-acquired token (post-step usage only)");
}
function parseGhaArgs(args: string[]) {
return arg(
{
"--help": Boolean,
"-h": "--help",
},
{
argv: args,
stopAtPositional: true,
}
);
}
function parseGhaTokenArgs(args: string[]) {
return arg(
{
"--help": Boolean,
@@ -118,27 +127,46 @@ export async function runCli(params: GhaCliParams): Promise<void> {
return;
}
const normalizedArgs = ["gha"];
const positional = parsed._;
const subcommand = positional[0];
if (positional.length > 1) {
console.error(`unexpected positional arguments for gha: ${positional.slice(1).join(" ")}\n`);
if (!subcommand) {
await run(["gha"]);
return;
}
if (subcommand !== "token") {
console.error(`unknown gha subcommand: ${subcommand}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (positional[0] === "token") {
normalizedArgs.push("token");
} else if (positional[0]) {
console.error(`unknown gha subcommand: ${positional[0]}\n`);
printGhaUsage({ stream: console.error, prog: params.prog });
// gha token [--post]
let tokenParsed: ReturnType<typeof parseGhaTokenArgs>;
try {
tokenParsed = parseGhaTokenArgs(positional.slice(1));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`${message}\n`);
printGhaTokenUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
if (parsed["--post"]) {
if (tokenParsed["--help"]) {
printGhaTokenUsage({ stream: console.log, prog: params.prog });
return;
}
if (tokenParsed._.length > 0) {
console.error(`unexpected positional arguments for gha token: ${tokenParsed._.join(" ")}\n`);
printGhaTokenUsage({ stream: console.error, prog: params.prog });
process.exit(1);
}
const normalizedArgs = ["gha", "token"];
if (tokenParsed["--post"]) {
normalizedArgs.push("--post");
}
await run(normalizedArgs);
}
@@ -150,8 +178,6 @@ export async function run(args: string[]) {
} else {
await tokenMain();
}
} else if (args.includes("--post")) {
await runPost();
} else {
await runMain();
}
+4
View File
@@ -40,6 +40,10 @@ export {
stripExistingFooter,
} from "../utils/buildPullfrogFooter.ts";
export type { ResourceUsage, UsageSummary } from "../utils/github.ts";
export {
isLeapingIntoActionCommentBody,
LEAPING_INTO_ACTION_PREFIX,
} from "../utils/leapingComment.ts";
export type {
CreateProgressCommentTarget,
ProgressComment,
+5 -4
View File
@@ -578,10 +578,11 @@ export async function main(): Promise<MainResult> {
toolState.todoTracker = todoTracker;
// on cancellation, stop scheduling new tracker writes immediately. without this, a
// debounced write queued just before SIGTERM could land at GitHub *after* the post-step
// writes its "This run was cancelled" message, clobbering it back to the task list.
// we can't await in-flight writes (the process is exiting), but cancelling the timer
// shrinks the race window. post-cleanup has its own verify-retry loop for the rest.
// debounced write queued just before SIGTERM could land at GitHub *after* the
// workflow_run.completed webhook has already replaced the comment with the
// "This run was cancelled" body, clobbering it back to the task list. we can't
// await in-flight writes (the process is exiting), but cancelling the timer
// shrinks the race window.
onExitSignal(() => {
todoTracker?.cancel();
});
+7 -13
View File
@@ -12,18 +12,11 @@ import {
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/**
* The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state
* and hasn't been updated with progress or error messages.
*/
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
export function isLeapingIntoActionCommentBody(body: string): boolean {
const content = stripExistingFooter(body).trimStart();
const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine);
}
// re-export for backward compat with anything importing the leaping helpers from mcp/comment
export {
isLeapingIntoActionCommentBody,
LEAPING_INTO_ACTION_PREFIX,
} from "../utils/leapingComment.ts";
function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string {
const runId = ctx.runId;
@@ -447,7 +440,8 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
body: bodyWithFooter,
});
// mark progress as updated so post script doesn't think the run failed
// mark progress as updated so error reporting + run-result handling know
// a substantive write happened (used by reportErrorToComment / handleAgentResult)
ctx.toolState.wasUpdated = true;
return {
+1 -8
View File
@@ -12,7 +12,6 @@ import { log } from "./utils/cli.ts";
import { runInDocker } from "./utils/docker.ts";
import { ensureGitHubToken } from "./utils/github.ts";
import { isInsideDocker } from "./utils/globals.ts";
import { runPostCleanup } from "./utils/postCleanup.ts";
import { setupTestRepo } from "./utils/setup.ts";
/**
@@ -78,13 +77,7 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
}
}
// wrap main() so post cleanup runs even on failure (mirrors action.yml post-if: "failure() || cancelled()")
let result: AgentResult;
try {
result = await main();
} finally {
await runPostCleanup();
}
const result: AgentResult = await main();
process.chdir(originalCwd);
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env node
import { runPullfrogCli } from "./runCli.ts";
runPullfrogCli({
cliArgs: ["gha", "--post"],
swallowErrors: true,
});
-1
View File
@@ -7,7 +7,6 @@ const scriptDir = dirname(fileURLToPath(import.meta.url));
const entryPoints = [
resolve(scriptDir, "../entry.ts"),
resolve(scriptDir, "../post.ts"),
resolve(scriptDir, "../get-installation-token/entry.ts"),
resolve(scriptDir, "../get-installation-token/post.ts"),
];
+18
View File
@@ -0,0 +1,18 @@
import { stripExistingFooter } from "./buildPullfrogFooter.ts";
/**
* The prefix text for the initial "leaping into action" comment.
* Used to detect whether a progress comment is still in its initial state
* and hasn't been updated with real progress or error messages.
*
* Lives in `utils/` (not `mcp/`) so it can be re-exported via `pullfrog/internal`
* without dragging the MCP server's transitive imports into the Next.js app's
* type-check graph.
*/
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
export function isLeapingIntoActionCommentBody(body: string): boolean {
const content = stripExistingFooter(body).trimStart();
const firstLine = content.split(/\r?\n/, 1)[0]?.trimEnd() ?? "";
return new RegExp(`(^|\\s)${LEAPING_INTO_ACTION_PREFIX}(\\.\\.\\.)?$`).test(firstLine);
}
-246
View File
@@ -1,246 +0,0 @@
import { isLeapingIntoActionCommentBody } from "../mcp/comment.ts";
import { getApiUrl } from "./apiUrl.ts";
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
import { log } from "./cli.ts";
import { createOctokit, parseRepoContext } from "./github.ts";
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
import {
getProgressComment,
type ProgressComment,
parseProgressComment,
updateProgressComment,
} from "./progressComment.ts";
import { getJobToken } from "./token.ts";
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
interface PostCleanupContext {
repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runId: number | undefined;
promptInput: JsonPromptInput | null;
}
// controls whether the script should check the reason for the workflow termination.
// it can be either canceled or failed.
// YAML file cannot supply it (not in ENV), so an extra request is required to check it.
const SHOULD_CHECK_REASON = true;
function buildErrorCommentBody(ctx: PostCleanupContext, isCancellation: boolean): string {
let errorMessage = isCancellation
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion.`
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported.`;
if (ctx.runId) {
errorMessage += " Please check the link below for details.";
}
const customParts: string[] = [];
if (!isCancellation && ctx.runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job ➔](${apiUrl}/trigger/${ctx.repoContext.owner}/${ctx.repoContext.name}/${ctx.runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: ctx.runId
? {
owner: ctx.repoContext.owner,
repo: ctx.repoContext.name,
runId: ctx.runId,
}
: undefined,
customParts,
});
return `${errorMessage}${footer}`;
}
async function validateStuckProgressComment(
ctx: PostCleanupContext
): Promise<ProgressComment | null> {
const promptComment = ctx.promptInput?.progressComment;
if (!promptComment) {
log.info("[post] no progressComment in prompt input, skipping cleanup");
return null;
}
const comment = parseProgressComment(promptComment);
if (!comment) {
log.info(`[post] progressComment.id is not a positive integer: ${promptComment.id}`);
return null;
}
log.info(`[post] validating progressComment from prompt input: ${comment.id} (${comment.type})`);
try {
const fetched = await getProgressComment(
{ octokit: ctx.octokit, owner: ctx.repoContext.owner, repo: ctx.repoContext.name },
comment
);
const body = fetched.body ?? "";
if (isLeapingIntoActionCommentBody(body)) {
log.info(`[post] comment ${comment.id} is stuck on "Leaping into action"`);
return comment;
}
// detect stranded todo checklists left by the tracker when the process was killed
// before the agent could call report_progress with a final summary
if (/^- \[[ x]\] |^- \*\*→\*\* |^- ~~/.test(body)) {
log.info(`[post] comment ${comment.id} is stuck on a todo checklist`);
return comment;
}
log.info(`[post] comment ${comment.id} is not stuck (already updated or different content)`);
return null;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.info(`[post] failed to get comment ${comment.id}: ${errorMessage}`);
return null;
}
}
async function getIsCancelled(ctx: PostCleanupContext): Promise<boolean> {
if (!ctx.runId) return false; // can't check without a run ID — assume failure
try {
const jobsResult = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
owner: ctx.repoContext.owner,
repo: ctx.repoContext.name,
run_id: ctx.runId,
});
// find current job by matching GITHUB_JOB env var.
// GITHUB_JOB is the job ID (yaml key), but job.name is the display name.
// for matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
// so we match jobs that START with the job ID
const currentJobName = process.env.GITHUB_JOB;
const currentJob = currentJobName
? jobsResult.data.jobs.find(
(j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)
)
: jobsResult.data.jobs[0]; // fallback to first job
if (!currentJob) {
log.warning("[post] could not find current job");
return false;
}
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
// but if it's still null, check steps for cancellation:
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
if (cancelledStep) {
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
return true;
}
log.info("[post] no cancellation found, assuming failure");
} catch (error) {
log.info(
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
);
}
return false; // assuming failure
}
export async function runPostCleanup(): Promise<void> {
log.info("» [post] starting post cleanup");
const runId = process.env.GITHUB_RUN_ID
? Number.parseInt(process.env.GITHUB_RUN_ID, 10)
: undefined;
// resolve prompt input once and use it for both issue number and comment ID extraction
// only use the object form (JSON payload), not plain string prompts
let promptInput: JsonPromptInput | null = null;
try {
const resolved = resolvePromptInput();
if (typeof resolved !== "string") promptInput = resolved;
} catch (error) {
log.info(
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
);
}
// get job token for API calls
const token = getJobToken();
const repoContext = parseRepoContext();
const octokit = createOctokit(token);
const ctx: PostCleanupContext = { repoContext, octokit, runId, promptInput };
const stuck = await validateStuckProgressComment(ctx);
if (!stuck) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
log.info(
`» [post] validated stuck comment: ${stuck.id} (${stuck.type}), updating with error message`
);
try {
const body = buildErrorCommentBody(
ctx,
SHOULD_CHECK_REASON ? await getIsCancelled(ctx) : false
);
await writeAndVerify(ctx, stuck, body);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.info(`[post] failed to update comment: ${errorMessage}`);
}
}
// post-cleanup runs in a separate process from the cancelled action, so any in-flight
// HTTP write the action's todoTracker had on the wire when SIGTERM landed can still get
// processed by GitHub *after* our update — clobbering the cancellation message back to
// the stale task list. (action-side mitigation: SIGTERM handler cancels the tracker; here
// we close the remaining race by reading back our write and re-issuing if it lost.)
const VERIFY_DELAY_MS = 3000;
const MAX_WRITE_ATTEMPTS = 3;
async function writeAndVerify(
ctx: PostCleanupContext,
comment: ProgressComment,
body: string
): Promise<void> {
const apiCtx = {
octokit: ctx.octokit,
owner: ctx.repoContext.owner,
repo: ctx.repoContext.name,
};
for (let attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
await updateProgressComment(apiCtx, comment, body);
await new Promise((resolve) => setTimeout(resolve, VERIFY_DELAY_MS));
let fetched: Awaited<ReturnType<typeof getProgressComment>>;
try {
fetched = await getProgressComment(apiCtx, comment);
} catch (error) {
// verify GET failed (5xx, secondary rate limit, network blip). the PUT itself
// returned 200, so we trust it landed; another write-and-verify pass would just
// amplify writes against a flaky GitHub. log and exit — if a stale tracker write
// does clobber us, the comment will be wrong but the agent's commit + replies
// already conveyed the substance of the run.
log.warning(
`[post] verify GET failed after attempt ${attempt} — trusting our PUT landed: ${
error instanceof Error ? error.message : String(error)
}`
);
return;
}
if (fetched.body === body) {
log.info(
`» [post] successfully updated progress comment (attempt ${attempt}/${MAX_WRITE_ATTEMPTS})`
);
return;
}
log.info(
`[post] body was overwritten after our write (attempt ${attempt}/${MAX_WRITE_ATTEMPTS}), retrying`
);
}
log.warning(
`[post] gave up after ${MAX_WRITE_ATTEMPTS} attempts — comment may be stale (in-flight writes from the cancelled run kept clobbering us)`
);
}