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
98 changes: 98 additions & 0 deletions evidence/slice-AA-local-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Slice AA local verification

The implementation is committed without a push. Full-suite verification remains blocked in this sandbox; live GitHub/agent acceptance was not run.

## SDK typecheck, test typecheck, and build

Working directory: `/Users/khaliqgant/fl-slice-AA/packages/sdk`

```sh
npm run typecheck && npm run typecheck:tests && npm run build
```

Exit code: 0. Captured output:

```text

> @relayflows/sdk@2.0.8 typecheck
> tsc --noEmit && tsc -p tsconfig.type-tests.json


> @relayflows/sdk@2.0.8 typecheck:tests
> tsc -p tsconfig.tests.json


> @relayflows/sdk@2.0.8 build
> tsc && node scripts/make-cli-executable.mjs

```

## Surface typecheck and tests

Working directory: `/Users/khaliqgant/fl-slice-AA/packages/surface`

```sh
npm run typecheck && ./node_modules/.bin/tsc -p tsconfig.test.json && ./node_modules/.bin/vitest run
```

Exit code: 0. Captured output:

```text

> @relayflows/surface@2.0.8 typecheck
> tsc --noEmit


RUN v2.1.9 /Users/khaliqgant/fl-slice-AA/packages/surface

✓ tests/triggers.test.ts (4 tests) 3ms
✓ tests/flow.test.ts (20 tests) 7ms
✓ tests/helpers.snapshot.test.ts (1 test) 215ms

Test Files 3 passed (3)
Tests 25 passed (25)
Start at 00:23:13
Duration 449ms (transform 58ms, setup 0ms, collect 90ms, tests 224ms, environment 0ms, prepare 91ms)

```

## Flow import validation

Working directory: `/Users/khaliqgant/fl-slice-AA/packages/sdk`

```sh
node dist/cli.js check scripts/dogfood/close-pr.flow.ts --json
```

Exit code: 0. Captured output:

```text
{"ok":true,"gates":[],"resolutions":[],"diagnostics":[],"mcpTools":{},"plugins":[],"path":"scripts/dogfood/close-pr.flow.ts"}
```

## Full SDK suite

Working directory: `packages/sdk` in this checkout.

```sh
PATH=/Users/khaliqgant/.bun/bin:/Users/khaliqgant/.cargo/bin:$PATH RELAYFLOWD_BIN=/private/tmp/slice-AA-cargo-target/debug/relayflowd RELAYFLOWS_ALLOW_ANALYZER_SKIP=1 ./node_modules/.bin/vitest run > /private/tmp/slice-AA-sdk-tests-final.txt 2>&1
```

Exit code: 1. Captured result lines:

```text
✓ tests/close-pr-flow.test.ts (27 tests) 3461ms

Test Files 24 failed | 56 passed | 1 skipped (81)
Tests 180 failed | 1133 passed | 11 skipped (1324)
Errors 14 errors
Start at 00:22:05
Duration 58.72s (transform 1.17s, setup 0ms, collect 9.28s, tests 263.31s, environment 7ms, prepare 2.27s)

```

Full captured output on this machine: `/private/tmp/slice-AA-sdk-tests-final.txt`.

The sandbox rejects Unix/TCP listeners with EPERM. The full run also reports EMFILE from file watching. The new real-daemon handoff test could not reach its assertions because the daemon could not bind its socket. The 27 close-loop tests use simulated GitHub/agent/journal transport, with real compiler and authored executor code; one also executes the generated commit and force-push commands against a temporary local Git remote. They do not contact GitHub.

The authored needs_human outcome is a journaled handoff, not a resumable kernel wait; the current authored runner has no durable root.
64 changes: 64 additions & 0 deletions packages/sdk/scripts/dogfood/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# PR close loop

`close-pr.flow.ts` is the companion to slice implementation. Invoke it after
the slice branch has been pushed. Running this flow opts into repair commits,
force-with-lease pushes, and squash merge with branch deletion.

Set `IMPL_CLOSE_INPUT` to JSON in the **daemon's environment** (deterministic
commands inherit that environment):

```json
{
"worktree": "/absolute/path/to/slice-worktree",
"repo": "owner/repository",
"branch": "feat/slice",
"base": "main",
"title": "Implement slice",
"body": "Closes #123",
"cli": "codex",
"model": "your-configured-model"
}
```

