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
9 changes: 8 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE/promotion.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
> **THE BUTTON CREATES A NEW COMMIT AND BREAKS THE RELEASE MODEL.**
> **COMMENT `/promote` TO COMPLETE THE PROMOTION.**

<!-- promotion-target: REPLACE_WITH_STAGING_SHA -->
<!-- Replace the value above with the staging commit this release was
validated against (git rev-parse origin/staging). Bare /promote promotes
exactly that sha — commits that land on staging afterwards do not ride
along — and /promote <sha> overrides it. An unfilled placeholder fails
the promotion checks; deleting the whole line promotes the staging tip. -->

## Summary

<!-- One sentence: what this release ships. -->
Expand All @@ -14,4 +21,4 @@

## Validation

<!-- Staging Deploy is green on the tip. Note anything soak-tested on staging.oddish.app. -->
<!-- Staging Deploy is green on the pinned target. Note anything soak-tested on staging.oddish.app. -->
50 changes: 50 additions & 0 deletions .github/scripts/promote/verify_promotion_target.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Shared promotion preconditions for Promotion Preflight and /promote.
#
# Resolves the target — TARGET_SHA if set, else the sha pinned in PR_BODY's
# `promotion-target` marker, else the staging tip — and verifies the
# fast-forward invariants and the staging deploy, then emits `sha=<target>`
# to GITHUB_OUTPUT. Read-only: the caller decides whether to push.
set -euo pipefail

git fetch origin main staging

raw="${TARGET_SHA:-}"
if [ -z "$raw" ]; then
body="$(printf '%s' "${PR_BODY:-}" | tr -d '\r')"
if printf '%s' "$body" | grep -q '<!--[[:space:]]*promotion-target:'; then
# A pin that is present but not a sha must fail, never fall through to
# the tip — an unfilled template placeholder is not consent to ship more.
raw="$(printf '%s' "$body" \
| grep -m1 -oE '<!--[[:space:]]*promotion-target:[[:space:]]*[0-9a-fA-F]{7,40}[[:space:]]*-->' \
| grep -oE '[0-9a-fA-F]{7,40}' | head -n1)" \
|| { echo "::error::the promotion-target pin in the PR body is not a commit sha; fix the pin or use an explicit target sha"; exit 1; }
echo "promoting the sha pinned in the PR body: $raw"
else
echo "::notice::no promotion-target pin in the PR body — promoting the staging tip"
fi
fi
target="${raw:-$(git rev-parse origin/staging)}"
target="$(git rev-parse --verify --quiet "${target}^{commit}")" \
|| { echo "::error::'$raw' does not resolve to a commit"; exit 1; }

git merge-base --is-ancestor "$target" origin/staging \
|| { echo "::error::$target is not on staging"; exit 1; }
git merge-base --is-ancestor origin/main "$target" \
|| { echo "::error::main is not an ancestor of $target — fast-forward impossible; run Sync Preflight for the repair steps"; exit 1; }

if gh workflow view "Staging Deploy" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh run list --repo "$GITHUB_REPOSITORY" --workflow "Staging Deploy" \
--branch staging --commit "$target" --json conclusion -q '.[0].conclusion' \
| grep -qx success \
|| {
echo "::error::Staging Deploy is not green on $target"
echo "::notice::A queued deploy is superseded when a newer commit lands (GitHub keeps one pending run per concurrency group), so a commit that staging moved past may never have deployed. A dispatched deploy always runs the tip of staging, not an older commit, so promote the staging tip instead of this sha."
exit 1
}
else
echo "::warning::Staging Deploy workflow not found — skipping deploy-green precondition (bootstrap)"
fi

