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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ Three location classes decide what survives:

| Path | Class | Holds | Written by | Read by | Removed by |
|---|---|---|---|---|---|
| `.orca/runs/<key>.progress.json` | committed | `ProgressLog`: header (branches, `branchMode`, `startingCommit`, `userPrompt`, `flow`), one `StageEntry` per completed stage (`id` as `StagePath` segments, `resultJson`), `published` | `ProgressStore` (`FlowLifecycle.freshRun`, `stage.scala`'s `recordAndCommit`, `recordOpenedPr`) | `stage.scala`'s `resumeFrom`, `RecoveryCheck`, `FlowLifecycle`, shell `ResumeDetector` (header) | success teardown, in a final commit |
| `.orca/runs/<key>.progress.json` | committed | `ProgressLog`: header (branches, `branchMode`, `startingCommit`, `userPrompt`, `flow`, `prReadiness`), one `StageEntry` per completed stage (`id` as `StagePath` segments, `resultJson`), `published` | `ProgressStore` (`FlowLifecycle.freshRun`, `stage.scala`'s `recordAndCommit`, `recordOpenedPr`) | `stage.scala`'s `resumeFrom`, `RecoveryCheck`, `FlowLifecycle`, shell `ResumeDetector` (header) | success teardown, in a final commit |
| `.orca/cache/runs/<key>/events.jsonl` | cache | the run's append-only `RunEvent` log, one JSON line per event: `AttemptStarted`, `BranchBound`, `StageStarted`/`StageEnded`, `SessionMinted`/`SessionWireId` (session records), `SessionCommitted`, `Turn` (token usage and cost), `RunSucceeded`, `AttemptFinished`; a public format, documented in docs/using/output-and-files.md | `RunEventLog`: the run's `SessionStore` and an `OrcaListener` | `RunEventLog` at attempt start (session records after the last `RunSucceeded`), shell `ContinuableAttempts` → session picker / `orca continue` (attempts with no session are left out), people and scripts | pruning at attempt start, by run directory: never the current run or one with a `SessionMinted` after its last `RunSucceeded`; else newest 20 runs with a session ∪ newest 20 of any kind |
| `.orca/cache/runs/<key>/<id>.trace.log` (+ `.trace.1.log`) | cache | DEBUG trace of logger `orca`: prompts, agent output, tool calls; 4 MB roll | `OrcaLog` | people (path in the banner and in `AttemptStarted.trace`) | pruned with its run directory; across all run directories, all but the newest 40 attempts' traces at attempt start |
| `.orca/cache/flow.lock` | cache | an OS file lock, held for the run; holder pid | `FlowLock` | `FlowLock` on contention | never; the OS releases the lock when the holder exits |
Expand Down
11 changes: 6 additions & 5 deletions docs/api/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ and `issue` an `IssueHandle`.
|---|---|
| `availability()` | probes whether a PR can be opened from this checkout |
| `prHandle(ref)` | a `PrHandle` from a PR URL or `owner/repo#N`; `Either[String, PrHandle]` |
| `createPr(title, body)` | opens a PR; `Either[PrCreateFailed, PrHandle]` |
| `createPr(title, body, readiness?)` | opens a PR, as a draft with `Some(PrReadiness.Draft)` or, when omitted, under `--draft`; `Either[PrCreateFailed, PrHandle]` |
| `updatePr(pr, title, body)` | replaces a PR's title and body |
| `readIssue(issue)`, `readIssueComments(issue)`, `readPrComments(pr)` | read an issue, its comments, or a PR's comments |
| `writeComment(pr, body)`, `writeComment(issue, body)` | posts a comment |
Expand All @@ -74,10 +74,11 @@ and `issue` an `IssueHandle`.
returns `Either[BuildWaitFailed, BuildStatus]`.

`createPr` is idempotent by branch: if a PR is already open for the branch, it
returns that one. `upsertComment` finds a prior comment carrying `marker` and
edits it in place, posting a new one only when there is none. `updatePr` is
harmless to re-run. This idempotency is what makes a resumed stage safe, see
[Pull requests](../authoring/pull-requests.md).
returns that one as it is: `readiness` applies only to a PR this call opens, and
never changes the state of an existing one. `upsertComment` finds a prior
comment carrying `marker` and edits it in place, posting a new one only when
there is none. `updatePr` is harmless to re-run. This idempotency is what makes
a resumed stage safe, see [Pull requests](../authoring/pull-requests.md).

## `fs`

Expand Down
11 changes: 9 additions & 2 deletions docs/authoring/pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ There are two calls that open a PR, and one that only writes its description.

### `openPrIfGitHub`

`openPrIfGitHub(summarisingAgent, openFindings, title?, body?, context?, instructions?): Option[PrHandle]`
`openPrIfGitHub(summarisingAgent, openFindings, title?, body?, context?, instructions?, readiness?): Option[PrHandle]`
pushes the branch, summarises the change and opens the PR, provided that
`gh.availability` says the checkout is on a reachable GitHub. Otherwise it
emits one step saying why and returns `None`; the run then finishes. This is
Expand All @@ -23,7 +23,7 @@ included.

### `openPrFromBranch`

`openPrFromBranch(summarisingAgent, openFindings, title?, body?, context?, instructions?): PrHandle`
`openPrFromBranch(summarisingAgent, openFindings, title?, body?, context?, instructions?, readiness?): PrHandle`
runs the same three stages, but fails the run when there is no GitHub remote
or `gh` login.

Expand All @@ -39,6 +39,13 @@ Both open calls create stages, so they cannot be called inside a `stage(...)`
body. Call them at the top level of the flow.
```

### Draft PRs

Both open calls, like `gh.createPr`, open a draft when the run gets `--draft`,
and a ready PR otherwise. A flow can override this with
`readiness = Some(PrReadiness.Draft)` or `Some(PrReadiness.Ready)`. It applies only to a PR the run opens: a PR
already open for the branch is reused with its state unchanged.

### Findings, title, body and context

`openFindings` is the final review's
Expand Down
3 changes: 2 additions & 1 deletion docs/using/run-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ A few things are worth knowing:

A re-run with the same prompt finds the progress log and resumes from the first
incomplete stage. If `--branch` names a different branch than the one in the
log, the run is refused. On resume, Orca prints which branch the run is bound
log, the run is refused. A resume keeps the first attempt's `--draft`, whatever
this attempt passes. On resume, Orca prints which branch the run is bound
to, how many stages are already done, and that the interrupted stage's
uncommitted work was dropped. Each
[durable session](../authoring/talking-to-agents.md) that is resumed gets the
Expand Down
1 change: 1 addition & 0 deletions docs/using/shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ job. The commands are:
| `--skip-branch` | continue on the current branch instead of creating one |
| `--keep-changes` | leave uncommitted files in place instead of stashing them |
| `--worktree` | run in a git worktree of this repository instead of the checkout |
| `--draft` | open the run's PR as a draft |
| `--honor-pin` | use the flow's own pinned Orca version |
| `--verbose` | print a stack trace on abort |

Expand Down
17 changes: 11 additions & 6 deletions flow/src/main/scala/orca/pr/openPrFromBranch.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package orca.pr
import orca.{FlowContext, FlowControl, OutsideStage, fail, gh, git, tracedStage}
import orca.agents.Agent
import orca.review.OpenFindings
import orca.tools.PrHandle
import orca.tools.{PrHandle, PrReadiness}

import ox.either.orThrow

Expand Down Expand Up @@ -37,10 +37,13 @@ import ox.either.orThrow
* findings it left unfixed. The same section is printed to the run output,
* also when opening the PR fails.
*
* `readiness` says whether the PR opens as a draft; when omitted, the run's
* `--draft` decides.
*
* `gh.createPr` is idempotent by head branch: a re-run that already opened the
* PR gets the existing handle back rather than failing. Returns that handle,
* recorded as the run's published work through [[recordOpenedPr]] inside the
* create stage.
* PR gets the existing handle back, its draft state untouched, rather than
* failing. Returns that handle, recorded as the run's published work through
* [[recordOpenedPr]] inside the create stage.
*
* Refused inside a stage — at compile time where the call sits in a stage
* body, at run time where it is reached through a helper: opening the PR is a
Expand All @@ -52,7 +55,8 @@ def openPrFromBranch(
title: PrSummary => String = _.title,
body: PrSummary => String = _.body,
context: Option[String] = None,
instructions: String = PrPrompts.Summarise
instructions: String = PrPrompts.Summarise,
readiness: Option[PrReadiness] = None
)(using FlowContext, FlowControl, OutsideStage): PrHandle =
summon[FlowControl].assertAtFlowBody("openPrFromBranch(...)")
reportOpenFindings(openFindings)
Expand All @@ -72,7 +76,8 @@ def openPrFromBranch(
val pr = gh
.createPr(
title = title(summary),
body = bodyWithOpenFindings(body(summary), openFindings)
body = bodyWithOpenFindings(body(summary), openFindings),
readiness = readiness
)
.orThrow
recordOpened(pr)
Expand Down
21 changes: 14 additions & 7 deletions flow/src/main/scala/orca/pr/openPrIfGitHub.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import orca.agents.Agent
import orca.events.OrcaEvent
import orca.progress.ThrowawayBranch
import orca.review.OpenFindings
import orca.tools.{GitHubAvailability, NoDefaultBase, PrHandle}
import orca.tools.{GitHubAvailability, NoDefaultBase, PrHandle, PrReadiness}
import orca.util.TextUtil

import scala.util.control.NonFatal
Expand Down Expand Up @@ -45,7 +45,8 @@ def openPrIfGitHub(
title: PrSummary => String = _.title,
body: PrSummary => String = _.body,
context: Option[String] = None,
instructions: String = PrPrompts.Summarise
instructions: String = PrPrompts.Summarise,
readiness: Option[PrReadiness] = None
)(using
ctx: FlowContext,
control: FlowControl,
Expand All @@ -62,7 +63,8 @@ def openPrIfGitHub(
// threaded through the legs below.
body = summary => bodyWithOpenFindings(body(summary), openFindings),
context = context,
instructions = instructions
instructions = instructions,
readiness = readiness
).fold(skipped, Some(_))

/** [[openPrFromBranch]]'s three stages, the two remote-facing legs under
Expand All @@ -82,15 +84,16 @@ private def pushThenCreate(
title: PrSummary => String,
body: PrSummary => String,
context: Option[String],
instructions: String
instructions: String,
readiness: Option[PrReadiness]
)(using FlowContext, FlowControl): Either[String, PrHandle] =
for
push <- gatedStage(PushStage)(preFlight(base))(_ => pushBestEffort())
_ <- push.value.outcome.left.map(refusalLine(_, push))
summary <- summarise(summarisingAgent, base, context, instructions).left
.map(baseStopReason)
create = tracedStage(CreateStage)(
createBestEffort(title(summary), body(summary))
createBestEffort(title(summary), body(summary), readiness)
)
pr <- create.value.outcome.left.map(refusalLine(_, create))
yield pr
Expand Down Expand Up @@ -147,15 +150,19 @@ private def pushBestEffort()(using FlowContext, WorkspaceWrite): PushResult =
/** [[openPrFromBranch]]'s create with the `gh pr create` under [[bestEffort]],
* as [[pushBestEffort]] is for the push.
*/
private def createBestEffort(title: String, body: String)(using
private def createBestEffort(
title: String,
body: String,
readiness: Option[PrReadiness]
)(using
FlowContext,
FlowControl,
WorkspaceWrite
): CreateResult =
bestEffort(
"could not open a PR",
"open it yourself from the pushed branch"
)(gh.createPr(title = title, body = body)) match
)(gh.createPr(title = title, body = body, readiness = readiness)) match
case Left(reason) => CreateResult.Refused(reason)
// Outside `bestEffort`, so a log that cannot be written fails the run.
case Right(pr) => recordOpened(pr)
Expand Down
7 changes: 5 additions & 2 deletions flow/src/main/scala/orca/progress/ProgressLog.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec
import orca.StagePath
import orca.gitref.{BranchName, CommitHash, Head}
import orca.json.{JsonData, RawJson, given}
import orca.tools.PrReadiness

/** Whether orca minted [[ProgressHeader.branch]] itself or bound to a
* pre-existing one. Gates the throwaway-branch auto-delete
Expand All @@ -25,7 +26,8 @@ enum BranchMode derives JsonData:
* `userPrompt` is the full prompt: the shell's "Resume interrupted run" offer
* relaunches the run byte-identically (ADR 0021 §3 amendment), and
* `RecoveryCheck` refuses a log whose prompt is not the current one. `flow` is
* `None` for a run started outside the shell.
* `None` for a run started outside the shell. `prReadiness` is the first
* attempt's `--draft`, which every resume keeps.
*
* `startingCommit` is the commit HEAD pointed at when the run bound its branch
* — the diff base for a review of everything the whole run changed.
Expand All @@ -38,7 +40,8 @@ case class ProgressHeader(
branchMode: BranchMode,
userPrompt: String,
flow: Option[FlowSource],
startingCommit: CommitHash
startingCommit: CommitHash,
prReadiness: PrReadiness
) derives JsonData:
/** Where the run started, which a successful run may hand HEAD back to. */
def startingHead: Head =
Expand Down
8 changes: 6 additions & 2 deletions flow/src/test/scala/orca/FlowSessionTest.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package orca

import orca.tools.PrReadiness

import munit.FunSuite
import orca.backend.{
AgentResult,
Expand Down Expand Up @@ -232,7 +234,8 @@ class FlowSessionTest extends FunSuite:
BranchMode.Created,
userPrompt = "p",
flow = None,
startingCommit = orca.gitref.CommitHash.from("0" * 40).get
startingCommit = orca.gitref.CommitHash.from("0" * 40).get,
prReadiness = PrReadiness.Ready
)
)
for record <- sessions do sessionStore.upsert(record)
Expand Down Expand Up @@ -754,7 +757,8 @@ class FlowSessionTest extends FunSuite:
BranchMode.Created,
userPrompt = "p",
flow = None,
startingCommit = orca.gitref.CommitHash.from("0" * 40).get
startingCommit = orca.gitref.CommitHash.from("0" * 40).get,
prReadiness = PrReadiness.Ready
)
)
sessionStore.upsert(
Expand Down
5 changes: 4 additions & 1 deletion flow/src/test/scala/orca/TestRun.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package orca

import orca.tools.PrReadiness

import orca.agents.{Agent, BackendTag}
import orca.events.EventDispatcher
import orca.progress.{BranchMode, ProgressHeader, ProgressStore}
Expand Down Expand Up @@ -47,7 +49,8 @@ object TestRun:
BranchMode.Created,
userPrompt = userPrompt,
flow = None,
startingCommit = headCommit
startingCommit = headCommit,
prReadiness = PrReadiness.Ready
)
)
val context = new TestFlowContext(
Expand Down
73 changes: 72 additions & 1 deletion flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
package orca.pr

import munit.FunSuite
import orca.{BoundedDiff, OrcaFlowException, OutsideStage, interceptReported}
import orca.{
BoundedDiff,
FlowContext,
FlowControl,
OrcaFlowException,
OutsideStage,
interceptReported
}
import orca.agents.Agent
import orca.plan.Title
import orca.progress.PublishedWork
import orca.review.{FindingId, OpenFinding, OpenFindings, OpenReason}
Expand All @@ -10,6 +18,7 @@ import orca.tools.{
GitHubAvailability,
PrCreateFailed,
PrHandle,
PrReadiness,
PushFailure
}
import orca.events.{OrcaEvent, OrcaListener}
Expand Down Expand Up @@ -74,6 +83,26 @@ class OpenPrFromBranchTest extends FunSuite:
steps.asScala.toList
)

/** The readiness each `gh.createPr` got while `open` ran over a fresh seeded
* repo, given a summariser to pass on.
*/
private def createPrReadinesses(
open: Agent[?] => (FlowContext, FlowControl) ?=> Any
): List[Option[PrReadiness]] =
val (dir, store) = seededPrRepo()
val readinesses = new ConcurrentLinkedQueue[Option[PrReadiness]]()
val run = prRun(
dir,
store,
_ => (),
new ConcurrentLinkedQueue[String](),
availability = GitHubAvailability.Available("github.com", "acme", "w"),
prReadinesses = readinesses
)
import run.given
val _ = open(new StubSummariser().agent)
readinesses.asScala.toList

private val oneOpen = OpenFindings(
List(
OpenFinding(
Expand Down Expand Up @@ -101,6 +130,48 @@ class OpenPrFromBranchTest extends FunSuite:
List("Push branch", "Generate PR title and description", "Open PR")
)

test("both open calls leave the readiness to gh by default"):
assertEquals(
createPrReadinesses(agent =>
openPrFromBranch(
summarisingAgent = agent,
openFindings = OpenFindings.empty
)
),
List(None)
)
assertEquals(
createPrReadinesses(agent =>
openPrIfGitHub(
summarisingAgent = agent,
openFindings = OpenFindings.empty
)
),
List(None)
)

test("both open calls pass an explicit readiness on"):
assertEquals(
createPrReadinesses(agent =>
openPrFromBranch(
summarisingAgent = agent,
openFindings = OpenFindings.empty,
readiness = Some(PrReadiness.Draft)
)
),
List(Some(PrReadiness.Draft))
)
assertEquals(
createPrReadinesses(agent =>
openPrIfGitHub(
summarisingAgent = agent,
openFindings = OpenFindings.empty,
readiness = Some(PrReadiness.Draft)
)
),
List(Some(PrReadiness.Draft))
)

test("openPrFromBranch reached inside a stage is refused before it pushes"):
// A helper taking only FlowControl carries OutsideStage past the compile
// check; the stage open around it is caught at run time.
Expand Down
Loading
Loading