Launch with `flows run packages/sdk/scripts/dogfood/close-pr.flow.ts --input '{}'`
using the daemon carrying that input. Alternatively, save the JSON in a file and
pass `--input close-input.json`; this is also captured in a journaled step and
does not require setting the daemon's environment. The worktree must be clean
and on the named branch.
`gh` must be authenticated with access to checks, Actions logs, review threads,
PR creation and merging. Attach a workspace-capable agent worker holding the
worktree's revision pins, launched from that worktree. The stream-only
`--local-agent` worker cannot accept a workspace declaration in this SDK.

Optional `prNumber` selects an existing PR; otherwise the flow looks up the open
PR for the branch before creating one. `cli` defaults to `codex`; the model is
passed through to normal SDK preflight. `pollIntervalSeconds` defaults to 15
and `maxPolls` to 120 per pushed head. Three **repair attempts** are allowed;
pending polls do not consume them, and the third repair is re-verified.

Each poll reads checks, paginated Bugbot review comments, and paginated review
thread resolution state through separate `f.run` effects. A completed check
whose name contains `Bugbot` is required, so missing/delayed reviews cannot look
green. Failed/canceled checks and Medium/High/Critical (or P0–P2) Bugbot comments
block merging. Low findings and other bots' comments are ignored. Unresolved
findings remain blocking even on old commits or outdated diff lines; only a
resolved thread clears a finding. Bugbot must resolve addressed threads on
re-review, or the run hands them to a human after its repair budget.

Failed GitHub Actions checks supply `gh run view --log-failed` output to the
repair agent. Other check providers supply their description and link. The
agent edits the same declared worktree, then deterministic steps commit changes
and push. The flow checks the PR head before and after each snapshot and passes
`--match-head-commit` to merge. It confirms `MERGED`, since `gh pr merge` can
instead enqueue a PR. A queued merge is handed off for human follow-up.

Exhaustion prints accumulated blockers in a journaled effect and calls
`f.done('needs_human')`. The authored executor records this handoff in a terminal
effect and the CLI returns exit 3 / `parked`. This is an **authored handoff**, not
a new kernel completion reason or a resumable kernel wait. The current authored
executor has no durable root: effects have journals, but restarting the whole
script does not replay them automatically. Re-invocation reuses the open PR.

Local tests simulate GitHub, the repair agent and journal transport through the
real authored executor. They do not constitute a live GitHub/agent
acceptance run. The CLI handoff also has a test against the real local daemon.
143 changes: 143 additions & 0 deletions packages/sdk/scripts/dogfood/close-pr-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { isAbsolute } from 'node:path';

export const MAX_REPAIR_ITERATIONS = 3;

// Sleep between polls runs inside a f.run step whose default kernel lease is
// 30 s (see flows#343 / slice W for the per-step timeout override). Any poll
// interval larger than that either times out the step or, worse, sleeps under
// a lease renewal window and races. Cap silently so a caller who sets
// `pollIntervalSeconds: 60` still gets a poll cycle instead of a step_failed.
export const MAX_POLL_INTERVAL_SECONDS = 25;

export interface ClosePrInput {
worktree: string;
repo: string;
branch: string;
base?: string;
title?: string;
body?: string;
prNumber?: number;
cli?: string;
model?: string;
pollIntervalSeconds?: number;
maxPolls?: number;
}

export function parseInput(raw: string): ClosePrInput {
const input = JSON.parse(raw) as ClosePrInput;
if (!input || typeof input !== 'object') throw new Error('IMPL_CLOSE_INPUT must be a JSON object');
for (const key of ['worktree', 'repo', 'branch'] as const) {
if (typeof input[key] !== 'string' || !input[key].trim() || input[key].includes('\0')) {
throw new Error(`IMPL_CLOSE_INPUT.${key} must be a nonempty string`);
}
}
if (!isAbsolute(input.worktree)) throw new Error('worktree must be an absolute path');
if (!/^[\w.-]+\/[\w.-]+$/.test(input.repo)) throw new Error('repo must be OWNER/REPO');
if (input.branch.startsWith('-')) throw new Error('branch must not start with -');
for (const key of ['base', 'title', 'body', 'cli', 'model'] as const) {
if (input[key] !== undefined && (typeof input[key] !== 'string' || input[key].includes('\0'))) {
throw new Error(`${key} must be a string`);
}
}
for (const key of ['prNumber', 'maxPolls', 'pollIntervalSeconds'] as const) {
if (input[key] !== undefined && (!Number.isSafeInteger(input[key]) || input[key] < 1)) {
throw new Error(`${key} must be a positive integer`);
}
}
if (input.pollIntervalSeconds !== undefined) {
input.pollIntervalSeconds = Math.min(input.pollIntervalSeconds, MAX_POLL_INTERVAL_SECONDS);
}
return input;
}