echo "sha=$target" >> "$GITHUB_OUTPUT"
echo "checks passed for $target"
7 changes: 6 additions & 1 deletion .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,12 @@ jobs:
- prepare-preview-database
- deploy-preview-backend
- update-vercel-preview
if: always() && github.event.action != 'closed'
# `!cancelled()` rather than `always()`: the gate must still run when
# upstream jobs are SKIPPED (fork and promotion paths), but a run that
# cancel-in-progress superseded must not publish a failing check and a
# failing deployment status for a commit whose replacement run is still
# building.
if: "!cancelled() && github.event.action != 'closed'"
runs-on: ubuntu-latest
# No job-level `environment:` key here: GitHub attributes that deployment
# record to the person who triggered the run, so pull requests showed a
Expand Down
32 changes: 18 additions & 14 deletions .github/workflows/promote-comment.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
name: Promote on comment
# Comment `/promote` on the staging -> main pull request.
# Comment `/promote` on the staging -> main pull request to promote the sha
# pinned in its body's `promotion-target` marker (the staging tip when no pin
# exists), or `/promote <sha>` to override the pin
# (the same target rule as the Promotion Preflight `target_sha` input).
#
# For an organization member with write access, the job always runs the
# promotion checks and reports them back on the pull request; anyone else gets
Expand Down Expand Up @@ -76,6 +79,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.issue.number }}
COMMENT: ${{ github.event.comment.body }}
run: |
set -euo pipefail
read -r base head state < <(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" \
Expand All @@ -86,18 +90,13 @@ jobs:
cross=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json isCrossRepository -q .isCrossRepository)
[ "$cross" = "false" ] || { echo "::error::#$PR comes from a fork; promotion uses the repository's own staging branch"; exit 1; }

git fetch origin main staging
target=$(git rev-parse origin/staging)
git merge-base --is-ancestor origin/main "$target" \
|| { echo "::error::main is not an ancestor of $target — run Sync Preflight for the repair steps"; exit 1; }

gh run list --repo "$GITHUB_REPOSITORY" --workflow "Staging Deploy" \
--branch staging --commit "$target" --json conclusion -q '.[0].conclusion' \
| grep -qx success \
|| { echo "::error::Staging Deploy is not green on $target"; exit 1; }

echo "sha=$target" >> "$GITHUB_OUTPUT"
echo "checks passed for $target"
# The word after `/promote` on the command line is the target;
# bare `/promote` promotes the sha pinned in the pull request body
# (the script falls back to the staging tip when there is no pin).
TARGET_SHA=$(printf '%s' "$COMMENT" | head -n1 | tr -d '\r' | awk '{print $2}')
PR_BODY=$(gh pr view "$PR" --repo "$GITHUB_REPOSITORY" --json body -q .body)
export TARGET_SHA PR_BODY
.github/scripts/promote/verify_promotion_target.sh

- name: Fast-forward main
id: push
Expand Down Expand Up @@ -136,7 +135,12 @@ jobs:
if [ "$CHECKS" != "success" ]; then
body="Promotion checks failed. See the run for the reason: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
elif [ "$PUSH_OUTCOME" = "success" ]; then
body="Promoted. main now points at \`${SHA}\`, the same commit as staging."
left=$(git rev-list --count "${SHA}..origin/staging")
if [ "$left" -eq 0 ]; then
body="Promoted. main now points at \`${SHA}\`, the same commit as staging."
else
body="Promoted. main now points at \`${SHA}\`; staging still carries ${left} unpromoted commit(s)."
fi
elif [ "$PUSH_OUTCOME" = "failure" ]; then
body="Promotion checks passed but the push failed. See the run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
else
Expand Down
37 changes: 15 additions & 22 deletions .github/workflows/promote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
target_sha:
description: "Staging commit to promote (default: staging tip)"
description: "Staging commit to promote (default: the promotion PR's pinned target, else the staging tip)"
required: false

jobs:
Expand All @@ -24,32 +24,25 @@ jobs:
fetch-depth: 0

- name: Verify promotion preconditions
id: checks
shell: bash
env:
TARGET_SHA: ${{ inputs.target_sha }}
run: |
set -euo pipefail
git fetch origin main staging
target="${{ inputs.target_sha }}"
target="${target:-$(git rev-parse origin/staging)}"
target="$(git rev-parse --verify "${target}^{commit}")" \
|| { echo "::error::target_sha does not resolve to a commit"; exit 1; }
git merge-base --is-ancestor "$target" origin/staging \
|| { echo "::error::$target is not on staging"; exit 1; }
git merge-base --is-ancestor origin/main "$target" \
|| { echo "::error::main is not an ancestor of $target — fast-forward impossible; see the recovery runbook"; exit 1; }
pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --base main --head staging --state open --json number -q '.[0].number')
[ -n "$pr" ] || { echo "::error::no open staging->main promotion PR"; exit 1; }
if gh workflow view "Staging Deploy" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh run list --repo "$GITHUB_REPOSITORY" --workflow "Staging Deploy" \
--branch staging --commit "$target" --json conclusion -q '.[0].conclusion' \
| grep -qx success \
|| {
echo "::error::Staging Deploy is not green on $target"
echo "::notice::A queued deploy is superseded when a newer commit lands (GitHub keeps one pending run per concurrency group), so a commit that staging moved past may never have deployed. A dispatched deploy always runs the tip of staging, not an older commit, so promote the staging tip instead of this sha."
exit 1
}
else
echo "::warning::Staging Deploy workflow not found — skipping deploy-green precondition (bootstrap)"
fi
PR_BODY=$(gh pr view "$pr" --repo "$GITHUB_REPOSITORY" --json body -q .body)
export PR_BODY
.github/scripts/promote/verify_promotion_target.sh

