Files
lobster/src/workflows/github_pr_monitor.ts
T
xingzhouandPeter Steinberger c440ca57d1 fix(runtime): cancelled workflows no longer continue external commands (#119)
* fix(runtime): stop process-backed work on cancellation

* fix(runtime): invalidate cancelled resume state

* Revert "fix(runtime): invalidate cancelled resume state"

This reverts commit 0f7d291f98.

* fix(runtime): narrow cancellation to safe child processes

* fix(runtime): stop after completed search cancellation

* fix(runtime): halt direct pipelines after cancellation

Preserve completed in-flight stage results while preventing later direct pipeline stages from starting after parent cancellation.

* fix(runtime): consume aborted approval resumes

* fix(runtime): cancelled workflows no longer continue external commands

* fix(workflow): propagate custom parent cancellation

* fix(runtime): preserve pre-aborted resume state

* fix(runtime): stop lazy handoff after cancellation

* fix(workflow): close remaining cancellation boundaries

* fix(runtime): preserve workflow resumes during setup cancellation

* fix(runtime): close final cancellation persistence gaps

* fix(runtime): stop lazy handoff after cancellation

* fix(runtime): interrupt blocked lazy handoff reads

* fix(runtime): terminate cancellation process trees

* fix(runtime): await process tree termination

* fix(runtime): terminate workflow process trees

* fix(runtime): bridge CLI cancellation

* fix(cli): preserve cancellation lifecycle

* fix(cli): abort stalled signal-aware commands

* fix(cli): release aborted interactive prompts

* fix(cli): preserve sequential prompt input

* fix(cli): preserve buffered prompt input

* fix(cli): handle prompt EOF after buffered input

* fix(runtime): preserve UTF-8 subprocess output

* fix(workflow): preserve retryable resume before execution

* fix(workflow): roll back cancelled resume replacement

* fix(state): roll back cancelled monitor snapshot

* fix(resume): preserve cancelled gate capabilities

* fix(resume): close cancellation rollback windows

* fix(resume): harden cancellation state cleanup

* fix(runtime): close resumed cancellation gaps

* fix(runtime): preserve cancellation cleanup

* fix(runtime): close cancellation lifecycle gaps

* fix(runtime): harden resumed cancellation boundaries

* fix(workflow): consume timed-out resume capabilities

* fix(workflow): preserve resume policy boundaries

* fix(runtime): preserve cancellation cleanup liveness

* fix(runtime): harden cancellation cleanup

* fix(runtime): stop lazy output after cancellation

* fix(runtime): settle cancellation cleanup

* fix(runtime): preserve safe input resumes

* fix(runtime): prevent consumed resume replays

* fix(runtime): serialize approval resume consumption

* fix(runtime): prevent concurrent safe gate forks

* fix(runtime): close cancellation review gaps

* fix(llm): restore cache after cancelled refresh

* fix(runtime): close remaining cancellation windows

* fix(runtime): preserve cancellation recovery invariants

* fix(runtime): prevent stale resume recovery

* fix(runtime): preserve resume claim recovery

* fix(runtime): retry pre-dispatch claims safely

* fix(state): synchronize rollback-safe reads

* fix(resume): discard cancelled pipeline successors

* fix(runtime): harden cancellation and state locking

* fix(runtime): recover durable cancellation failures

* fix(runtime): preserve legacy workflow cancellation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-13 10:54:20 -07:00

183 lines
4.1 KiB
TypeScript

import { runAbortableProcess } from "../abortable_process.js";
async function runProcess(command, argv, { env, cwd, signal, forceTerminationSignal }) {
const { stdout, stderr, code } = await runAbortableProcess({
command,
argv,
env,
cwd,
signal,
forceTerminationSignal,
notFoundMessage: "gh not found on PATH (install GitHub CLI)",
});
if (code === 0) return { stdout, stderr };
throw new Error(`gh failed (${code}): ${stderr.trim() || stdout.trim()}`);
}
import { diffAndStore } from "../state/store.js";
function pickSubset(snapshot) {
if (!snapshot || typeof snapshot !== "object") return null;
return {
number: snapshot.number,
title: snapshot.title,
url: snapshot.url,
state: snapshot.state,
isDraft: snapshot.isDraft,
mergeable: snapshot.mergeable,
reviewDecision: snapshot.reviewDecision,
updatedAt: snapshot.updatedAt,
baseRefName: snapshot.baseRefName,
headRefName: snapshot.headRefName,
};
}
export function buildPrChangeSummary(before, after) {
const a = pickSubset(after);
const b = pickSubset(before);
if (!a) return { changedFields: [], changes: {} };
if (!b) {
return {
changedFields: Object.keys(a),
changes: Object.fromEntries(Object.keys(a).map((k) => [k, { from: null, to: a[k] }])),
};
}
const changes = {};
for (const key of Object.keys(a)) {
if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) {
changes[key] = { from: b[key], to: a[key] };
}
}
return {
changedFields: Object.keys(changes),
changes,
};
}
function formatPrChangeMessage({ repo, pr, changedFields, prInfo }) {
const fields = changedFields.length ? ` (${changedFields.join(", ")})` : "";
const title = prInfo?.title ? `: ${prInfo.title}` : "";
const url = prInfo?.url ? ` ${prInfo.url}` : "";
return `PR updated: ${repo}#${pr}${title}${fields}.${url}`.replace(/\s+/g, " ").trim();
}
export async function runGithubPrMonitorWorkflow({ args, ctx }) {
ctx.signal?.throwIfAborted();
const repo = args.repo;
const pr = args.pr;
if (!repo || !pr) throw new Error("github.pr.monitor requires args.repo and args.pr");
const key = args.key ?? `github.pr:${repo}#${pr}`;
const changesOnly = Boolean(args.changesOnly);
const summaryOnly = Boolean(args.summaryOnly);
const argv = [
"pr",
"view",
String(pr),
"--repo",
String(repo),
"--json",
"number,title,url,state,isDraft,mergeable,reviewDecision,author,baseRefName,headRefName,updatedAt",
];
const { stdout } = (await runProcess("gh", argv, {
env: ctx.env,
cwd: process.cwd(),
signal: ctx.signal,
forceTerminationSignal: ctx.forceTerminationSignal,
})) as any;
ctx.signal?.throwIfAborted();
let current;
try {
current = JSON.parse(stdout.trim());
} catch {
throw new Error("gh returned non-JSON output");
}
const { changed, before } = await diffAndStore({
env: ctx.env,
key,
value: current,
signal: ctx.signal,
});
if (changesOnly && !changed) {
return {
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
changed: false,
suppressed: true,
};
}
const summary = buildPrChangeSummary(before, current);
if (summaryOnly) {
return {
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
changed,
summary,
pr: {
number: current.number,
title: current.title,
url: current.url,
state: current.state,
updatedAt: current.updatedAt,
},
};
}
return {
kind: "github.pr.monitor",
repo,
prNumber: Number(pr),
key,
changed,
summary,
prSnapshot: current,
};
}
export async function runGithubPrMonitorNotifyWorkflow({ args, ctx }) {
const base = await runGithubPrMonitorWorkflow({
args: {
...args,
changesOnly: true,
summaryOnly: true,
},
ctx,
});
if (base.suppressed) {
return { kind: "github.pr.monitor.notify", suppressed: true };
}
const changedFields = base.summary?.changedFields ?? [];
const prInfo = base.pr ?? {};
return {
kind: "github.pr.monitor.notify",
changed: Boolean(base.changed),
repo: args.repo,
prNumber: Number(args.pr),
message: formatPrChangeMessage({
repo: args.repo,
pr: Number(args.pr),
changedFields,
prInfo,
}),
pr: prInfo,
summary: base.summary,
};
}