Files
shockbot/utils/postCleanup.ts
T
David Blass 6b18b6730b live todo tracking, collapsible task list in final progress, hide set_output outside standalone (#492)
* fix false "without reporting progress" error + live todo tracking

clean up orphaned progress comments when review is skipped or only
set_output is used, preventing the false positive in handleAgentResult.

parse todowrite events from OpenCode's NDJSON stream and render a
live markdown checklist in the PR progress comment (2s debounce).
agent's explicit report_progress always takes priority.

Made-with: Cursor

* fix contradictory review/progress prompting

align Review and IncrementalReview mode prompts with their guidance —
mode prompts said "always submit" while guidance said "skip if clean."
remove the empty-approval submission that was silently dropped by the
tool. make progress comment lifecycle explicit: created on first call,
updated in place, removed after review submission.

Made-with: Cursor

* centralize todo tracking into shared TodoTracker module

extract inline todo tracking logic (~95 lines) from opentoad.ts into
action/utils/todoTracking.ts. the tracker is created once in main.ts
and passed to agents via AgentRunContext.todoTracker, making it
agent-agnostic and reusable for future agent implementations.

Made-with: Cursor

* fix todoTracker optional type to match file convention

add | undefined to todoTracker in AgentRunContext, matching every
other optional property in the same interface.

Made-with: Cursor

* instruct agents to always maintain a task list for live progress

system prompt now tells agents to create an internal task list at
the start of every run. the tracker renders it to the progress
comment automatically. report_progress is reserved for final
results only — no more intermediate "Checking..." messages that
cancel the tracker and leave stale text on the comment.

Made-with: Cursor

* require report_progress summary at end of every run

agents must always call report_progress with a final summary —
the completed task list should never be the end state of the
progress comment. updated all review mode prompts to call
report_progress after submitting (or not submitting) a review.

Made-with: Cursor

* keep progress comment after review with final summary

stop deleting the progress comment after review submission —
the agent now always calls report_progress with a summary at
the end, and that summary should persist as a record of what
was done.

Made-with: Cursor

* harden stranded progress comment cleanup

- main.ts: detect when tracker was last writer (agent never called
  report_progress) and delete the stranded checklist instead of
  leaving it as the final comment state
- postCleanup.ts: expand stuck-comment detection to also catch
  stranded todo checklists (regex match for checklist patterns)
  when the process is killed before normal cleanup runs
- modes.ts + selectMode.ts: add report_progress step to Summarize
  and SummaryUpdate modes (only modes that were missing it)

Made-with: Cursor

* fix stale comments, typo, and build mode redundancy

- comment.ts: update deleteProgressComment docstring and inline comment
  to reflect current usage (stranded-comment cleanup, not post-review)
- modes.ts: merge duplicate report_progress steps (8 + 10) into single
  step 9, fix "optimizatfixons" typo
- wiki/post-cleanup.md: document checklist detection regex

Made-with: Cursor

* collapsible completed todos in final progress, hide set_output outside standalone mode

- add renderCollapsible() to TodoTracker, append completed task list as
  <details> section when agent calls report_progress
- cancel tracker after agent's final report_progress so it doesn't
  overwrite with raw checklist
- conditionally register SetOutputTool only in standalone mode or when
  output_schema is provided
- remove unconditional set_output instruction from orchestrator task section
- update Summarize/SummaryUpdate mode guidance to not reference set_output

Made-with: Cursor

* show completion count in collapsible task list summary

Made-with: Cursor

* only count completed (not cancelled) in collapsible task list summary

Made-with: Cursor

* reinforce concise summary prompting across system prompt, modes, and tool description

Made-with: Cursor

* address review feedback: wasUpdated bypass, tracker false-positive, race condition

- remove wasUpdated=true from cleanup paths so handleAgentResult correctly
  detects genuinely silent runs
- add hadProgressComment to ToolState as immutable snapshot for the safety check
- use todoTracker.hasPublished instead of enabled for stranded-comment cleanup
- serialize onUpdate calls via inflightPromise chain with post-cancel guard
- add settled() to wait for in-flight updates before writing final summary

Made-with: Cursor

* address round-2 review: hasPublished after success, finalSummaryWritten flag

- set hasPublished only after onUpdate resolves (not before) so failed
  writes are not counted as published
- add finalSummaryWritten flag to ToolState, set after successful
  non-plan reportProgress; decouple cleanup detection from
  todoTracker.enabled so it survives API failures where cancel() ran
  but the write didn't succeed

Made-with: Cursor
2026-03-25 19:35:31 +00:00

203 lines
6.9 KiB
TypeScript

import { LEAPING_INTO_ACTION_PREFIX } 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 { getJobToken } from "./token.ts";
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
// 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;
type BuildErrorCommentBodyParams = {
owner: string;
repo: string;
runId: number | undefined;
isCancellation: boolean;
};
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
let errorMessage = params.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 (params.runId) {
errorMessage += " Please check the link below for details.";
}
const customParts: string[] = [];
if (!params.isCancellation && params.runId) {
const apiUrl = getApiUrl();
customParts.push(
`[Rerun failed job ➔](${apiUrl}/trigger/${params.owner}/${params.repo}/${params.runId}?action=rerun)`
);
}
const footer = buildPullfrogFooter({
triggeredBy: true,
workflowRun: params.runId
? { owner: params.owner, repo: params.repo, runId: params.runId }
: undefined,
customParts,
});
return `${errorMessage}${footer}`;
}
type ValidateStuckCommentParams = {
promptInput: JsonPromptInput | null;
octokit: ReturnType<typeof createOctokit>;
owner: string;
repo: string;
};
async function validateStuckProgressComment(
params: ValidateStuckCommentParams
): Promise<number | null> {
if (!params.promptInput?.progressCommentId) {
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
return null;
}
const commentId = parseInt(params.promptInput.progressCommentId, 10);
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
try {
const commentResult = await params.octokit.rest.issues.getComment({
owner: params.owner,
repo: params.repo,
comment_id: commentId,
});
const body = commentResult.data.body ?? "";
if (body.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
return commentId;
}
// 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 ${commentId} is stuck on a todo checklist`);
return commentId;
}
log.info(`[post] comment ${commentId} 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 ${commentId}: ${errorMessage}`);
return null;
}
}
type GetIsCancelledParams = {
repoContext: ReturnType<typeof parseRepoContext>;
octokit: ReturnType<typeof createOctokit>;
runId: number | undefined;
};
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
if (!params.runId) return false; // can't check without a run ID — assume failure
try {
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
owner: params.repoContext.owner,
repo: params.repoContext.name,
run_id: params.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 commentId = await validateStuckProgressComment({
promptInput,
octokit,
owner: repoContext.owner,
repo: repoContext.name,
});
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
try {
const body = buildErrorCommentBody({
owner: repoContext.owner,
repo: repoContext.name,
runId,
isCancellation: SHOULD_CHECK_REASON
? await getIsCancelled({ octokit, repoContext, runId })
: false,
});
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: commentId,
body,
});
log.info("» [post] successfully updated progress comment");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.info(`[post] failed to update comment: ${errorMessage}`);
}
}