- name: Print the push command
shell: bash
env:
TARGET: ${{ steps.checks.outputs.sha }}
run: |
set -euo pipefail
target="$TARGET"
{
echo "## Promotion preflight PASSED"
echo
Expand Down
13 changes: 12 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,15 @@ High-level flow:
nonterminal trial in the org. Final result settlement performs the same
check for agents without live usage. Cancellation retires queued, running,
blocked, and retrying worker jobs in the database before terminating remote
handles; a task is failed only when no other live trial remains.
handles; a task is failed only when no other live trial remains. If quota
cancellation interrupts a replacement QA pass, the last successful verdict
is restored through `cancel_verdict`; a terminal QA failure instead clears
that preserved payload through `fail_verdict`. All task verdict-column
mutations go through `oddish.core.verdict_state`: a published payload may
coexist with QUEUED/RUNNING while its replacement is active, but it must
return to SUCCESS if that pass is abandoned. The
`ck_tasks_published_verdict_status` database constraint rejects a published
payload with a missing or FAILED status.
6. Trial completion persists queryable execution metrics on the trial row:
input/cache/output tokens, total trajectory steps, native runtime cost when
reported, phase timing, trajectory availability, arbitrary verifier
Expand Down Expand Up @@ -218,6 +226,9 @@ block. Editing a prompt is a code change that ships with a deploy.
`TagProjectJobHandler`, plus the legacy `AnalysisJobHandler`)
- the task-level QA job (`run_task_qa_job`): classify every live trial via
the shared `classify_trial_and_store`, then synthesize the task verdict
- the verdict state machine (`oddish.core.verdict_state`), which is the only
writer for `tasks.verdict*` lifecycle columns and preserves the last
published result until a replacement succeeds or terminally fails
- post-trial classification runs through `AnalyzerBlock`. It reads two
already-downloaded directories and executes nothing, so `resolve_substrate`
keeps it on the worker-local Claude Code client (`CLAUDE_CLI`) everywhere;
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-08-07]

### Changed

- The verdict now says `accept` or `reject` instead of `is_good: true/false`. Stored payloads keep `is_good` too, so old rows, the dashboard queries, and the Slack alert still work. The badge shows "Accepted" or "Rejected".
- The verdict judge used to bury its hard rules inside exceptions, and it accepted a task whose own audit had found a `must_fix` leak — on tests the untouched base model already passed (0.96 against a 0.25 threshold). The prompt (`verdict_prompt.txt`) is rewritten as two steps: first look for evidence that rejects the task by itself (a leak, weak tests, a failed baseline), and only then weigh the trials' opinions, which need agreement.
- The task overview panel used to list only the current experiment's trials, but the verdict is computed over every trial of the task — so the panel could show a verdict whose deciding trial it refused to list. It now shows every trial of the version. Trials from other experiments carry a dashed "elsewhere" chip and open in a new tab. Long subtypes also stopped pushing the "View trial" button out of its row.
- The verdict badge used to hide its rerun button once a verdict existed, and the button that did exist re-classified every trial from scratch. Tasks with a verdict now show "Rerun verdict" (`qa/backfill` with `force: false`), which keeps the stored trial analyses and redoes only the verdict. The full re-classify stays on `qa/retry`.
- Submitting new trials used to delete the task's verdict immediately, and the task had no verdict until QA finished the new trials. The old verdict now stays until the new QA run replaces it.

### Removed

- The cc_chat dashboard chat feature is gone end to end: the `/chat-sessions`
Expand All @@ -30,6 +38,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- Quota cancellation, retry, and append reconciliation no longer hide a preserved accepted verdict by leaving its payload paired with a missing status. Verdict lifecycle changes now use one state-transition module: replacement QA retains the published payload while queued/running, cancellation or a no-op restores it to `SUCCESS`, and only terminal QA failure discards it. A database constraint repairs and prevents invalid payload/status pairs.
- Worker heartbeats used to stop as soon as the agent finished, but the worker still had to upload and save the results. When that took over 15 minutes, the cleanup sweep marked the trial "Worker heartbeat stalled for over 15 minutes", threw away the finished result, and re-ran the whole trial. The heartbeat now runs until the results are saved and settled.

