Skip to content

Commit b9161a5

Browse files
feat(otdf-sdk-mgr,.claude): idempotent orchestrate run + --force flag (DSPX-3302)
Re-running `orchestrate run` after a partial failure now resumes from where it left off: cells whose branch already has an open PR are skipped (reported as OK with the existing URL), and only failed/pending cells are dispatched. - Add check_existing_pr() helper (gh pr list --head <branch>) - run_cell() checks for existing PR before creating worktree or launching subagent - --force flag bypasses the idempotency check (re-run even with existing PR) - Dry-run annotates already-done cells with [PR EXISTS: <url>] - SKILL.md: update Step 1/2 + replace "When to use partial runs" with "Resumption and partial runs" covering fix-and-retry, staging, and --force Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 95cd951 commit b9161a5

2 files changed

Lines changed: 50 additions & 7 deletions

File tree

.claude/skills/feature-orchestrate/SKILL.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Before dispatching, run a dry-run so the user can confirm the topology and pinni
2626
uv run otdf-sdk-mgr orchestrate run xtest/features/<name>.yaml --dry-run
2727
```
2828

29-
The output names each cell, its target repo path, the branch it'll work on, and the worktree path the orchestrator will create (or `action=push+draft-PR` for the `tests` cell). Each wave is a set of cells with no dependencies between them; cells in a later wave have at least one `depends_on` edge into an earlier wave.
29+
The output names each cell, its target repo path, the branch it'll work on, and the worktree path the orchestrator will create (or `action=push+draft-PR` for the `tests` cell). Cells that already have an open PR are annotated with `[PR EXISTS: <url>]` — those will be skipped on the real run (no subagent launched). Each wave is a set of cells with no dependencies between them; cells in a later wave have at least one `depends_on` edge into an earlier wave.
3030

3131
Surface the dry-run output to the user verbatim. If anything looks wrong (a cell going to the wrong repo, a missing `depends_on` edge, a stale branch name), ask the user to fix the spec via `feature-design` (or edit it directly) before proceeding.
3232

@@ -38,7 +38,9 @@ When the user confirms, run for real:
3838
uv run otdf-sdk-mgr orchestrate run xtest/features/<name>.yaml
3939
```
4040

41-
For each cell, the orchestrator:
41+
The run is **idempotent**: cells whose branch already has an open PR are reported as `OK` with the existing PR URL and no subagent is launched. Re-running after a partial failure resumes from where it left off — already-done cells are skipped, failed or not-yet-started cells are dispatched. Use `--force` to re-run a cell even if it already has a PR (e.g. to incorporate spec changes into an existing draft).
42+
43+
For each cell that needs work, the orchestrator:
4244

4345
1. Creates `~/Documents/GitHub/worktrees/<JIRA-KEY>-<cell-key>/` as a worktree of `~/Documents/GitHub/opentdf/<path>` on branch `<cell.branch>`. Idempotent — reuses an existing worktree if it's already on the right branch, bails if it's on a different one.
4446
2. Writes a minimal `.claude/settings.json` into the worktree (allowing `git`, `gh pr create`, and the repo-type-appropriate test commands: `go`/`make`/`buf` for platform, `mvn` for java-sdk, `npm` for web-sdk).
@@ -62,10 +64,18 @@ web-sdk FAIL exit 1
6264

6365
Pass the table on to the user, plus the JSONL transcript paths for any FAIL rows so they can inspect what went wrong.
6466

65-
## When to use partial runs
67+
## Resumption and partial runs
68+
69+
**Resuming after a failure** — just re-run the same command. The orchestrator skips cells that already have open PRs and only dispatches the ones that failed or were skipped due to upstream failures. The dependency check still runs: if a cell's `depends_on` failed in the previous wave, it will be skipped again rather than dispatched into a broken state. Fix the upstream cell first (see below), then re-run.
70+
71+
**Fixing a failed cell** — inspect the transcript at `.claude/tmp/runs/<JIRA-KEY>-<cell-key>.jsonl`, identify the problem, fix it (in the worktree at `~/Documents/GitHub/worktrees/<JIRA-KEY>-<cell-key>/` or by patching the spec), then re-run. If the fix was in the worktree (e.g. the subagent left partial commits), you may want `--only <cell-key>` to avoid redundant PR-existence checks across many cells.
6672

