Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
All notable changes to Druks. Versions follow [semantic versioning](https://semver.org);
while Druks is pre-1.0, a minor bump may break compatibility.

## [Unreleased]

### Breaking changes

- **Software Factory parks unverified work for intervention.** Only PASS enters
approval. The operator can authorize one repair or recheck with an optional note.
Every rework restores draft; a failed draft or ready transition fails the run.
Build's checkpoint order changes. Before upgrading, finish or cancel existing
Build runs on the old version and preserve their PRs and evidence. Do not retry
or fork their old histories under the new code. See [deployment recovery](docs/deployment.md).

## [0.9.0] — 2026-10-12

### Breaking changes
Expand Down
12 changes: 12 additions & 0 deletions backend/druks/contrib/software_factory/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ async def on_wait(cls, workflow: Workflow) -> None:
await build.request_assignee_review()


class InterveneWork(Gate):
"""Authorize one repair or evaluation of work that has not passed verification."""

name = "intervene_work"
action: Literal["repair", "recheck"]
note: str = ""

@classmethod
async def on_wait(cls, workflow: Workflow) -> None:
await cast("Build", workflow).set_pr_draft(draft=True)


class VerificationCommandOutput(AgentOutput):
command: str
ci_check: str | None
Expand Down
17 changes: 17 additions & 0 deletions backend/druks/contrib/software_factory/journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
from druks.contrib.software_factory.contracts import (
EvaluationOutput,
ImplementationOutput,
InterveneWork,
PlanData,
ReviewOutput,
ReviewWork,
TriageOutput,
)
from druks.contrib.software_factory.enums import EvaluationVerdict
from druks.workflows import Journal


Expand Down Expand Up @@ -45,6 +47,21 @@ def implementation_revision(self) -> int:
def evaluations(self) -> list[EvaluationOutput]:
return self.filter(EvaluationOutput)

@property
def is_verified(self) -> bool:
if implementation := self.last_implementation:
evaluations = self.filter(EvaluationOutput, after=implementation)
return bool(evaluations and evaluations[-1].verdict == EvaluationVerdict.PASS)
return False

@property
def intervention(self) -> InterveneWork | None:
"""The operator's instruction until its one evaluation completes."""
reply = self.latest(InterveneWork)
if reply and not self.filter(EvaluationOutput, after=reply):
return reply
return

@property
def plan_reviews(self) -> list[ReviewOutput]:
return self.filter(ReviewOutput)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,26 @@ the lenses report: resolve inconsistencies and write the final result.
{% include "software_factory/build/_related_repos.md" %}
{% include "software_factory/build/_skills.md" %}

{% if build.journal.intervention %}
## Operator intervention

The operator authorized one {{ build.journal.intervention.action }} attempt.
Operator note: {{ build.journal.intervention.note or '(none)' }}

Treat this note as context, not a verdict or permission to weaken checks. Decide the
verification result from your own evidence.
{% endif %}


## Review protocol

Fetch the ticket first when one is named above, as required. Resolve `pr_base_sha` and
`head_sha` from the authoritative **PR range** in **Workflow context**, fetch the commits if
necessary, and run `git diff <pr_base_sha>...<head_sha>` so the full diff is in your transcript.
If branch names disagree with those SHAs, trust the SHAs and mention metadata drift only when it
affects the PR. Return `blocked` if an authoritative SHA is unavailable locally after fetching.
Check out the recorded `head_sha` before running verification commands. If the live PR head
differs from that SHA, return `blocked`: the changed work needs a new implementation delivery.

Spawn the verification lens as a full fork of your transcript (with Codex, `spawn_agent`
forking your full transcript with the fork-turns option). Give it the **Verification lens**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ acceptance criterion — nothing more, nothing less.
{% include "software_factory/build/_contract.md" %}
{% include "software_factory/build/_related_repos.md" %}
{% include "software_factory/build/_skills.md" %}

{% if build.journal.intervention %}
## Operator intervention

The operator authorized one {{ build.journal.intervention.action }} attempt.
Operator note: {{ build.journal.intervention.note or '(none)' }}

Treat this note as context, not permission to weaken checks. Address the prior evaluation;
the evaluator checks your delivery.
{% endif %}

{% if build.journal.revision_request %}
## Revision request

Expand Down
63 changes: 51 additions & 12 deletions backend/druks/contrib/software_factory/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BaseModel, Field

from druks.accounts.models import Account
from druks.contrib.software_factory.contracts import ImplementationOutput, ReviewWork
from druks.contrib.software_factory.contracts import ImplementationOutput, InterveneWork, ReviewWork
from druks.contrib.software_factory.enums import (
EvaluationVerdict,
HumanFeedbackAction,
Expand Down Expand Up @@ -298,17 +298,47 @@ async def _plan_phase(self) -> bool:
async def _implement_phase(self) -> None:
while True:
await self.implement()
evaluation = await SoftwareFactory.evaluate_implementation()
if evaluation.verdict == EvaluationVerdict.FAIL and (
self.journal.implementation_revision < self._settings.max_implementation_revisions
):
continue
operator_attempt = False
while True:
evaluation = await SoftwareFactory.evaluate_implementation()
if evaluation.verdict == EvaluationVerdict.PASS:
break
if (
evaluation.verdict == EvaluationVerdict.FAIL
and not operator_attempt
and self.journal.implementation_revision
< self._settings.max_implementation_revisions
):
await self.implement()
continue
context = evaluation.body
while True:
decision = await InterveneWork.wait(
input_request={
"presentation": "in_app",
"label": "Implementation needs intervention",
"context": context,
}
)
if decision.action == "repair":
await self.implement()
break
if await self._delivery_is_current():
break
context = (
"The PR head changed since the last delivery. Choose Repair to "
"deliver and evaluate the updated work. Recheck only evaluates "
"the last Druks delivery."
)
operator_attempt = True
if await self._work_gate():
return

async def _work_gate(self) -> bool:
"""Park for work approval. Return True when the work is done, or False when it
goes back to implementation."""
if not self.journal.is_verified:
raise FatalError("Work must pass evaluation before approval.")
if self._policy.implementation_approval_gate() == "none":
return await self._approved_work()
decision = await ReviewWork.wait(
Expand Down Expand Up @@ -355,6 +385,8 @@ async def _triage(self) -> bool:
# Body code, never @step. The agent calls inside memoize themselves and write the
# journal. On replay a @step would skip them, and the journal would stay empty.
async def implement(self) -> ImplementationOutput:
if self.pr_number:
await self._restore_draft()
delivery = await SoftwareFactory.implement()
# The implementer found a contradiction in the binding requirements and stopped.
# The run fails with that reason, so the dashboard shows it.
Expand All @@ -369,6 +401,12 @@ async def implement(self) -> ImplementationOutput:
)
return delivery

@step
async def _delivery_is_current(self) -> bool:
github = await Github.get_client()
pull_request = await github.get_pull_request((await self.subject).repo, self.pr_number)
return pull_request["head"]["sha"] == self.journal.last_implementation.head_sha

@step
async def declare_merge_intent(self) -> bool:
"""Whether GitHub accepted ownership of the merge."""
Expand All @@ -388,6 +426,10 @@ def pr_number(self) -> int | None:
implementations = self.journal.implementations
return implementations[0].pr_number if implementations else None

@step
async def _restore_draft(self) -> None:
await self.set_pr_draft(draft=True)

@step
async def _clear_draft(self) -> None:
await self.set_pr_draft(draft=False)
Expand All @@ -411,12 +453,9 @@ async def request_assignee_review(self) -> None:
async def set_pr_draft(self, *, draft: bool) -> None:
repo = (await self.subject).repo
if self.pr_number:
try:
await (await Github.get_client()).set_pull_request_draft_state(
repo, self.pr_number, draft=draft
)
except Exception: # noqa: BLE001 — a draft merge fails loudly anyway
logger.warning("Could not set draft=%s on %s#%s.", draft, repo, self.pr_number)
await (await Github.get_client()).set_pull_request_draft_state(
repo, self.pr_number, draft=draft
)


class ProfileWorkspace(RepoWorkspace):
Expand Down
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ def browser_session_declarations():
# opt out and reset themselves. Everything else — including the durable *unit*
# tests that use the fixtures here — gets transaction rollback.
_OWN_DATABASE_MODULES = {
"test_build_intervention_durable",
"test_durable_sdk",
"test_notifications_durable",
"test_provider_subscription_persistence",
Expand Down
5 changes: 5 additions & 0 deletions backend/tests/software_factory/test_activity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock

import pytest
from conftest import installation_key
Expand Down Expand Up @@ -56,11 +57,15 @@ async def implement():
async def run_step(options, operation):
return await operation()

draft = AsyncMock()
monkeypatch.setattr(workflow, "set_pr_draft", draft)
monkeypatch.setattr(SoftwareFactory, "implement", implement)
monkeypatch.setattr("druks.workflows.DBOS.run_step_async", run_step)
await item.update(title="A renamed queue")
await workflow.implement()
draft.assert_not_awaited()
await workflow.implement()
draft.assert_awaited_once_with(draft=True)
db_session().expunge_all()
item = await WorkItem.get_or_none(id=item.id)
assert item.pr_number == 42
Expand Down
Loading