---
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ deploy, and the fast-forward condition, then prints the push command) and
executes that push themselves; never merge, squash, or push to `main` directly.
An organization member with `write`, `maintain`, or `admin` access can instead
comment `/promote` on the promotion pull request; the workflow runs the same
checks and, when the promote token is set, does the push.
checks and, when the promote token is set, does the push. Bare `/promote`
promotes the sha pinned in the pull request body (the template's
`promotion-target` marker), so commits that reach `staging` after the
promotion pull request was written do not ride along; `/promote <sha>`
overrides the pin, and a body without one promotes the staging tip.

**Never complete a promotion pull request with the merge button.** The button
squashes, which puts a new commit on `main` and breaks the fast-forward
Expand Down Expand Up @@ -71,7 +75,9 @@ copy gets a different commit id, so the branches stay diverged.
Not every change has to be releasable to merge. Land unfinished work behind a
flag that is off by default (as `ODDISH_GKE_ENABLED` and
`ODDISH_PRE_TRIAL_ENABLED` do), or promote only part of `staging` by giving
the promotion workflow the commit to stop at.
the promotion workflow the commit to stop at (the `target_sha` input on
Promotion Preflight, `/promote <sha>` on the promotion pull request, or the
`promotion-target` pin in its body).

## Useful pointers

Expand Down
7 changes: 4 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ or globally via the env default. Under `enforce`, an over-cap submission gets
HTTP **402** (`"Your organization is over its monthly budget …"`); under
`shadow` it emits `metric=quota.would_block reason=org_over_budget`. Admins see
month-to-date org usage on `GET /quotas`; any member can read the org budget
snapshot + adaptive daily goal on `GET /quotas/org`. Advisory-lock order is
org → payer → row locks (ENFORCE-only on admission; the org lock is always
taken first, even when no org cap is configured).
snapshot + adaptive daily goal on `GET /quotas/org`. Admission takes no
locks; concurrent submissions can briefly overshoot a cap and the
enforcement sweep cancels the overage. Only the sweep takes the quota
advisory locks (org → payer, non-blocking).
4 changes: 1 addition & 3 deletions backend/api/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,7 @@ async def create_task_sweep(
request_hash=request_hash,
)
except TimeoutError as exc:
# Quota advisory-lock waits (and other DB wait timeouts) surface as
# bare TimeoutError from asyncpg. Map to 503 so the CLI retries with
# a legible message instead of an opaque "Internal Server Error".
# asyncpg raises bare TimeoutError on DB wait timeouts.
logger.error(
"create_task_sweep timed out for task_id=%s org_id=%s",
submission.task_id,
Expand Down
19 changes: 18 additions & 1 deletion backend/api/routers/trials.py
Original file line number Diff line number Diff line change
Expand Up @@ -400,22 +400,39 @@ async def get_trial_trajectory(
async def get_trial_trajectory_summary(
trial_id: str,
auth: Annotated[AuthContext, Depends(require_auth)],
refresh: bool = Query(
False,
description=(
"Discard the stored summary and generate a new one. Costs an LLM "
"call per request, so it needs the same scope as an analysis rerun."
),
),
) -> dict:
"""Get a Claude-generated summary of the trajectory.

Returns the summary from the latest `analyzer_blocks` row (mirrored to
`trials.trajectory_summary`) when fresh, otherwise generates one. 404 when
the trial has no trajectory; 502 if generation fails.

Freshness is keyed on `schema_version` alone, so a change that alters the
summary's *content* without altering its shape -- retiring a taxonomy
label, say -- leaves stored summaries serving the old vocabulary forever.
`refresh=true` is the way out for those.
"""
auth.require_scope(APIKeyScope.READ)
if refresh:
auth.require_scope(APIKeyScope.TASKS, allow_member_created_task_key=False)
trial = await _get_authorized_trial(trial_id, auth)
try:
async with get_session() as session:
attached_trial = await session.get(TrialModel, trial.id)
if attached_trial is None:
raise HTTPException(status_code=404, detail="Trial not found")
summary = await get_or_generate_summary(
session, attached_trial, triggered_by_user_id=auth.user_id
session,
attached_trial,
triggered_by_user_id=auth.user_id,
refresh=refresh,
)
except SummaryGenerationError as e:
logger.error(
Expand Down
Loading
Loading