Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "t3-pstack",
"source": "./plugins/pstack",
"description": "pstack for T3 Code: rigorous agent workflows whose panels and fan-outs run across model families through T3. Original pstack by Lauren Tan (poteto); Claude Code port by Michael Denyer.",
"version": "1.0.8"
"version": "1.0.9"
}
]
}
17 changes: 17 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

This file is the release changelog, with one `## <version> - <title>` entry per release, newest first. The Cursor-to-Claude rewrite rules live in [`tools/substitutions.json`](tools/substitutions.json), and the [sync boundary](CONTRIBUTING.md#the-sync-boundary) in `CONTRIBUTING.md` defines how a change to upstream's skill content is declared.

## 1.0.9 - merge pstack-claude 0.9.81

t3-pstack 1.0.9 merges pstack-claude 0.9.80 and 0.9.81 (upstream commit `24c96b1`). It carries no new change from Lauren Tan's pstack. The upstream entries below describe what came in: `watch-pr` and `ship-pr` compare the base branch's current commit instead of the PR's recorded `baseRefOid`, every GraphQL read in the watcher fails closed on `errors`, and `ship-pr inspect` reads a closed or merged PR whose base branch is gone. The Pi orphan cleanup fix comes in as upstream ships it; t3-pstack does not use Pi. Model defaults are unchanged.

- The settle window's GraphQL read fails closed on `errors` too. It cannot go through the reader's new `graphql()` method, which returns only the pull request, because it also reads the top-level `viewer`, so `parseReviewActivity` in `settle.ts` checks `errors` itself and throws the same retryable failure. The `github.ts` hook is unchanged.

## 1.0.8 - merge pstack-claude 0.9.79

t3-pstack 1.0.8 merges pstack-claude 0.9.76 through 0.9.79 (upstream commit `60ae9e2`), which carries Lauren Tan's pstack v0.15.15. The upstream entries below describe what came in. `/poteto-help` offers `/setup-pstack` when the model sheet is missing and the answer depends on it, and the substitution rules match model lists of any length. reflect passes its reviewers the transcript path with nothing beside it, and the fallback digest records events without a verdict. `haiku` joins the default Claude panel in `models.json`. Its `t3` block is unchanged, so the T3 defaults and every T3 sheet are too. Fixes cover the shipping dequeue input id, the worktree-audit and Pi restore guards, `watch-pr` rejecting an `--interval` too long for one timer, the orchestration store lock, the Pi wakeup slot, the sync slug period, and the shipping test fakes, with a `lean/` model of the polling arithmetic.
Expand Down Expand Up @@ -87,6 +93,17 @@ t3-pstack is pstack-claude 0.9.69 with every subagent, panel, fan-out, schedule,
- The checker rejects a malformed or duplicated role line, a list on a single-model role, a `default effort` line, a bad `session hook` value, and a single-family panel, counting `inherit-parent` as the parent's family. The generator runs the same grammar over the `t3` block.
- The plugin is `t3-pstack@t3-pstack` (marketplace repo `chhoumann/t3-pstack`), and its model sheet is `t3-pstack-models.md`, so it never collides with pstack-claude's `pstack@pstack-claude` and `pstack-models.md`. Skill text keeps upstream's `pstack:<name>` spelling; `t3-tools.md` reads it as `t3-pstack:<name>`.
- The sync denylist's `grok-` token became `(?<!/)grok-`, so a T3 target such as `grok/grok-4.7` passes while a bare Cursor slug still fails.
## 0.9.81 - inspect terminal PRs and recover orphaned Pi agents

`ship-pr inspect` reads closed and merged PRs after their base branch is deleted, using the stored base commit when the current branch is gone. Open PRs still require the current base target, and cancellation refuses a closed or merged PR before changing anything.

Pi retries orphan cleanup when process inspection recovers, including when inspection failed at the delayed SIGKILL check. Each signal checks process identity, termination attempts do not overlap, and the agent can resume only after the previous process exits. Both fixes are in [#247](https://github.com/michael-denyer/pstack-claude/pull/247).

## 0.9.80 - compare the base branch's current commit

`watch-pr` and `ship-pr` read the base branch's current commit from `baseRef { target { oid } }`. They read the pull request's `baseRefOid` before, a value GitHub records when the PR opens and does not always move when the base branch advances, so a base that moved between two reads could pass the snapshot check and the `cancel-pending` comparison. The watcher takes the head and base facts from one GraphQL query, so both come from one observation. An open PR whose base ref is unavailable is a query failure, not a landing record. @mshk found and fixed this in [#245](https://github.com/michael-denyer/pstack-claude/pull/245), closing [#244](https://github.com/michael-denyer/pstack-claude/issues/244).

The watcher unwraps every GraphQL response in one place, so a response carrying `errors` fails all five queries the same way. The empty review decision that `gh pr view` reported is gone with the command that reported it: GraphQL returns null, and an empty string is now rejected like any other unknown value.

## 0.9.79 - dequeue a queued PR, and fixes from three models and a code review

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.8
1.0.9
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "t3-pstack",
"private": true,
"version": "1.0.8",
"version": "1.0.9",
"description": "If you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence. Pi package of the Claude Code plugin; skills are shared, tool names resolve via skills/poteto-mode/references/pi-tools.md. Original pstack by Lauren Tan (poteto).",
"author": {
"name": "Christian Bager Bach Houmann",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pstack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "t3-pstack",
"displayName": "t3-pstack (pstack for T3 Code)",
"version": "1.0.8",
"version": "1.0.9",
"description": "pstack for T3 Code. Rigorous agent workflows whose subagents, panels, schedules, and PR watches dispatch through T3's orchestration, so panels mix Claude, GPT, and Grok. Original pstack by Lauren Tan (poteto); Claude Code port by Michael Denyer.",
"author": {
"name": "Christian Bager Bach Houmann",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pstack/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "t3-pstack",
"version": "1.0.8",
"version": "1.0.9",
"description": "pstack for T3 Code on a Codex host. Subagents, panels, schedules, and PR watches dispatch through T3's orchestration (skills/poteto-mode/references/t3-tools.md); other tool names resolve via codex-tools.md. Original pstack by Lauren Tan (poteto); Claude Code port by Michael Denyer.",
"author": {
"name": "Christian Bager Bach Houmann",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pstack/.github/plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "t3-pstack",
"displayName": "t3-pstack (no Copilot support)",
"version": "1.0.8",
"version": "1.0.9",
"description": "t3-pstack runs only inside T3 Code; on GitHub Copilot install pstack@pstack-claude from michael-denyer/pstack-claude.",
"author": {
"name": "Christian Bager Bach Houmann",
Expand Down
27 changes: 19 additions & 8 deletions plugins/pstack/pi/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ interface Run {

// A remote agent runs under the live pi process that restore left it to.
type LocalAgent = { kind: "local"; record: RunningRecord; run: Run };
// Unverified restores retry identity checks; interrupted ones await termination.
type AgentState = LocalAgent
| { kind: "remote"; record: RunningRecord }
| { kind: "unverified"; record: RunningRecord }
| { kind: "interrupted"; record: RunningRecord }
| { kind: "ended"; record: EndedRecord };

Expand Down Expand Up @@ -169,8 +171,8 @@ function fateOf(record: RunningRecord): Fate {

// Stops an orphan, by its group so the bash command it has running goes too.
// No other fate is signalled: a pid that may have been reused is not ours to hit.
function reapOrphan(record: RunningRecord, killGraceMs: number): void {
if (record.pid !== undefined) terminateGroup(record.pid, () => fateOf(record) === "orphan", killGraceMs);
function reapOrphan(record: RunningRecord, killGraceMs: number): Promise<void> {
return record.pid === undefined ? Promise.resolve() : terminateGroup(record.pid, () => fateOf(record) === "orphan", killGraceMs);
}

const now = () => new Date().toISOString();
Expand Down Expand Up @@ -312,12 +314,12 @@ export class AgentRunner {

// An agent this process can act on. One running under another pi process is
// not: only that process holds its stdin and can message or stop it.
private owned(to: string): Exclude<AgentState, { kind: "remote" | "interrupted" }> {
private owned(to: string): Exclude<AgentState, { kind: "remote" | "unverified" | "interrupted" }> {
const state = this.find(to);
if (state.kind === "remote") {
throw new Error(`Agent ${state.record.agent.id} is running under another pi process (pid ${state.record.parentPid}); only that process can message or stop it.`);
}
if (state.kind === "interrupted") {
if (state.kind === "unverified" || state.kind === "interrupted") {
throw new Error(`Agent ${state.record.agent.id}'s previous process (pid ${state.record.pid}) has not exited; it cannot resume or be stopped here until its exit is confirmed.`);
}
return state;
Expand Down Expand Up @@ -380,10 +382,19 @@ export class AgentRunner {
if (fate === "kept" && state.kind === "remote") return state;
if (fate !== "gone") {
if (state.kind === "interrupted") return state;
const interrupted: AgentState = { kind: "interrupted", record };
this.agents.set(record.agent.id, interrupted);
reapOrphan(record, this.settings.killGraceMs);
return interrupted;
const restored: AgentState = {
kind: fate === "orphan" ? "interrupted" : "unverified",
record,
};
this.agents.set(record.agent.id, restored);
if (restored.kind === "interrupted") {
void reapOrphan(record, this.settings.killGraceMs).then(() => {
if (this.agents.get(record.agent.id) === restored) {
this.agents.set(record.agent.id, { kind: "unverified", record });
}
});
}
return restored;
}
const stopped: EndedRecord = {
agent: record.agent,
Expand Down
13 changes: 10 additions & 3 deletions plugins/pstack/pi/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,15 @@ export function signalGroup(pid: number, signal: NodeJS.Signals): void {

// SIGTERM to the group now, SIGKILL if `ours` still holds after the grace
// period. `ours` guards both signals, since a reused pid is not ours to signal.
export function terminateGroup(pid: number, ours: () => boolean, graceMs: number): void {
if (!ours()) return;
// Resolves when the signal attempt ends, not when the process exits. A restored
// orphan can retry if either identity check was unavailable during this attempt.
export function terminateGroup(pid: number, ours: () => boolean, graceMs: number): Promise<void> {
if (!ours()) return Promise.resolve();
signalGroup(pid, "SIGTERM");
setTimeout(() => ours() && signalGroup(pid, "SIGKILL"), graceMs).unref();
return new Promise((resolve) => {
setTimeout(() => {
if (ours()) signalGroup(pid, "SIGKILL");
resolve();
}, graceMs).unref();
});
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type {
Check,
ChecksFastPath,
Expand Down Expand Up @@ -100,11 +100,15 @@
async pullRequest(requested) {
calls.push("pullRequest");
reads += 1;
const facts = {
...defaults,
...options.facts,
...(reads > 1 ? options.factsOnReread : {}),
};
return parsePullRequest(
{
...defaults,
...options.facts,
...(reads > 1 ? options.factsOnReread : {}),
...facts,
baseRef: { target: { oid: facts.baseRefOid } },
},
requested
);
Expand Down
40 changes: 34 additions & 6 deletions plugins/pstack/skills/poteto-mode/scripts/watch-pr/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { describe, expect, it } from "bun:test";
import { WatchDeadline } from "./deadline.ts";
import {
Expand All @@ -9,6 +9,7 @@
parseFastCheck,
parsePullRequest,
parseReviewThreads,
PR_FACTS_QUERY,
resolveChecks,
resolveContext,
runJson,
Expand Down Expand Up @@ -299,7 +300,7 @@
mergeStateStatus: "CLEAN",
reviewDecision: "APPROVED",
headRefOid: "head",
baseRefOid: "base",
baseRef: { target: { oid: "base" } },
headRefName: "feature",
baseRefName: "main",
state: "OPEN",
Expand All @@ -316,11 +317,10 @@
).toBe("CONFLICTING");
});

it("reads gh's empty reviewDecision as no decision rather than a parse failure", () => {
expect(
it("rejects an empty reviewDecision like any other unknown enum value", () => {
expect(() =>
parsePullRequest({ ...rawPullRequest, reviewDecision: "" }, context)
.reviewDecision
).toBeNull();
).toThrow(WatcherQueryError);
});

it("still rejects an unknown reviewDecision", () => {
Expand All @@ -329,6 +329,32 @@
).toThrow(WatcherQueryError);
});

it("uses the current base target when the scalar base OID is stale", () => {
expect(
parsePullRequest(
{
...rawPullRequest,
baseRefOid: "stale-base",
baseRef: { target: { oid: "current-base" } },
},
context
).baseRefOid
).toBe("current-base");
});

it("queries the current base target with the other PR facts", () => {
expect(PR_FACTS_QUERY).toContain("baseRef { target { oid } }");
expect(PR_FACTS_QUERY).not.toContain("baseRefOid");
});

it("fails closed when the current base ref is null or missing", () => {
const missingBaseRef = Object.fromEntries(
Object.entries(rawPullRequest).filter(([key]) => key !== "baseRef")
);
for (const value of [{ ...rawPullRequest, baseRef: null }, missingBaseRef])
expect(() => parsePullRequest(value, context)).toThrow(WatcherQueryError);
});

it("rejects unknown enum values as retryable errors carrying the raw value", () => {
try {
parsePullRequest(
Expand Down Expand Up @@ -406,7 +432,9 @@
},
},
};
const threads = parseReviewThreads(response);
const threads = parseReviewThreads(
response.data.repository.pullRequest.reviewThreads.nodes
);
expect(threads).toHaveLength(2);
expect(threads.map((thread) => thread.isBugbot)).toEqual([true, true]);
expect(threads.map((thread) => thread.bugbotReviewPasses)).toEqual([3, 3]);
Expand Down
Loading
Loading