export function quote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

export function parsePrNumber(output: string): number {
const match = output.trim().match(/^(?:https:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/)?([1-9]\d*)\/?$/);
const number = Number(match?.[1]);
if (!Number.isSafeInteger(number) || number < 1) throw new Error(`Invalid PR number: ${output}`);
return number;
}

export interface Check {
name: string;
bucket: 'pass' | 'fail' | 'pending' | 'skipping' | 'cancel';
link: string;
description: string;
}

export interface BotComment {
id: number;
body: string;
path: string;
html_url: string;
user: { login: string };
}

export interface ReviewThread {
isResolved: boolean;
isOutdated: boolean;
commentId: number;
}

export interface Finding {
kind: 'ci' | 'bugbot';
message: string;
link: string;
}

/** Parse API data fail-closed: empty/malformed output must never mean green. */
export function parseChecks(raw: string): Check[] {
const checks = JSON.parse(raw) as Check[];
if (!Array.isArray(checks) || checks.some(check => !check || typeof check.name !== 'string'
|| !['pass', 'fail', 'pending', 'skipping', 'cancel'].includes(check.bucket)
|| typeof check.link !== 'string' || typeof check.description !== 'string')) {
throw new Error('Invalid gh pr checks response');
}
return checks;
}

export function analyzeFindings(checks: Check[], commentsRaw: string, threadsRaw: string) {
const comments = JSON.parse(commentsRaw) as BotComment[];
const threads = JSON.parse(threadsRaw) as ReviewThread[];
if (!Array.isArray(comments) || comments.some(comment => !comment || !Number.isSafeInteger(comment.id)
|| typeof comment.body !== 'string' || typeof comment.user?.login !== 'string'
|| typeof comment.path !== 'string' || typeof comment.html_url !== 'string')) {
throw new Error('Invalid PR comments response');
}
if (!Array.isArray(threads) || threads.some(thread => !thread || !Number.isSafeInteger(thread.commentId)
|| typeof thread.isResolved !== 'boolean' || typeof thread.isOutdated !== 'boolean')) {
throw new Error('Invalid PR review threads response');
}
const findings: Finding[] = checks.filter(check => ['fail', 'cancel'].includes(check.bucket))
.map(check => ({ kind: 'ci', message: `${check.name}: ${check.bucket}. ${check.description}`, link: check.link }));
for (const comment of comments) {
if (!/^(cursor|bugbot)(\[bot\])?$/i.test(comment.user.login)) continue;
const text = comment.body.replace(/<img\b[^>]*\balt=["']([^"']*)["'][^>]*>/gi, '$1')
.replace(/<[^>]*>/g, ' ').replace(/[*_`]/g, '');
if (!/\b(?:medium|high|critical)\s+(?:severity|priority)\b|\bseverity\s*:\s*(?:medium|high|critical)\b|\bP[012]\b/i.test(text)) continue;
const thread = threads.find(thread => thread.commentId === comment.id);
// Missing thread metadata is blocking. Old commit_id alone is not proof of a fix.
if (thread?.isResolved) continue;
findings.push({ kind: 'bugbot', message: `${comment.path}: ${comment.body}`, link: comment.html_url });
}
const pending = checks.length === 0 || checks.some(check => check.bucket === 'pending');
const bugbotReviewed = checks.some(check => /bugbot/i.test(check.name) && ['pass', 'fail', 'cancel'].includes(check.bucket));
return { findings, pending: pending || !bugbotReviewed };
}

/** gh returns 1 for failed checks and 8 for pending checks; neither is an effect failure. */
export function checksCommand(pr: number, repo: string): string {
return `gh pr checks ${pr} --repo ${quote(repo)} --json name,bucket,link,description; `
+ 'close_status=$?; case "$close_status" in 0|1|8) exit 0 ;; *) exit "$close_status" ;; esac';
}

export function failedRunId(link: string, repo: string): string | undefined {
let url: URL;
try { url = new URL(link); } catch { return undefined; }
if (url.hostname !== 'github.com') return undefined;
const prefix = `/${repo}/actions/runs/`;
return url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length).match(/^([0-9]+)(?:\/|$)/)?.[1] : undefined;
}
Loading
Loading