67-
- `--only platform-proto` — proto change has to ship before anything else can adopt the new bindings. Run the proto cell alone first, review the PR, merge it, then run the rest.
68-
- `--only java-sdk` — re-launch a single failed cell after fixing whatever broke. The dependency check still runs; if `java-sdk`'s `depends_on` failed earlier, the orchestrator will refuse rather than racing.
73+
**Staging a cell before its dependents**`--only platform-proto` runs only the proto cell. Once its PR is reviewed and merged, re-run without `--only` and the orchestrator picks up from the next wave (proto cell already has a PR, so it's skipped; service/SDK cells proceed).
74+
75+
**Forcing a re-run**`--force` makes the orchestrator ignore existing PRs and dispatch a fresh subagent for every non-skipped cell. Combine with `--only` to force just one cell:
76+
```bash
77+
uv run otdf-sdk-mgr orchestrate run xtest/features/<name>.yaml --only java-sdk --force
78+
```
6979

7080
## Notes
7181

otdf-sdk-mgr/src/otdf_sdk_mgr/cli_orchestrate.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,19 @@ def build_prompt(spec: FeatureSpec, cell: Cell) -> str:
299299
PR_URL_RE = re.compile(r"https://github\.com/[^\s]+/pull/\d+")
300300

301301

302+
def check_existing_pr(repo: Path, branch: str) -> str | None:
303+
"""Return the URL of an open PR for this branch in the given repo, or None."""
304+
result = subprocess.run(
305+
["gh", "pr", "list", "--head", branch, "--json", "url", "--jq", ".[0].url"],
306+
cwd=str(repo),
307+
capture_output=True,
308+
text=True,
309+
)
310+
if result.returncode == 0 and result.stdout.strip():
311+
return result.stdout.strip()
312+
return None
313+
314+
302315
@dataclass
303316
class CellResult:
304317
cell: Cell
@@ -316,7 +329,16 @@ def run_cell(
316329
transcripts_dir: Path,
317330
timeout_s: int,
318331
model: str,
332+
force: bool = False,
319333
) -> CellResult:
334+
# Idempotency: skip cells whose branch already has an open PR, unless --force.
335+
if not force:
336+
repo = OPENTDF_ROOT / (cell.path or "")
337+
if repo.is_dir():
338+
existing_pr = check_existing_pr(repo, cell.branch)
339+
if existing_pr:
340+
return CellResult(cell, Path(), Path(), True, existing_pr, None)
341+
320342
try:
321343
wt = ensure_worktree(spec, cell)
322344
except Exception as e:
@@ -442,6 +464,10 @@ def run(
442464
model: Annotated[
443465
str, typer.Option("--model", help="Sub-agent model alias.")
444466
] = "sonnet",
467+
force: Annotated[
468+
bool,
469+
typer.Option("--force", help="Re-run cells even if they already have open PRs."),
470+
] = False,
445471
transcripts_dir: Annotated[
446472
Path,
447473
typer.Option(
@@ -481,14 +507,20 @@ def run(
481507
typer.echo(f" Wave {i}:")
482508
for cell in wave:
483509
if cell.key == "tests":
510+
existing = check_existing_pr(TESTS_REPO, cell.branch)
511+
pr_note = f" [PR EXISTS: {existing}]" if existing else ""
484512
typer.echo(
485513
f" - {cell.key}: path=(tests repo) branch={cell.branch}"
486-
f" action=push+draft-PR"
514+
f" action=push+draft-PR{pr_note}"
487515
)
488516
else:
517+
repo = OPENTDF_ROOT / (cell.path or "")
518+
existing = check_existing_pr(repo, cell.branch) if repo.is_dir() else None
519+
pr_note = f" [PR EXISTS: {existing}]" if existing else ""
489520
wt = worktree_for(spec, cell)
490521
typer.echo(
491-
f" - {cell.key}: path={cell.path} branch={cell.branch} worktree={wt}"
522+
f" - {cell.key}: path={cell.path} branch={cell.branch}"
523+
f" worktree={wt}{pr_note}"
492524
)
493525
return
494526

@@ -512,6 +544,7 @@ def _dispatch(c: Cell) -> CellResult:
512544
transcripts_dir=transcripts_dir,
513545
timeout_s=timeout_s,
514546
model=model,
547+
force=force,
515548
)
516549

517550
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(runnable))) as ex:

0 commit comments

Comments
 (0)