From f5968cfab5bf0e8eaa1f1a748298ce0615565312 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 23 Jun 2026 08:59:01 +1200 Subject: [PATCH] feat: migrate missing harnesses, templates, and workflows from agent-runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the framework CRS repo up to date with all content that was living in agent-runtimes (local-dev fallback) but hadn't been promoted. New composites: feature-delivery-loop, integration-direct, scaffolding-repo, sonnet-impl-narrow, sonnet-manager, test-writing-repo New contexts: integration/v1, scaffolding/v1, sonnet-manager/v1, z-ai/v1, airouter/v1/bin (anthropic-compat-wrapper.sh), cp-harness/v1/init.sh New task-templates: sonnet-integrator.yaml, workflow/* (17 typed workflow task templates for the Epic 1 pipeline) Updated: agent-repo/v1/finalize.sh — adds AR-38/F97 empty-deliverable audit (SKIP_BRANCH_PUSH support, boilerplate-path filtering, ci_metadata.json flag) Also adds MEMORY.md index and memory/ topic files for the framework repo. --- MEMORY.md | 13 + .../feature-delivery-loop/v1/harness.yaml | 10 + .../integration-direct/v1/harness.yaml | 10 + .../scaffolding-repo/v1/harness.yaml | 10 + .../sonnet-impl-narrow/v1/harness.yaml | 11 + .../composites/sonnet-manager/v1/harness.yaml | 12 + .../test-writing-repo/v1/harness.yaml | 10 + harnesses/contexts/agent-repo/v1/finalize.sh | 209 ++++++------ .../v1/bin/anthropic-compat-wrapper.sh | 10 + harnesses/contexts/cp-harness/v1/init.sh | 121 +++++++ harnesses/contexts/integration/v1/CLAUDE.md | 87 +++++ .../contexts/integration/v1/harness.yaml | 9 + harnesses/contexts/scaffolding/v1/CLAUDE.md | 48 +++ .../contexts/scaffolding/v1/harness.yaml | 9 + harnesses/contexts/sonnet-manager/__init__.py | 0 .../contexts/sonnet-manager/v1/CLAUDE.md | 7 + .../contexts/sonnet-manager/v1/__init__.py | 0 .../contexts/sonnet-manager/v1/harness.yaml | 17 + .../sonnet-manager/v1/manager-policy.yaml | 3 + .../contexts/sonnet-manager/v1/runner.py | 301 ++++++++++++++++++ harnesses/contexts/z-ai/v1/harness.yaml | 29 ++ harnesses/contexts/z-ai/v1/init.sh | 76 +++++ memory/decisions.md | 21 ++ memory/gotchas-gitea.md | 25 ++ memory/gotchas-tokens.md | 7 + memory/log/2026-05-04.225112.md | 27 ++ memory/process-lessons.md | 17 + task-templates/sonnet-integrator.yaml | 14 + .../workflow/airouter-impl-narrow@1.yaml | 25 ++ .../workflow/airouter-impl-ready@1.yaml | 26 ++ .../workflow/feature-delivery-loop@1.yaml | 36 +++ .../workflow/frozen-fixture-emit@1.yaml | 23 ++ .../workflow/impl-review-sonnet@1.yaml | 32 ++ task-templates/workflow/integration@1.yaml | 39 +++ task-templates/workflow/merge@1.yaml | 27 ++ .../workflow/review-spec-arch-opus@1.yaml | 49 +++ .../workflow/review-spec-security-opus@1.yaml | 18 ++ task-templates/workflow/scaffold@1.yaml | 38 +++ .../workflow/scope-decompose-sonnet@1.yaml | 68 ++++ .../workflow/sonnet-impl-narrow@1.yaml | 22 ++ .../workflow/sonnet-impl-ready@1.yaml | 19 ++ .../workflow/spec-draft-opus@1.yaml | 47 +++ task-templates/workflow/test-validate@1.yaml | 31 ++ .../workflow/test-write-minimax@2.yaml | 18 ++ task-templates/workflow/test-write@1.yaml | 36 +++ 45 files changed, 1566 insertions(+), 101 deletions(-) create mode 100644 MEMORY.md create mode 100644 harnesses/composites/feature-delivery-loop/v1/harness.yaml create mode 100644 harnesses/composites/integration-direct/v1/harness.yaml create mode 100644 harnesses/composites/scaffolding-repo/v1/harness.yaml create mode 100644 harnesses/composites/sonnet-impl-narrow/v1/harness.yaml create mode 100644 harnesses/composites/sonnet-manager/v1/harness.yaml create mode 100644 harnesses/composites/test-writing-repo/v1/harness.yaml create mode 100755 harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh create mode 100755 harnesses/contexts/cp-harness/v1/init.sh create mode 100644 harnesses/contexts/integration/v1/CLAUDE.md create mode 100644 harnesses/contexts/integration/v1/harness.yaml create mode 100644 harnesses/contexts/scaffolding/v1/CLAUDE.md create mode 100644 harnesses/contexts/scaffolding/v1/harness.yaml create mode 100644 harnesses/contexts/sonnet-manager/__init__.py create mode 100644 harnesses/contexts/sonnet-manager/v1/CLAUDE.md create mode 100644 harnesses/contexts/sonnet-manager/v1/__init__.py create mode 100644 harnesses/contexts/sonnet-manager/v1/harness.yaml create mode 100644 harnesses/contexts/sonnet-manager/v1/manager-policy.yaml create mode 100644 harnesses/contexts/sonnet-manager/v1/runner.py create mode 100644 harnesses/contexts/z-ai/v1/harness.yaml create mode 100755 harnesses/contexts/z-ai/v1/init.sh create mode 100644 memory/decisions.md create mode 100644 memory/gotchas-gitea.md create mode 100644 memory/gotchas-tokens.md create mode 100644 memory/log/2026-05-04.225112.md create mode 100644 memory/process-lessons.md create mode 100644 task-templates/sonnet-integrator.yaml create mode 100644 task-templates/workflow/airouter-impl-narrow@1.yaml create mode 100644 task-templates/workflow/airouter-impl-ready@1.yaml create mode 100644 task-templates/workflow/feature-delivery-loop@1.yaml create mode 100644 task-templates/workflow/frozen-fixture-emit@1.yaml create mode 100644 task-templates/workflow/impl-review-sonnet@1.yaml create mode 100644 task-templates/workflow/integration@1.yaml create mode 100644 task-templates/workflow/merge@1.yaml create mode 100644 task-templates/workflow/review-spec-arch-opus@1.yaml create mode 100644 task-templates/workflow/review-spec-security-opus@1.yaml create mode 100644 task-templates/workflow/scaffold@1.yaml create mode 100644 task-templates/workflow/scope-decompose-sonnet@1.yaml create mode 100644 task-templates/workflow/sonnet-impl-narrow@1.yaml create mode 100644 task-templates/workflow/sonnet-impl-ready@1.yaml create mode 100644 task-templates/workflow/spec-draft-opus@1.yaml create mode 100644 task-templates/workflow/test-validate@1.yaml create mode 100644 task-templates/workflow/test-write-minimax@2.yaml create mode 100644 task-templates/workflow/test-write@1.yaml diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..9870067 --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,13 @@ +# Memory Index — agent-runtime-framework + +Thin index. Read topic files for detail. + +## Gotchas +- [memory/gotchas-gitea.md](memory/gotchas-gitea.md) — Gitea API auth quirks: no `/login`, Basic Auth not Bearer, merged-as-closed, fork Actions independence +- [memory/gotchas-tokens.md](memory/gotchas-tokens.md) — `~/.config/agent-runtimes/tokens.json` flat structure + +## Decisions +- [memory/decisions.md](memory/decisions.md) — Fork cleanup workflow, shallow clones, pre-test hook, workflow output validation, push retry + +## Process +- [memory/process-lessons.md](memory/process-lessons.md) — Verifying PR state, fork workflow placement, fetch-depth for date inspection, wrong-path smoke testing diff --git a/harnesses/composites/feature-delivery-loop/v1/harness.yaml b/harnesses/composites/feature-delivery-loop/v1/harness.yaml new file mode 100644 index 0000000..294ae77 --- /dev/null +++ b/harnesses/composites/feature-delivery-loop/v1/harness.yaml @@ -0,0 +1,10 @@ +kind: composite +name: feature-delivery-loop +version: 1 +description: "Orchestrator harness for feature-delivery-loop@1: drives the eligibility-pick-dispatch-wait loop on a planning item; reads ACL inbox + planning state; calls CP API" + +layers: + - context: anthropic-cloud-paul-oauth/v1 + - context: gitea-ssh/v1 + - context: agent-repo/v1 + - context: planning/v1 diff --git a/harnesses/composites/integration-direct/v1/harness.yaml b/harnesses/composites/integration-direct/v1/harness.yaml new file mode 100644 index 0000000..1f6e63c --- /dev/null +++ b/harnesses/composites/integration-direct/v1/harness.yaml @@ -0,0 +1,10 @@ +kind: composite +name: integration-direct +version: 1 +description: "Integration harness — cherry-picks from agent fork and pushes directly to main after test verification" + +layers: + - context: integration/v1 + - context: anthropic-cloud-paul-oauth/v1 + - context: gitea-ssh/v1 + - context: direct-push/v1 diff --git a/harnesses/composites/scaffolding-repo/v1/harness.yaml b/harnesses/composites/scaffolding-repo/v1/harness.yaml new file mode 100644 index 0000000..c326736 --- /dev/null +++ b/harnesses/composites/scaffolding-repo/v1/harness.yaml @@ -0,0 +1,10 @@ +kind: composite +name: scaffolding-repo +version: 1 +description: "Model-agnostic scaffolding harness — writes stubs for coding agents to implement against" + +layers: + - context: scaffolding/v1 + - context: anthropic-cloud-paul-oauth/v1 + - context: gitea-ssh/v1 + - context: agent-repo/v1 diff --git a/harnesses/composites/sonnet-impl-narrow/v1/harness.yaml b/harnesses/composites/sonnet-impl-narrow/v1/harness.yaml new file mode 100644 index 0000000..35e81f6 --- /dev/null +++ b/harnesses/composites/sonnet-impl-narrow/v1/harness.yaml @@ -0,0 +1,11 @@ +kind: composite +name: sonnet-impl-narrow +version: 1 +description: "Sonnet impl agent for narrow single-file workflows: code methodology + Anthropic cloud (OAuth) + repo clone + TDD-protect (tests read-only)" + +layers: + - context: code-methodology/v1 + - context: anthropic-cloud-paul-oauth/v1 + - context: gitea-ssh/v1 + - context: agent-repo/v1 + - context: tdd-protect/v1 diff --git a/harnesses/composites/sonnet-manager/v1/harness.yaml b/harnesses/composites/sonnet-manager/v1/harness.yaml new file mode 100644 index 0000000..dfdb960 --- /dev/null +++ b/harnesses/composites/sonnet-manager/v1/harness.yaml @@ -0,0 +1,12 @@ +kind: composite +name: sonnet-manager +version: 1 +description: "Sonnet-driven per-project manager: ACL-typed-capable + planning + cp-cli + agent-repo" + +layers: + - context: cp-harness/v1 + - context: agent-communication/v1 + - context: agent-repo/v1 + - context: anthropic-cloud-paul-oauth/v1 + - context: planning/v1 + - context: sonnet-manager/v1 diff --git a/harnesses/composites/test-writing-repo/v1/harness.yaml b/harnesses/composites/test-writing-repo/v1/harness.yaml new file mode 100644 index 0000000..0dd60c9 --- /dev/null +++ b/harnesses/composites/test-writing-repo/v1/harness.yaml @@ -0,0 +1,10 @@ +kind: composite +name: test-writing-repo +version: 1 +description: "Model-agnostic test writing harness — credential layer swappable as model scores evolve" + +layers: + - context: test-writing/v1 + - context: anthropic-cloud-paul-oauth/v1 + - context: gitea-ssh/v1 + - context: agent-repo/v1 diff --git a/harnesses/contexts/agent-repo/v1/finalize.sh b/harnesses/contexts/agent-repo/v1/finalize.sh index 8d710db..b1795d3 100644 --- a/harnesses/contexts/agent-repo/v1/finalize.sh +++ b/harnesses/contexts/agent-repo/v1/finalize.sh @@ -1,8 +1,18 @@ #!/bin/bash # Agent repo finalize script — auto-commit and push changes -# AR-19, AR-20, AR-8, AR-32, F66, BUG-5, BUG-20 +# AR-19, AR-20, AR-8, AR-32, AR-38, F66, F97, BUG-5, BUG-20 set -euo pipefail +# --------------------------------------------------------------------------- +# AR-38 / F97: Post-commit empty-deliverable audit configuration +# Paths matching this regex are treated as boilerplate / metadata only — +# a commit containing ONLY paths matching this pattern is rejected. +# Overridable so harnesses with a different notion of "trivial" can adjust +# without forking this script. +# --------------------------------------------------------------------------- +DEFAULT_BOILERPLATE_PATHS_REGEX='^(\.gitignore|AGENTS\.md|ci_metadata\.json|memory/log/.*|\.agent-output/.*)$' +BOILERPLATE_PATHS_REGEX="${AGENT_BOILERPLATE_PATHS_REGEX:-$DEFAULT_BOILERPLATE_PATHS_REGEX}" + # --------------------------------------------------------------------------- # F66: Content-based secret scanning # Scans staged diff for credential patterns before committing. @@ -59,6 +69,9 @@ BRANCH="${AGENT_BRANCH:-}" REPO_URL="${AGENT_REPO_URL:-}" # BUG-20: Push retry PUSH_RETRIES="${AGENT_PUSH_RETRIES:-1}" +# When true, skip the branch push entirely (e.g. direct-push integrators that +# push to origin main themselves rather than using branch persistence). +SKIP_BRANCH_PUSH="${AGENT_SKIP_BRANCH_PUSH:-false}" mkdir -p "$AGENT_OUTPUT_DIR" @@ -146,6 +159,16 @@ fi # Check again after potential moves if [ -z "$(git status --porcelain)" ]; then echo "No changes to commit — skipping push (AR-8)" + # AR-38 / F97: AR-8 no-commit path is semantically equivalent to + # "empty deliverable" — the agent produced no work product at all. + # When the audit is enabled, surface this as empty_deliverable=true so + # operators triaging via ci_metadata.json can distinguish "agent did + # nothing" from "audit disabled by operator config" (the only path + # that produces an absent key). + AR8_EMPTY_DELIVERABLE_ENABLED="true" + if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then + AR8_EMPTY_DELIVERABLE_ENABLED="false" + fi python3 -c " import json, os out = '$METADATA_FILE' @@ -159,6 +182,8 @@ if os.path.isfile(out): meta['agent_branch'] = '$BRANCH' meta['agent_repo_url'] = '$REPO_URL' meta['agent_branch_pushed'] = False +if '$AR8_EMPTY_DELIVERABLE_ENABLED' == 'true': + meta['empty_deliverable'] = True with open(out, 'w') as f: json.dump(meta, f) print('Wrote ci_metadata.json (no-op: no changes)') @@ -238,6 +263,44 @@ git commit -F "$COMMIT_MSG_FILE" COMMIT_SHA=$(git rev-parse HEAD) echo "Committed as: $COMMIT_SHA" +# AR-38 / F97: Post-commit empty-deliverable audit ------------------------ +# Inspect the committed diff. If every changed path is boilerplate +# (.gitignore, AGENTS.md, ci_metadata.json, memory/log/*, .agent-output/*), +# flag the commit as empty_deliverable so the operator can distinguish a +# real successful run from a no-op that happened to advance HEAD. +# +# Sentinel values for EMPTY_DELIVERABLE: +# "true" — audit ran, every committed path was boilerplate +# "false" — audit ran, at least one path was substantive +# "skipped" — audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false +# +# Wrapped in `set +eo pipefail` because a piped grep that finds nothing +# returns 1, which would otherwise abort the script (see gotchas-agent-repo +# "AR-21 set-e/pipefail aborts finalize on diff-verify pipeline"). +EMPTY_DELIVERABLE="false" +if [ "${AGENT_EMPTY_DELIVERABLE_CHECK:-true}" = "false" ]; then + echo "AR-38: empty-deliverable audit disabled by AGENT_EMPTY_DELIVERABLE_CHECK=false" + EMPTY_DELIVERABLE="skipped" +else + set +eo pipefail + COMMITTED_PATHS=$(git diff-tree --no-commit-id --name-only -r "$COMMIT_SHA") + SUBSTANTIVE_PATHS=$(printf '%s\n' "$COMMITTED_PATHS" \ + | grep -v '^[[:space:]]*$' \ + | grep -vxE "$BOILERPLATE_PATHS_REGEX") + set -eo pipefail + if [ -z "$SUBSTANTIVE_PATHS" ]; then + echo "ERROR: AR-38 empty-deliverable audit FAILED for commit $COMMIT_SHA" >&2 + echo "ERROR: every committed path matched the boilerplate regex:" >&2 + echo "ERROR: regex: $BOILERPLATE_PATHS_REGEX" >&2 + echo "ERROR: committed paths:" >&2 + printf '%s\n' "$COMMITTED_PATHS" | sed 's/^/ERROR: /' >&2 + EMPTY_DELIVERABLE="true" + else + echo "AR-38: audit passed — substantive path(s) found in commit:" + printf '%s\n' "$SUBSTANTIVE_PATHS" | sed 's/^/ /' + fi +fi + # BUG-20: Output validation — check expected output file exists and is non-empty # AGENT_EXPECTED_OUTPUT: absolute path to required output file (e.g., /workspace/project/spec/f94-auth.md) # AGENT_MIN_OUTPUT_BYTES: minimum size in bytes (default 0 = any non-empty) @@ -260,79 +323,31 @@ if [ -n "${AGENT_EXPECTED_OUTPUT:-}" ]; then fi fi -# AR-21 (2026-05-08): Diff-against-upstream verification. -# AGENT_EXPECTED_CHANGED_FILES: comma-separated list of paths that MUST appear -# in the working-tree diff vs upstream main. Catches the failure mode where -# an agent reports "succeeded" but produced no actual change to the target -# file (real incident: 2026-05-08 dogfood batch, gotchas item 30 — three -# "succeeded" tasks merged nothing actionable). -# AGENT_FORBIDDEN_CHANGED_FILES: comma-separated list of paths that MUST NOT -# appear in the diff. Catches the inverse: an agent silently destroying or -# refactoring files outside the task scope (real incident: 2026-05-08 task -# 4a2f2988 — agent stripped 9 unrelated functions; gotchas item 17/21). -# -# Whole block runs with set +e (and pipefail off) to ensure no diagnostic -# pipeline failure aborts finalize before metadata can be written. We -# explicitly check exit codes where they matter. -DIFF_VERIFIED=true -DIFF_MISMATCH="" -DIFF_SUMMARY="" -set +eo pipefail -if [ -d /workspace/reference/main/.git ]; then - REF_HEAD=$(git -C /workspace/reference/main rev-parse HEAD 2>/dev/null) - if [ -n "$REF_HEAD" ]; then - # Files modified/added/deleted by the agent vs the upstream HEAD seed. - # Use git diff (working-tree style) plus committed changes — the - # agent commits via finalize.sh later, so the diff against REF_HEAD - # reflects total scope. `|| true` belt-and-braces against unreachable - # SHAs (e.g., if init.sh fell back to fork main without seeding from - # upstream). - DIFF_RAW=$(git diff --name-only "$REF_HEAD"..HEAD 2>/dev/null || true) - # If the .. range fails (rev-parse error) the substitution returns "". - # Fall back to the simpler diff against working-tree HEAD-1 (no good - # answer; just emit empty). - DIFF_SUMMARY=$(printf '%s\n' "$DIFF_RAW" | tr '\n' ',' | sed 's/,$//' || true) - - if [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}" ]; then - echo "Verifying required changed files: $AGENT_EXPECTED_CHANGED_FILES" - IFS=',' read -ra _REQUIRED <<< "$AGENT_EXPECTED_CHANGED_FILES" - for required in "${_REQUIRED[@]:-}"; do - required="${required#"${required%%[![:space:]]*}"}" - required="${required%"${required##*[![:space:]]}"}" - [ -z "$required" ] && continue - if ! printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$required,"; then - echo "ERROR: Required change to '$required' missing from agent's diff" - DIFF_VERIFIED=false - DIFF_MISMATCH="$DIFF_MISMATCH missing:$required" - fi - done - fi - - if [ -n "${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then - echo "Verifying forbidden files unchanged: $AGENT_FORBIDDEN_CHANGED_FILES" - IFS=',' read -ra _FORBIDDEN <<< "$AGENT_FORBIDDEN_CHANGED_FILES" - for forbidden in "${_FORBIDDEN[@]:-}"; do - forbidden="${forbidden#"${forbidden%%[![:space:]]*}"}" - forbidden="${forbidden%"${forbidden##*[![:space:]]}"}" - [ -z "$forbidden" ] && continue - if printf ',%s,' "$DIFF_SUMMARY" | grep -qF ",$forbidden,"; then - echo "ERROR: Forbidden file '$forbidden' was modified by agent" - DIFF_VERIFIED=false - DIFF_MISMATCH="$DIFF_MISMATCH forbidden:$forbidden" - fi - done - fi - - if [ "$DIFF_VERIFIED" = "true" ] && [ -n "${AGENT_EXPECTED_CHANGED_FILES:-}${AGENT_FORBIDDEN_CHANGED_FILES:-}" ]; then - echo "Diff verification passed (changed: $DIFF_SUMMARY)" - fi - else - echo "WARNING: /workspace/reference/main has no commits; skipping diff verification" - fi -else - echo "Note: /workspace/reference/main not present; skipping AR-21 diff verification" +# AGENT_SKIP_BRANCH_PUSH: direct-push agents (e.g. integrators) push to origin +# main themselves and don't want a task branch created as a side-effect. +if [ "$SKIP_BRANCH_PUSH" = "true" ]; then + echo "AGENT_SKIP_BRANCH_PUSH=true — skipping task branch push" + python3 -c " +import json, os +out = '$METADATA_FILE' +meta = {} +if os.path.isfile(out): + try: + with open(out) as f: + meta = json.load(f) + except Exception: + meta = {} +meta['agent_branch'] = '$BRANCH' +meta['agent_repo_url'] = '$REPO_URL' +meta['agent_branch_pushed'] = False +meta['direct_push_mode'] = True +with open(out, 'w') as f: + json.dump(meta, f) +print('Wrote ci_metadata.json (direct_push_mode: branch push skipped)') +" + echo "=== agent-repo/v1 finalize.sh complete (direct_push_mode) ===" + exit 0 fi -set -eo pipefail # AR-19: Push with retry — attempt up to PUSH_RETRIES+1 times (default 2: initial + 1 retry) echo "Pushing branch $BRANCH to $REPO_URL..." @@ -344,23 +359,13 @@ for attempt in $(seq 1 $((PUSH_RETRIES + 1))); do sleep 5 echo "Retry $attempt: git push $REPO_URL HEAD:refs/heads/$BRANCH --force" fi - # Capture push output explicitly to stderr so it shows in finalize-error - # capture (the previous `if cmd 2>&1; then` form let git stderr go to - # stdout where it was lost — the actual push error message wasn't - # visible in CP logs, hiding e.g. "Permission denied (publickey)" or - # "remote: error: ..." rejections. Real incident: 2026-05-08 probe 6 - # silently failed for 6 retries with no visible reason. - set +e - PUSH_OUT=$(timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1) - PUSH_EXIT=$? - set -e - echo "git push attempt $attempt exit=$PUSH_EXIT, output:" >&2 - echo "$PUSH_OUT" >&2 - if [ "$PUSH_EXIT" -eq 0 ]; then + if timeout 120 git push "$REPO_URL" "HEAD:refs/heads/$BRANCH" --force 2>&1; then echo "Push succeeded (attempt $attempt)" PUSHED=true + PUSH_EXIT=0 break else + PUSH_EXIT=$? echo "Push attempt $attempt failed with exit code $PUSH_EXIT" if [ "$attempt" -lt $((PUSH_RETRIES + 1)) ]; then echo "Will retry..." @@ -395,28 +400,30 @@ meta['diff_kb'] = float('$DIFF_KB') if '$DIFF_KB' else 0.0 meta['output_validated'] = $( [ '$OUTPUT_VALIDATED' = 'true' ] && echo 'True' || echo 'False' ) if '$OUTPUT_MISSING': meta['output_missing'] = '$OUTPUT_MISSING' -# AR-21: diff verification metadata -meta['diff_verified'] = $( [ '$DIFF_VERIFIED' = 'true' ] && echo 'True' || echo 'False' ) -if '$DIFF_MISMATCH'.strip(): - meta['diff_mismatch'] = '$DIFF_MISMATCH'.strip() -if '$DIFF_SUMMARY': - meta['diff_changed_files'] = [f for f in '$DIFF_SUMMARY'.split(',') if f] +# AR-38 / F97: record empty-deliverable audit outcome. +# Only emit the key when the audit ran. Skipped state omits the key so that +# downstream consumers can tell apart ran-and-passed from did-not-run. +ed = '$EMPTY_DELIVERABLE' +if ed == 'true': + meta['empty_deliverable'] = True +elif ed == 'false': + meta['empty_deliverable'] = False with open(out, 'w') as f: json.dump(meta, f) print('Wrote ci_metadata.json') " +# AR-38 / F97: exit non-zero (dedicated code 2) when audit flagged the commit. +# Take precedence over push failure (code 1) — empty deliverable is the more +# actionable signal for the operator. Exit only after push attempt above so +# the commit is still pushed for forensics. +if [ "$EMPTY_DELIVERABLE" = "true" ]; then + echo "ERROR: exiting 2 — AR-38 empty-deliverable audit failed" >&2 + exit 2 +fi + if [ "$PUSHED" = "false" ]; then exit 1 fi -# AR-21: fail the task if diff verification didn't pass — even if push succeeded. -# Branch is preserved on the agent repo for forensics, but the task ends as -# failed so the operator + CP know it shouldn't be merged. -if [ "$DIFF_VERIFIED" = "false" ]; then - echo "ERROR: Diff verification failed:$DIFF_MISMATCH" >&2 - echo "Branch $BRANCH is pushed for forensics, but the task is being marked failed." >&2 - exit 1 -fi - echo "=== agent-repo/v1 finalize.sh complete ===" \ No newline at end of file diff --git a/harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh b/harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh new file mode 100755 index 0000000..118dac8 --- /dev/null +++ b/harnesses/contexts/airouter/v1/bin/anthropic-compat-wrapper.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# Anthropic-compat wrapper for airouter harness (Phase 9 ESO-managed secrets) +# Reads airouter credentials from /run/agent/secrets/airouter/ and passes them +# as env vars to the underlying Claude runner. +set -euo pipefail + +exec env \ + ANTHROPIC_AUTH_TOKEN="$(cat /run/agent/secrets/airouter/auth_token)" \ + ANTHROPIC_BASE_URL="$(cat /run/agent/secrets/airouter/base_url)" \ + claude "$@" \ No newline at end of file diff --git a/harnesses/contexts/cp-harness/v1/init.sh b/harnesses/contexts/cp-harness/v1/init.sh new file mode 100755 index 0000000..a4e8151 --- /dev/null +++ b/harnesses/contexts/cp-harness/v1/init.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# cp-harness init — CPH-4/5/6 startup verification. +# +# Verifies five dispatcher-injected files exist with correct modes, +# validates cp_url (https:// prefix, no whitespace/newlines), and checks +# the not_after RFC 3339 timestamp is not expired. +# +# CPH-4: file existence + mode checks (tls.key must be 0400) +# CPH-5: cp_url must start with https://, no whitespace/newlines +# CPH-6: not_after must be a valid RFC 3339 timestamp in the future +# +# RUN_DIR: defaults to /run; tests pass a tmpdir path via env. +# PROFILE_D_DIR: defaults to /etc/profile.d; override in tests if needed. +set -euo pipefail + +RUN_DIR="${RUN_DIR:-/run}" +PROFILE_D_DIR="${PROFILE_D_DIR:-/etc/profile.d}" + +# --------------------------------------------------------------------------- +# CPH-4: verify file existence and modes +# --------------------------------------------------------------------------- + +TLS_CRT="$RUN_DIR/cp-client/tls.crt" +TLS_KEY="$RUN_DIR/cp-client/tls.key" +CA_CRT="$RUN_DIR/cp-client/ca.crt" +CP_URL_FILE="$RUN_DIR/cp-harness/cp_url" +NOT_AFTER_FILE="$RUN_DIR/cp-harness/not_after" + +# Check all five files exist and are readable. +for f in "$TLS_CRT" "$TLS_KEY" "$CA_CRT" "$CP_URL_FILE" "$NOT_AFTER_FILE"; do + if [ ! -r "$f" ]; then + echo "ERROR: missing required file $f" >&2 + exit 1 + fi +done + +# tls.key must be strictly 0400 (private key — defence in depth). +key_mode=$(stat -c %a "$TLS_KEY") +if [ "$key_mode" != "400" ]; then + # Attempt to tighten the mode. + if ! chmod 0400 "$TLS_KEY" 2>/tmp/cp_harness_chmod_err; then + chmod_err=$(cat /tmp/cp_harness_chmod_err 2>/dev/null || true) + echo "ERROR: tls.key mode $key_mode is broader than 0400; chmod failed: $chmod_err" >&2 + exit 1 + fi + # Re-check after chmod. + key_mode=$(stat -c %a "$TLS_KEY") + if [ "$key_mode" != "400" ]; then + echo "ERROR: tls.key mode $key_mode remains broader than 0400 after chmod" >&2 + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# CPH-5: validate cp_url +# --------------------------------------------------------------------------- + +cp_url=$(cat "$CP_URL_FILE") + +# Must start with https:// (case-sensitive, literal). +if [[ "$cp_url" != https://* ]]; then + echo "ERROR: cp_url does not start with https:// prefix" >&2 + exit 1 +fi + +# Must not contain carriage return, newline, or any whitespace. +# Use explicit byte checks plus [[:space:]] guard. +if printf '%s' "$cp_url" | grep -qP '\r|\n'; then + echo "ERROR: cp_url contains invalid whitespace/newline" >&2 + exit 1 +fi +if [[ "$cp_url" =~ [[:space:]] ]]; then + echo "ERROR: cp_url contains invalid whitespace/newline" >&2 + exit 1 +fi + +# Export for downstream processes. Fail silently if /etc/profile.d is unwritable +# (test environments may not have it). +mkdir -p "$PROFILE_D_DIR" 2>/dev/null || true +printf 'export CP_URL=%s\n' "$cp_url" > "$PROFILE_D_DIR/cp-url.sh" 2>/dev/null || true + +# --------------------------------------------------------------------------- +# CPH-6: validate not_after RFC 3339 timestamp +# --------------------------------------------------------------------------- + +not_after=$(cat "$NOT_AFTER_FILE") + +# Parse and validate: exit 1 if expired or unparseable. +if ! python3 -c " +import datetime, sys +raw = sys.argv[1].strip() +try: + t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00')) + if t.tzinfo is None: + t = t.replace(tzinfo=datetime.timezone.utc) +except Exception: + sys.exit(1) +now = datetime.datetime.now(datetime.timezone.utc) +delta = (t - now).total_seconds() +sys.exit(0 if delta > 0 else 1) +" "$not_after" 2>/dev/null; then + echo "ERROR: not_after expired or unparseable" >&2 + exit 1 +fi + +# Warn if expiry is within 300s. +warn_seconds=$(python3 -c " +import datetime, sys +raw = sys.argv[1].strip() +t = datetime.datetime.fromisoformat(raw.rstrip('Z').replace('Z', '+00:00')) +if t.tzinfo is None: + t = t.replace(tzinfo=datetime.timezone.utc) +now = datetime.datetime.now(datetime.timezone.utc) +print(int((t - now).total_seconds())) +" "$not_after" 2>/dev/null || echo "0") + +if [ "$warn_seconds" -lt 300 ]; then + echo "WARNING: cert expires in ${warn_seconds}s" >&2 +fi + +echo "cp-harness: all checks passed" diff --git a/harnesses/contexts/integration/v1/CLAUDE.md b/harnesses/contexts/integration/v1/CLAUDE.md new file mode 100644 index 0000000..8dfdf9a --- /dev/null +++ b/harnesses/contexts/integration/v1/CLAUDE.md @@ -0,0 +1,87 @@ +# Integration Context + +You are cherry-picking code from the agent fork to main and verifying the full test suite passes. This is the integration gate — code only reaches main through you. + +## Your task + +1. **Identify the coding agent's branch** (see Branch Discovery below) +2. **Cherry-pick commits** from the agent fork branch onto main +3. **Run the target test files** — must pass, not just collect +4. **Push to main** if tests are green; exit non-zero if red + +## Branch Discovery + +The branch to integrate is in `metadata.automation.last_coder_branch`. If that is empty or absent (F58 fix pending), discover the branch: + +```bash +AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" +ITEM_UUID="${item.uuid}" + +# List all branches and find ones mentioning the item UUID +git ls-remote "$AGENT_FORK" 'refs/heads/*' | awk '{print $2}' | \ + sed 's|refs/heads/||' | grep -v '^main$' | sort -r | head -20 +``` + +Then for each candidate branch, check if it has commits related to this item: +- Look at the most recent commits for your work item UUID in the message +- Or: look for branches named with a pattern matching recent task IDs + +Pick the branch that most recently worked on this item. + +## Cherry-pick procedure + +```bash +AGENT_FORK="git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" +BRANCH="${metadata.automation.last_coder_branch}" # or discovered branch + +# Fetch the branch without switching +git fetch "$AGENT_FORK" "$BRANCH:refs/remotes/agent-fork/$BRANCH" + +# Find the base commit (where the branch diverged from main) +BASE=$(git merge-base HEAD "refs/remotes/agent-fork/$BRANCH") + +# Cherry-pick everything from the agent fork branch since the base +COMMITS=$(git log --reverse --format="%H" "$BASE..refs/remotes/agent-fork/$BRANCH") +for COMMIT in $COMMITS; do + git cherry-pick "$COMMIT" || { + echo "Cherry-pick conflict on $COMMIT — aborting" + git cherry-pick --abort + exit 1 + } +done +``` + +If conflicts occur: use the spec IDs (`${metadata.automation.spec_ids}`) as authority. Never silently discard test changes. + +## Test verification + +After cherry-pick: +```bash +python -m pytest ${metadata.automation.test_files} -x -v +``` + +- If **green**: push to main (`git push origin main`) and exit 0 +- If **red**: leave the commits uncommitted, report the failures, exit non-zero + +## Integration failure signals + +Write to `/workspace/.agent-output/ci_metadata.json` to signal the failure reason: +```json +{ + "failure_reason": "cherry_pick_conflict", # or "regression" or "integration_nonlanding" + "agent_branch": "", + "test_files": ["..."] +} +``` + +Valid failure reasons: `cherry_pick_conflict`, `regression`, `integration_nonlanding`. + +## Commit message format + +``` +feat(): integrate ${item.uuid[:8]} + +Integrates coding work for: ${item.title} +Spec IDs: ${metadata.automation.spec_ids} +Work item: ${item.uuid} +``` diff --git a/harnesses/contexts/integration/v1/harness.yaml b/harnesses/contexts/integration/v1/harness.yaml new file mode 100644 index 0000000..1dc43d0 --- /dev/null +++ b/harnesses/contexts/integration/v1/harness.yaml @@ -0,0 +1,9 @@ +kind: context +name: integration +version: 1 +description: "Integration methodology — cherry-pick coding output from agent fork to main and verify full test suite" +provides: [integrator] + +context_files: + - source: ./CLAUDE.md + target: /opt/harness/context/integration/CLAUDE.md diff --git a/harnesses/contexts/scaffolding/v1/CLAUDE.md b/harnesses/contexts/scaffolding/v1/CLAUDE.md new file mode 100644 index 0000000..64a804b --- /dev/null +++ b/harnesses/contexts/scaffolding/v1/CLAUDE.md @@ -0,0 +1,48 @@ +# Scaffolding Context + +You are writing STUB IMPLEMENTATIONS. Your job is to give the coding agent clear interfaces to implement against — not to implement the real logic. + +## Rules + +1. **Read the test files first.** Identify every function, class, and module the tests import. + +2. **Write stubs with correct signatures and type hints.** Match what the tests expect exactly. + +3. **Bodies must be minimal:** + - For functions: `raise NotImplementedError("spec-id: ")` where spec-id matches the failing test's xfail reason + - For Pydantic models: define all required fields with correct types, use minimal defaults + - For abstract base classes: define the interface with `@abstractmethod` stubs + - Never implement real logic + +4. **Verify stubs compile and tests collect:** + ```bash + python -m py_compile + python -m pytest --collect-only + ``` + Fix any ImportError or collection errors before committing. + +5. **Do NOT make tests pass.** Tests should remain `xfail` (expected failure). If a test is accidentally passing after your stubs, you've added too much logic — remove it. + +6. **Commit and push to the work branch.** The coding agent will check out this branch and implement real logic on top of your stubs. + +## Common patterns + +```python +# Function stub +def compute_agent_branch(payload: dict, task_id: str) -> str: + raise NotImplementedError("AR-25: compute branch from payload + task_id") + +# Class stub +class TriggerRegistry: + def __init__(self, rules_path: str) -> None: + raise NotImplementedError("WT-REG-1: load rules from YAML") + + def evaluate(self, event: dict) -> list[str]: + raise NotImplementedError("WT-REG-2: evaluate trigger rules against event") + +# Pydantic model stub +class WorkflowInput(BaseModel): + state: str + tags_required: list[str] = [] + tags_forbidden: list[str] = [] +``` diff --git a/harnesses/contexts/scaffolding/v1/harness.yaml b/harnesses/contexts/scaffolding/v1/harness.yaml new file mode 100644 index 0000000..f6ce68d --- /dev/null +++ b/harnesses/contexts/scaffolding/v1/harness.yaml @@ -0,0 +1,9 @@ +kind: context +name: scaffolding +version: 1 +description: "Stub implementation methodology — write skeleton code that satisfies test signatures without real logic" +provides: [scaffolder] + +context_files: + - source: ./CLAUDE.md + target: /opt/harness/context/scaffolding/CLAUDE.md diff --git a/harnesses/contexts/sonnet-manager/__init__.py b/harnesses/contexts/sonnet-manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/harnesses/contexts/sonnet-manager/v1/CLAUDE.md b/harnesses/contexts/sonnet-manager/v1/CLAUDE.md new file mode 100644 index 0000000..97f2903 --- /dev/null +++ b/harnesses/contexts/sonnet-manager/v1/CLAUDE.md @@ -0,0 +1,7 @@ +# Sonnet Manager — Project Orchestrator + +You are the **per-project manager**. Your job: poll the ACL inbox, poll the eligibility queue, and dispatch eligible workflows through `cp-cli`. Idle-exit when both inbox and eligibility have been empty for `settle_threshold` consecutive polls. + +Full runtime contract: `spec/manager-sonnet.md`. + +(Detailed manager instructions will be added in a follow-up.) diff --git a/harnesses/contexts/sonnet-manager/v1/__init__.py b/harnesses/contexts/sonnet-manager/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/harnesses/contexts/sonnet-manager/v1/harness.yaml b/harnesses/contexts/sonnet-manager/v1/harness.yaml new file mode 100644 index 0000000..1ccbb1c --- /dev/null +++ b/harnesses/contexts/sonnet-manager/v1/harness.yaml @@ -0,0 +1,17 @@ +kind: context +name: sonnet-manager +version: 1 +description: "Sonnet-driven manager: per-project orchestrator that polls ACL inbox + eligibility and dispatches workflows" + +requires: [] +provides: [] +provides_tags: [orchestrator] +requires_tags: [cp-client, agent-communication-typed-capable] +requires_project_id: true + +scripts: + init: "./init.sh" + finalize: "./finalize.sh" + +env: {} +secrets_required: [] diff --git a/harnesses/contexts/sonnet-manager/v1/manager-policy.yaml b/harnesses/contexts/sonnet-manager/v1/manager-policy.yaml new file mode 100644 index 0000000..9407e52 --- /dev/null +++ b/harnesses/contexts/sonnet-manager/v1/manager-policy.yaml @@ -0,0 +1,3 @@ +version: 1 +defaults: {} +rules: [] diff --git a/harnesses/contexts/sonnet-manager/v1/runner.py b/harnesses/contexts/sonnet-manager/v1/runner.py new file mode 100644 index 0000000..15d3cba --- /dev/null +++ b/harnesses/contexts/sonnet-manager/v1/runner.py @@ -0,0 +1,301 @@ +"""Sonnet-manager runner — per-project orchestrator. + +Drives planning items toward terminal states by polling the ACL inbox, +checking eligibility, and dispatching workflows. Idle-exits when both +inbox and eligibility have been empty for settle_threshold consecutive +polls. + +Implements MS-18..MS-25 from spec/manager-sonnet.md. +""" +from __future__ import annotations + +import logging +import sys +import time +from dataclasses import dataclass +from subprocess import CalledProcessError + +__all__ = [ + "IdleExitConfig", + "manager_loop", + "handle_inbox_message", + "fallback_escalation", +] + +_logger = logging.getLogger("manager.runner") + + +# ── Data model ─────────────────────────────────────────────────────────────── + +@dataclass +class IdleExitConfig: + """Idle-exit configuration. + + settle_threshold: number of consecutive empty iterations before exiting. + poll_interval_seconds: sleep between iterations when idle. + """ + settle_threshold: int + poll_interval_seconds: float = 30.0 + + +# ── Main loop (MS-18, MS-19, MS-20, MS-24, MS-25) ────────────────────────── + +def manager_loop(*, client, project_id, idle_config: IdleExitConfig): + """Outer loop: inbox → eligibility → dispatch → idle-exit. + + MS-18: inbox polled BEFORE eligibility each iteration. + MS-19: exit 0 after settle_threshold consecutive empty iterations. + MS-20: tag_revoke before sys.exit(0). + MS-24: revoke → final poll → re-advertise-if-nonempty. + MS-25: acl_send exit 3 triggers fallback, not crash. + MS-9: tag_advertise exit 3 is fatal. + """ + # Startup: advertise orchestrator tag + try: + client.tag_advertise(project_id=project_id) + except CalledProcessError as exc: + # MS-9: tag_advertise exit 3 is fatal + _logger.error("tag_advertise failed (exit %s) — fatal", exc.returncode) + sys.exit(exc.returncode if exc.returncode else 1) + + idle_settle_count = 0 + + while True: + # ── MS-18: inbox poll FIRST ────────────────────────────────────── + inbox = client.acl_inbox_poll(project_id=project_id) + + if inbox: + # Non-empty inbox → reset settle counter + idle_settle_count = 0 + # Process each message + for msg in inbox: + _process_inbox_message_safe(client, msg, project_id) + # After processing, go back to top of loop + continue + + # ── Eligibility check ───────────────────────────────────────────── + eligible = client.list_eligible_workflows(project_id=project_id) + + if not eligible: + idle_settle_count += 1 + else: + idle_settle_count = 0 + # Per-item dispatch would go here (MS-14..MS-16, not yet implemented) + # for item in eligible: + # rule = policy.match(item, item.get('eligible', [])) + # if rule: + # client.dispatch_workflow(...) + + if idle_settle_count >= idle_config.settle_threshold: + # ── MS-20 / MS-24: idle-exit sequence ───────────────────────── + # MS-20: revoke before exit + client.tag_revoke(project_id=project_id) + + # MS-24: final poll after revoke + final_inbox = client.acl_inbox_poll(project_id=project_id) + if final_inbox: + # Re-advertise and reset counter + client.tag_advertise(project_id=project_id) + idle_settle_count = 0 + continue + + # Final poll empty → clean exit + sys.exit(0) + + if idle_config.poll_interval_seconds > 0: + time.sleep(idle_config.poll_interval_seconds) + + +def _process_inbox_message_safe(client, message, project_id): + """Process a single inbox message, catching exceptions. + + MS-25: acl_send exit 3 should not crash the manager. + """ + message_type = message.get("message_type") + if message_type is None: + # Plain message — log and continue + _logger.info("Received plain message (msg_id=%s): %s", + message.get("msg_id"), message.get("body", "")) + return + + try: + # We need item_uuid from the message; fall back to None + item_uuid = message.get("item_uuid") + handle_inbox_message( + client=client, + message=message, + item_uuid=item_uuid, + project_id=project_id, + ) + except CalledProcessError as exc: + if exc.returncode == 3: + # MS-25: exit 3 from acl_send → fallback escalation + payload = message.get("typed_payload") or {} + fallback_escalation( + client=client, + original_message_type=message_type, + cp_error_code="typed_message_unroutable", + typed_payload=payload, + item_uuid=item_uuid, + project_id=project_id, + ) + else: + _logger.error("acl_send failed with exit %s: %s", exc.returncode, exc) + + +# ── Inbox message handler (MS-21, MS-22) ───────────────────────────────────── + +def handle_inbox_message(*, client, message, item_uuid, project_id): + """Route a typed inbox message according to its message_type. + + MS-21: request-handoff → escalation map dispatch or human fallback. + MS-22: request-clarification → policy-driven routing. + MS-25: acl_send exit 3 → fallback_escalation (caught by caller). + """ + msg_type = message.get("message_type") + typed_payload = message.get("typed_payload") or {} + + if msg_type == "request-handoff": + _handle_request_handoff(client, message, typed_payload, item_uuid, project_id) + elif msg_type == "request-clarification": + _handle_request_clarification(client, message, typed_payload, item_uuid, project_id) + else: + _logger.info("Unhandled message type '%s' (msg_id=%s)", msg_type, message.get("msg_id")) + + +def _handle_request_handoff(client, message, typed_payload, item_uuid, project_id): + """MS-21: dispatch escalation workflow or fall back to human.""" + reason = typed_payload.get("reason", "other") + + # "other" always goes to human + if reason == "other": + _escalate_to_human(client, message, typed_payload, item_uuid, project_id) + return + + # Look up escalation map from workflow template + try: + template = client.get_workflow_template(item_uuid=item_uuid, project_id=project_id) + except Exception: + template = {} + + escalation_map = template.get("escalation") or {} + target_workflow = escalation_map.get(reason) + + if target_workflow: + # MS-21: dispatch configured escalation workflow + try: + client.dispatch_workflow( + item_uuid=item_uuid, + workflow=target_workflow, + project_id=project_id, + ) + except CalledProcessError as exc: + if exc.returncode == 3: + fallback_escalation( + client=client, + original_message_type="request-handoff", + cp_error_code=getattr(exc, "cp_error_code", "dispatch_failed"), + typed_payload=typed_payload, + item_uuid=item_uuid, + project_id=project_id, + ) + else: + raise + else: + # No escalation for this reason → human fallback + _escalate_to_human(client, message, typed_payload, item_uuid, project_id) + + +def _handle_request_clarification(client, message, typed_payload, item_uuid, project_id): + """MS-22: request-clarification is routed to human for policy decision.""" + _escalate_to_human(client, message, typed_payload, item_uuid, project_id) + + +def _escalate_to_human(client, message, typed_payload, item_uuid, project_id): + """Send a request-clarification to tag: human.""" + try: + client.acl_send( + to="tag:human", + message_type="request-clarification", + body=_build_escalation_body(message, typed_payload), + item_uuid=item_uuid, + project_id=project_id, + ) + except CalledProcessError as exc: + if exc.returncode == 3: + fallback_escalation( + client=client, + original_message_type="request-clarification", + cp_error_code=getattr(exc, "cp_error_code", "acl_send_failed"), + typed_payload=typed_payload, + item_uuid=item_uuid, + project_id=project_id, + ) + else: + raise + + +def _build_escalation_body(message, typed_payload): + """Build a human-readable escalation body from a message.""" + context_ref = typed_payload.get("context_ref") + context_digest = "" + if context_ref: + context_digest = f" Context: {repr(context_ref)}" + return ( + f"Handoff/clarification request: reason={typed_payload.get('reason', 'unknown')}." + f"{context_digest}" + ) + + +# ── Fallback escalation (MS-25) ────────────────────────────────────────────── + +def fallback_escalation(*, client, original_message_type, cp_error_code, + typed_payload, item_uuid, project_id): + """MS-25: when a typed send fails, send a plain escalation to human. + + If the fallback send also fails, log a warning and continue. + The manager MUST NOT crash. + """ + _logger.warning( + "fallback escalation: typed send failed — type=%s, error=%s", + original_message_type, cp_error_code, + ) + + body = _build_fallback_body(original_message_type, cp_error_code, typed_payload) + + try: + client.acl_send( + to="tag:human", + body=body, + item_uuid=item_uuid, + project_id=project_id, + ) + _logger.info( + "fallback escalation sent successfully for item %s", item_uuid, + ) + except CalledProcessError as exc: + _logger.warning( + "fallback_escalation_failed: original_type=%s, cp_error=%s, " + "fallback_exit=%s", + original_message_type, cp_error_code, exc.returncode, + ) + + +def _build_fallback_body(original_message_type, cp_error_code, typed_payload): + """Build the fallback plain-message body (MS-25 format). + + Includes original message type, CP error code, truncated payload summary, + and a SERIALIZER_VERSION drift note. + """ + reason = typed_payload.get("reason", "unknown") if isinstance(typed_payload, dict) else "unknown" + # Truncate payload summary to 500 chars + payload_summary = repr(typed_payload)[:500] + + body = ( + f"Manager could not route typed message: type={original_message_type}, " + f"reason={reason}.\n" + f"CP rejected with: {cp_error_code}. " + f"Possible cause: SERIALIZER_VERSION drift (AC-43).\n" + f"Payload summary: {payload_summary}" + ) + return body diff --git a/harnesses/contexts/z-ai/v1/harness.yaml b/harnesses/contexts/z-ai/v1/harness.yaml new file mode 100644 index 0000000..669efee --- /dev/null +++ b/harnesses/contexts/z-ai/v1/harness.yaml @@ -0,0 +1,29 @@ +kind: context +name: z-ai +version: 1 +description: "Z.ai — Anthropic-API-compatible endpoint" +requires: [] +provides: [claude-code] + +# Auth is wired by init.sh via Claude Code's apiKeyHelper (settings.json). +# No credential env vars: the secret stays in the mounted file and is read +# only by the helper command at request time. +env: + ANTHROPIC_BASE_URL: "https://api.z.ai/v1" + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: "1" + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" + DISABLE_PROMPT_CACHING: "1" + +secrets_required: + - name: z-ai + account_ref: "z-ai" + mount_path: /run/agent/secrets/z-ai + # 0400 (root-only) — defense in depth. The agent user CANNOT read this + # mount. init.sh runs as root and `install`s a per-secret copy into the + # agent's home with mode 0600 owned by agent; only that copy is exposed + # to the runtime. Matches the gitea-ssh pattern. If the ESO Secret + # later grows additional keys, they remain inaccessible by default. + mode: "0400" + +scripts: + init: ./init.sh diff --git a/harnesses/contexts/z-ai/v1/init.sh b/harnesses/contexts/z-ai/v1/init.sh new file mode 100755 index 0000000..e2077c4 --- /dev/null +++ b/harnesses/contexts/z-ai/v1/init.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# z-ai init — stage the auth_token for the agent user and wire apiKeyHelper. +# +# Threat model: keep the ESO mount root-only (mode 0400) so the agent user +# cannot directly `cat` /run/agent/secrets/z-ai/auth_token. init.sh runs as +# root (in uid-wrapper.sh, before the gosu drop) and stages a per-secret +# copy into the agent's home with mode 0600 owned by agent. apiKeyHelper +# points at the COPY. This is the gitea-ssh pattern — only the file the +# harness explicitly grants is reachable by the runtime. +# +# Rotation handling: this is a one-shot copy at container start. For +# ephemeral container agents (one task = one container) every task starts +# with the latest secret. Long-running sessions don't refresh the copy +# until a future scripts.control_loop hook lands (planning E1-M3). +# +# Auth wire-up: apiKeyHelper output is sent as `Authorization: Bearer +# ` when ANTHROPIC_BASE_URL is non-anthropic.com — exactly what +# api.z.ai/v1 requires. The secret value never enters this process' env, +# the claude subprocess' env, or /proc//environ. + +set -euo pipefail + +ESO_AUTH_TOKEN="/run/agent/secrets/z-ai/auth_token" + +if [ ! -r "$ESO_AUTH_TOKEN" ]; then + echo "ERROR: $ESO_AUTH_TOKEN not readable. Check ESO ExternalSecret acct-." >&2 + exit 1 +fi + +# Resolve the agent user's home (init.sh's $HOME is /root before gosu drop). +AGENT_USER="${AGENT_USER:-agent}" +AGENT_HOME=$(getent passwd "$AGENT_USER" | cut -d: -f6) +if [ -z "$AGENT_HOME" ] || [ ! -d "$AGENT_HOME" ]; then + AGENT_HOME="/home/$AGENT_USER" +fi + +# Stage the auth_token into a per-secret path owned by agent, mode 0600. +# install(1) handles ownership/mode atomically; the destination is outside +# the read-only ESO mount so we can chmod/chown freely. +STAGED_KEY_DIR="$AGENT_HOME/.claude/secrets" +STAGED_KEY="$STAGED_KEY_DIR/z-ai-token" +mkdir -p "$STAGED_KEY_DIR" +chown "$AGENT_USER:" "$STAGED_KEY_DIR" 2>/dev/null || true +chmod 0700 "$STAGED_KEY_DIR" +install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ESO_AUTH_TOKEN" "$STAGED_KEY" + +# Wire apiKeyHelper to the staged copy in the agent's settings.json. +CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AGENT_HOME/.claude}" +mkdir -p "$CONFIG_DIR" +chown "$AGENT_USER:" "$CONFIG_DIR" 2>/dev/null || true +chmod 0755 "$CONFIG_DIR" + +SETTINGS_FILE="$CONFIG_DIR/settings.json" + +# Merge into an existing settings.json (from another harness layer) when +# possible; otherwise create a fresh one. +if [ -f "$SETTINGS_FILE" ] && command -v jq >/dev/null 2>&1; then + TMP=$(mktemp) + jq --arg helper "cat $STAGED_KEY" \ + '. + {apiKeyHelper: $helper}' \ + "$SETTINGS_FILE" > "$TMP" + mv "$TMP" "$SETTINGS_FILE" +else + cat > "$SETTINGS_FILE" </dev/null || true +chmod 0644 "$SETTINGS_FILE" + +echo "z-ai auth_token staged at $STAGED_KEY (0600 $AGENT_USER:$AGENT_USER)" +echo "z-ai apiKeyHelper wired in $SETTINGS_FILE" diff --git a/memory/decisions.md b/memory/decisions.md new file mode 100644 index 0000000..7cf54b2 --- /dev/null +++ b/memory/decisions.md @@ -0,0 +1,21 @@ +# Decisions + +## Weekly fork cleanup workflow on `agent-runtimes-agents` + +A weekly cron in the fork's `.gitea/workflows/cleanup.yaml` deletes `task-*` branches that have been inactive for 7+ days, and resets `main` to an orphan commit if there have been no commits for 7+ days. Supports a dry-run via `workflow_dispatch`. Runs on the fork (not the parent) because Gitea Actions is per-repo and the fork enables Actions independently. Merged 2026-05-04 (PR #1, opened by ai_enablement, merged by admin). + +## Shallow clones (`--depth 1`) for task branches in fork cleanup + +The fork-cleanup workflow uses `--depth 1` when cloning individual task branches it is about to delete — no history is needed beyond the tip. Keeps the runner cheap. The exception is the cleanup job itself, which uses `fetch-depth: 0` so it can inspect commit dates across `main`. + +## Workspace pre-test hook reverts agent test edits before each test run + +`pre_test.sh` at the harness level reverts any agent modifications to test folders before each test run. The hook writes `{"reverted": N}` to `/workspace/.agent-output/.pre-test-result.json` atomically (mktemp + mv). Exit non-zero → that test attempt is skipped. Closes BUG-5 (`.agent-output/` silent drops) and prevents agents from tampering with the tests they are being judged against. + +## Workflow nodes declare `output: {path, min_bytes}` for validation + +All 17 nodes in the spec-planning workflow now carry an `output:` block. The finalize step validates that the declared file exists and meets the minimum size before declaring the node successful. Pairs with wrong-path detection: if the agent writes the right content to the wrong path, a `.correction-prompt.txt` sentinel is written and the entrypoint re-invokes the agent with the correction prompt. Closes BUG-20 (MiniMax finalize no-push). + +## Push retry with debug tracing in finalize + +`finalize.sh` now runs with `set -x` tracing and retries the final git push up to `AGENT_PUSH_RETRIES` times. Output validation gates the push: if `AGENT_EXPECTED_OUTPUT` is set and the file is missing or under-sized, the finalize fails loudly rather than silently pushing an empty branch. diff --git a/memory/gotchas-gitea.md b/memory/gotchas-gitea.md new file mode 100644 index 0000000..9a1aac7 --- /dev/null +++ b/memory/gotchas-gitea.md @@ -0,0 +1,25 @@ +# Gitea Gotchas + +## `/login` returns 404 — use Basic Auth or `/users//access_tokens` + +Symptom: hitting `/api/v1/login` (or any `/login`-style endpoint) returns `404 Not Found`. Easy to mistake for a misconfigured Gitea instance. + +Fix: Gitea has no session-login API endpoint. Either (a) use HTTP Basic Auth directly against any `/api/v1/` endpoint with `auth=("user","pass")` in `httpx`/`requests`, or (b) create an access token by POSTing to `/api/v1/users//access_tokens` (also Basic-Auth'd). Bearer token auth is supported on subsequent calls *after* you have a token. + +## API access tokens require HTTP Basic Auth, not Bearer + +Symptom: Bearer token auth (`Authorization: Bearer `) returns `401 Unauthorized` even with a valid access token against Gitea API endpoints used for token creation. + +Fix: use `httpx.get(url, auth=("user","pass"))` (Basic Auth) for token-creation calls. Once you hold a personal access token, subsequent API calls accept it as the `password` half of Basic Auth (with the username as the user) — still NOT Bearer-style. Encode this in any helper library wrapping the Gitea API. + +## PR state `closed` with `merged: true` means merged, not abandoned + +Symptom: PR appears `state: closed` in the API response; easy to assume the PR was cancelled. + +Fix: always check `merged` alongside `state`. Gitea encodes merged PRs as `state: closed, merged: true`. Parse both fields. When verifying a PR's outcome programmatically, dump the full JSON (`json.dumps(..., indent=2)`) and read both rather than asserting on `state` alone. + +## Fork actions run on the fork, not the parent + +Symptom: A workflow file lives on a fork repo and you expect the parent repo's Actions runner to see it — or vice versa. The workflow never fires (or fires on the wrong runner). + +Fix: Gitea Actions is enabled per-repo. `has_actions=true` on the parent does NOT propagate to forks — forks must enable Actions independently. Fork-cleanup workflows must be committed to the fork's own `.gitea/workflows/` and run on the fork. Parent-repo workflows ignore fork branches entirely. diff --git a/memory/gotchas-tokens.md b/memory/gotchas-tokens.md new file mode 100644 index 0000000..a879e14 --- /dev/null +++ b/memory/gotchas-tokens.md @@ -0,0 +1,7 @@ +# Token Gotchas + +## `~/.config/agent-runtimes/tokens.json` uses flat structure + +Symptom: code expecting a nested `{provider: {access_token, expires_at}}` shape fails to find the token. + +Fix: the file is flat — `{"access_token": "...", "expires_at": }`. There is no provider key. Read it as a flat dict. When debugging "is my token expired?", compare `expires_at` against `int(time.time())` directly — both are Unix epoch seconds. diff --git a/memory/log/2026-05-04.225112.md b/memory/log/2026-05-04.225112.md new file mode 100644 index 0000000..e8bc286 --- /dev/null +++ b/memory/log/2026-05-04.225112.md @@ -0,0 +1,27 @@ +# Session Log -- 2026-05-04 + +## Summary +Implemented 5 planned fixes for BUG-5 (`.agent-output/` silent drops) and BUG-20 (MiniMax finalize no-push): pre_test.sh harness hook, output validation in finalize, push retry with debug tracing, spec-planning workflow output tags, and wrong-path detection with agent correction re-invoke. Fork cleanup PR merged, agent-monitor PR #44 merged. + +## Decisions +- Fork cleanup workflow (PR #1 on agent-runtimes-agents): weekly cron deletes `task-*` branches inactive for 7+ days; main reset to orphan commit if no commits for 7+ days. Supports dry-run via workflow_dispatch. **Merged** (2026-05-04 10:39 UTC by admin). +- Gitea API token auth requires HTTP Basic Auth (`auth=("user","pass")`) not Bearer token — Bearer returns 401 even with valid access token. Use `httpx.get(..., auth=("user","pass"))` pattern. +- Shallow clone (`--depth 1`) for task branches in fork cleanup to avoid bloating with full history. + +## Gotchas Discovered +- **[Gitea Gotchas]** Gitea API endpoint `/login` is `404 Not Found` — use `/users//access_tokens` to create tokens, or use HTTP Basic Auth directly against any `/api/v1/` endpoint with `auth=("user","pass")`. +- **[Gitea Gotchas]** PR state `closed` with `merged: true` means the PR was merged (not just closed). Gitea distinguishes merged vs closed states. +- **[Gitea Gotchas]** Fork PRs use the parent repo's Actions (has_actions=true on parent, `has_actions: false` on fork). Fork cleanup workflow runs on the fork's GHA because fork enables Actions independently. +- **[tokens]** Token file at `~/.config/agent-runtimes/tokens.json` uses flat structure: `{"access_token": "...", "expires_at": N}`. Token was valid (not expired at session continuation time: now 1777891716 vs expiry 1777891804). + +## Key Context +- **agent-runtime-framework** `8bbf6cb`: pre_test.sh created (harness-level test folder revert), finalize.sh gains `set -x` tracing, push retry (`AGENT_PUSH_RETRIES`), output validation (`AGENT_EXPECTED_OUTPUT`), wrong-path detection + `.correction-prompt.txt` sentinel for entrypoint re-invoke. All 17 spec-planning workflow nodes tagged with `output: {path, min_bytes}`. +- **agent-runtimes-agents** fork: `fork-cleanup` branch merged to main (PR #1 by ai_enablement, merged by admin 2026-05-04). Workflow file at `.gitea/workflows/cleanup.yaml`. +- **agent-runtimes** `148d9d3`: PR #44 merged — `scripts/agent-monitor` gains `provider` and `model_full` columns. +- **agent-runtimes** local uncommitted: M22 Phase 3 manifests work (`controlplane/api/manifests.py`, `controlplane/db/manifest_store.py`, migration `20260504_0017_m16_phase3_manifests.py`, `controlplane/manifests/`). Not related to this session's focus. +- Two ops gates pending before M22 Phase 9 live cutover: CI build `agent-runtimes-init:1.0.0` image; `agent-session` Role deployed via `homelab/agent-runtimes-deploy` + ArgoCD sync. + +## Process Notes +- When verifying PR state via Gitea API: parse JSON directly (don't assume specific keys — use `json.dumps(..., indent=2)` to inspect full structure). +- Fork cleanup workflow commits to `agent-runtimes-agents` fork main; parent `agent-runtimes` main unchanged. Fork's main tracked via `agent-runtimes-agents` remote in local `agent-runtime-framework` checkout. +- Fork cleanup's `fetch-depth: 0` is needed for commit date inspection even with shallow clones elsewhere. diff --git a/memory/process-lessons.md b/memory/process-lessons.md new file mode 100644 index 0000000..17a7991 --- /dev/null +++ b/memory/process-lessons.md @@ -0,0 +1,17 @@ +# Process Lessons + +## When verifying PR state via the Gitea API, dump the full JSON + +Do not assert on a single key like `state`. Dump the response (`json.dumps(..., indent=2)`) and read `state`, `merged`, `merged_at`, and `merge_commit_sha` together. PRs that look "closed" may have been merged; PRs that look "open" may have a stale `head` SHA pointing at a deleted branch. + +## Fork-cleanup workflows must live on the fork, not the parent + +When the goal is "clean up branches on a fork", commit the workflow to the fork's `.gitea/workflows/`. The parent repo's Actions runner does not see fork branches. Verify Gitea Actions is enabled on the fork (`has_actions: true` in the fork's repo metadata). + +## Use `fetch-depth: 0` in cleanup jobs that inspect commit dates + +Shallow clones omit the timestamps needed to decide "this branch has been inactive for 7 days". The cleanup job itself must use `fetch-depth: 0` even if every other clone in the workflow is shallow. + +## Test the wrong-path detection by writing to the wrong path on purpose + +When adding output validation + correction prompt, do a smoke test that deliberately writes the expected content to the wrong path. Confirm the sentinel `.correction-prompt.txt` is written and the entrypoint re-invokes the agent. Don't rely on accidental coverage. diff --git a/task-templates/sonnet-integrator.yaml b/task-templates/sonnet-integrator.yaml new file mode 100644 index 0000000..a2c7a2e --- /dev/null +++ b/task-templates/sonnet-integrator.yaml @@ -0,0 +1,14 @@ +name: sonnet-integrator +description: "Sonnet integrator — cherry-picks from coding fork and pushes directly to main. No fork persistence." +model: sonnet +harness: code-sonnet-direct/v1 +requires_tags: [claude-code, coding-agent, git-access] +required_params: [repo_url] +defaults: + timeout: 1800 +pre_actions: + - type: clone + repo: "{{ repo_url }}" + branch: main + depth: 1 + clone_path: /workspace/project diff --git a/task-templates/workflow/airouter-impl-narrow@1.yaml b/task-templates/workflow/airouter-impl-narrow@1.yaml new file mode 100644 index 0000000..fbe280d --- /dev/null +++ b/task-templates/workflow/airouter-impl-narrow@1.yaml @@ -0,0 +1,25 @@ +name: airouter-impl-narrow +version: 1 +description: Single-file impl of a tagged spec requirement against existing tests, airouter in worktree +runtime: code-airouter-tdd-repo +input: + state: test-validated + tags_required: [airouter-eligible] + tags_forbidden: [security-sensitive, multi-file, algorithmic-large] + required_artifacts: [test_file, impl_skeleton, prompt_pack] +output: + state: impl-green +scope_budget: + files_modified_max: 1 + lines_diff_max: 80 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: code-airouter-tdd-repo + prompt: "Implement the failing test. Test file: ${artifacts.test_file}. Skeleton: ${artifacts.impl_skeleton}." +escalation: + fixture_broken: fix-test-fixture-sonnet + contract_ambiguous: review-spec-opus + scope_exceeded: replan-spec-opus + agent_bailed: airouter-impl-narrow diff --git a/task-templates/workflow/airouter-impl-ready@1.yaml b/task-templates/workflow/airouter-impl-ready@1.yaml new file mode 100644 index 0000000..415cdad --- /dev/null +++ b/task-templates/workflow/airouter-impl-ready@1.yaml @@ -0,0 +1,26 @@ +name: airouter-impl-ready +version: 1 +description: Single-file impl of a tagged spec requirement against pre-validated tests, airouter in worktree +runtime: code-airouter-tdd-repo +input: + state: scaffolded + tags_required: [airouter-eligible] + tags_forbidden: [security-sensitive, multi-file, algorithmic-large] + required_artifacts: [] +output: + state: impl-green +scope_budget: + files_modified_max: 1 + lines_diff_max: 80 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: code-airouter-tdd-repo + labels: [airouter] + prompt: "Implement the failing test. Test files: ${metadata.automation.test_files}. Spec IDs: ${metadata.automation.spec_ids}." +escalation: + fixture_broken: fix-test-fixture-sonnet + contract_ambiguous: review-spec-opus + scope_exceeded: replan-spec-opus + agent_bailed: airouter-impl-ready diff --git a/task-templates/workflow/feature-delivery-loop@1.yaml b/task-templates/workflow/feature-delivery-loop@1.yaml new file mode 100644 index 0000000..12b1a55 --- /dev/null +++ b/task-templates/workflow/feature-delivery-loop@1.yaml @@ -0,0 +1,36 @@ +name: feature-delivery-loop +version: 1 +description: | + Orchestrator workflow for delivering a feature-tagged item end-to-end. Reads + the item's workflow_state and workflow_history, lists eligible child workflows, + picks one via policy-pick, dispatches it, waits for completion, then loops. + Emits ACL request-handoff when policy-pick returns no match or when an outcome + triggers an escalation that is not yet implemented. +runtime: feature-delivery-loop +input: + state: ready + required_artifacts: [] +output: + state: impl-green +scope_budget: + # The orchestrator runner does not directly modify code; child workflows do. + # The schema requires positive minimums, so set the loop's own budget to 1/1 + # — child workflow scope budgets are enforced separately during their dispatch. + files_modified_max: 1 + lines_diff_max: 1 + wall_clock_seconds_max: 7200 +nodes: + - id: policy-pick + kind: script + cmd: scripts/workflow/policy-pick + on_fail: escalate + - id: agent + kind: agent + template: feature-delivery-loop + runtime_config: + runner: feature_delivery_loop + prompt: "Drive the eligibility-pick-dispatch-wait loop for item ${item.uuid} until it reaches a terminal state." +escalation: + policy_pick_no_match: blocked-pending-handoff + scope_exceeded: replan-spec-opus + agent_bailed: feature-delivery-loop diff --git a/task-templates/workflow/frozen-fixture-emit@1.yaml b/task-templates/workflow/frozen-fixture-emit@1.yaml new file mode 100644 index 0000000..78c60fd --- /dev/null +++ b/task-templates/workflow/frozen-fixture-emit@1.yaml @@ -0,0 +1,23 @@ +name: frozen-fixture-emit +version: 1 +description: Emit frozen test fixtures for test-needed items +runtime: script +input: + state: scoped-tagged + tags_required: [test-needed] + required_artifacts: [impl_skeleton] +output: + state: test-skeleton-ready +scope_budget: + files_modified_max: 2 + lines_diff_max: 200 + wall_clock_seconds_max: 600 +nodes: + - id: frozen-fixture-emit + kind: script + cmd: scripts/workflow/frozen-fixture-emit + on_fail: refuse_dispatch + - id: state-tx + kind: script + cmd: scripts/workflow/state-tx + on_fail: continue \ No newline at end of file diff --git a/task-templates/workflow/impl-review-sonnet@1.yaml b/task-templates/workflow/impl-review-sonnet@1.yaml new file mode 100644 index 0000000..f2c705e --- /dev/null +++ b/task-templates/workflow/impl-review-sonnet@1.yaml @@ -0,0 +1,32 @@ +name: impl-review-sonnet +version: 1 +description: Code review of implementation using Sonnet code-review +runtime: best-practices-airouter-repo +input: + state: integrated + tags_forbidden: [] + required_artifacts: [] +output: + state: impl-reviewed +scope_budget: + files_modified_max: 1 + lines_diff_max: 80 + wall_clock_seconds_max: 1200 +nodes: + - id: agent + kind: agent + template: best-practices-airouter-repo + prompt: | + Code-review the implementation for the following task. + + **Title**: ${artifacts.item_title} + + Review all changed Python files in the workspace (compare against main branch). + Check for: + 1. Correctness against the spec requirements + 2. Test coverage for new code paths + 3. Security issues (injection, auth bypass, data leaks) + 4. Performance or concurrency bugs + + Write your review to /workspace/.agent-output/review.md with a PASS or + REWORK verdict. If REWORK, list specific changes required. \ No newline at end of file diff --git a/task-templates/workflow/integration@1.yaml b/task-templates/workflow/integration@1.yaml new file mode 100644 index 0000000..0a1ca01 --- /dev/null +++ b/task-templates/workflow/integration@1.yaml @@ -0,0 +1,39 @@ +name: integration +version: 1 +description: Cherry-pick coding output from agent fork to main and verify test suite (WT-PIPE-5) +runtime: integration-direct +input: + state: impl-green + tags_forbidden: [] + required_artifacts: [] +output: + state: integrated +scope_budget: + files_modified_max: 20 + lines_diff_max: 2000 + wall_clock_seconds_max: 3600 +nodes: + - id: agent + kind: agent + template: integration-direct + prompt: | + You are the integration gate for this work item. + + Work item UUID: ${item.uuid} + Work item title: ${item.title} + Spec IDs: ${metadata.automation.spec_ids} + Test files: ${metadata.automation.test_files} + Coding agent branch: ${metadata.automation.last_coder_branch} + Agent fork: git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git + + Task: + 1. Fetch and cherry-pick commits from the coding agent's branch (see integration context) + 2. Run: python -m pytest ${metadata.automation.test_files} -x -v + 3. If green: git push origin main, then exit 0 + 4. If red or conflict: write failure reason to ci_metadata.json and exit non-zero + + Branch discovery: if last_coder_branch is empty, list agent fork branches + and find the one most recently working on this item (UUID in commit message or + branch name matches known patterns for this project's task IDs). + + NEVER push if tests are red. Better to fail here than to break main. diff --git a/task-templates/workflow/merge@1.yaml b/task-templates/workflow/merge@1.yaml new file mode 100644 index 0000000..9448c74 --- /dev/null +++ b/task-templates/workflow/merge@1.yaml @@ -0,0 +1,27 @@ +name: merge +version: 1 +description: Prepare a reviewed implementation for merge — runs pre-merge checks and marks item merge-ready +runtime: best-practices-airouter-repo +input: + state: impl-reviewed + tags_forbidden: [] + required_artifacts: [] +output: + state: merge-ready +scope_budget: + files_modified_max: 1 + lines_diff_max: 200 + wall_clock_seconds_max: 600 +nodes: + - id: agent + kind: agent + template: best-practices-airouter-repo + cli: agentic + model: Qwen3.6 + labels: [airouter] + runtime_env: + AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" + AGENT_BRANCH: "spec/auto-draft" + AGENT_SKIP_BRANCH_PUSH: "true" + AGENT_EMPTY_DELIVERABLE_CHECK: "false" + prompt: "Review the implementation for '${item.title}' (project: ${item.project_id}). Write a concise merge checklist to /workspace/.agent-output/merge-checklist.md covering: (1) tests pass, (2) scope matches spec, (3) no regressions. End with MERGE_READY or NEEDS_REWORK." diff --git a/task-templates/workflow/review-spec-arch-opus@1.yaml b/task-templates/workflow/review-spec-arch-opus@1.yaml new file mode 100644 index 0000000..621345d --- /dev/null +++ b/task-templates/workflow/review-spec-arch-opus@1.yaml @@ -0,0 +1,49 @@ +name: review-spec-arch-opus +version: 1 +description: Architecture review of a spec draft +runtime: best-practices-airouter-repo +input: + state: spec-draft + tags_forbidden: [security-sensitive] + required_artifacts: [spec_file] +output: + state: spec-reviewed-arch +scope_budget: + files_modified_max: 2 + lines_diff_max: 150 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: best-practices-airouter-repo + cli: agentic + model: Qwen3.6 + labels: [airouter] + runtime_env: + AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" + AGENT_BRANCH: "spec/auto-draft" + prompt: | + The agent-runtimes project repo is cloned at /workspace/project. + Change to that directory before doing any work. + + Review the spec draft for the following concept. + + **Title**: ${item.title} + + **Original concept**: + ${item.body} + + Steps: + 1. cd /workspace/project + 2. Find the spec file: ls spec/ and look for a file matching the concept title slug + 3. Read the spec file completely + 4. Review it against architectural best practices: + - Are requirements testable and unambiguous? + - Are there security implications not addressed? + - Are interfaces and data contracts clearly defined? + - Are edge cases and failure modes covered? + 5. Write your review findings to /workspace/.agent-output/review.md with a PASS or + REWORK verdict and specific findings + 6. Call task_complete with "PASS" or "REWORK: " +escalation: + contract_ambiguous: review-spec-opus \ No newline at end of file diff --git a/task-templates/workflow/review-spec-security-opus@1.yaml b/task-templates/workflow/review-spec-security-opus@1.yaml new file mode 100644 index 0000000..b3bad48 --- /dev/null +++ b/task-templates/workflow/review-spec-security-opus@1.yaml @@ -0,0 +1,18 @@ +name: review-spec-security-opus +version: 1 +description: Security review of a spec draft using Opus security-review +runtime: opus-security-review +input: + state: spec-draft + tags_required: [security-sensitive] + required_artifacts: [spec_file] +output: + state: spec-reviewed-security +scope_budget: + files_modified_max: 5 + lines_diff_max: 300 + wall_clock_seconds_max: 3600 +nodes: + - id: agent + kind: agent + template: opus-security-review \ No newline at end of file diff --git a/task-templates/workflow/scaffold@1.yaml b/task-templates/workflow/scaffold@1.yaml new file mode 100644 index 0000000..b4ee9fb --- /dev/null +++ b/task-templates/workflow/scaffold@1.yaml @@ -0,0 +1,38 @@ +name: scaffold +version: 1 +description: Write stub implementations satisfying test signatures before coding begins (WT-PIPE-3) +runtime: scaffolding-repo +input: + state: tests-written + tags_forbidden: [] + required_artifacts: [] +output: + state: scaffolded +scope_budget: + files_modified_max: 5 + lines_diff_max: 300 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: scaffolding-repo + prompt: | + Write stub implementations for the following task. + + Work item UUID: ${item.uuid} + Test files: ${metadata.automation.test_files} + Spec IDs: ${metadata.automation.spec_ids} + Task description: ${item.body} + + Read the test files on the work branch to identify what functions, classes, + and modules need to exist. Write minimal stub implementations: + - Correct signatures and type hints + - Bodies: raise NotImplementedError("spec-id: description") — no real logic + - Minimal Pydantic model fields where needed + + Verify stubs compile and tests still collect (they should remain xfail): + python -m py_compile + python -m pytest --collect-only ${metadata.automation.test_files} + + Commit and push to the work branch. The coding agent will implement + real logic on top of these stubs. diff --git a/task-templates/workflow/scope-decompose-sonnet@1.yaml b/task-templates/workflow/scope-decompose-sonnet@1.yaml new file mode 100644 index 0000000..7c5fb7c --- /dev/null +++ b/task-templates/workflow/scope-decompose-sonnet@1.yaml @@ -0,0 +1,68 @@ +name: scope-decompose-sonnet +version: 1 +description: Scope and decompose a spec into tagged tasks using Sonnet planning +runtime: spec-writing-airouter-repo +input: + state: spec-reviewed-arch + tags_forbidden: [] + required_artifacts: [] +output: + state: scoped-tagged +scope_budget: + files_modified_max: 10 + lines_diff_max: 500 + wall_clock_seconds_max: 2400 +nodes: + - id: agent + kind: agent + template: spec-writing-airouter-repo + cli: agentic + model: Qwen3.6 + labels: [airouter] + runtime_env: + AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" + AGENT_BRANCH: "spec/auto-draft" + CP_URL: "http://controlplane.agent-runtimes.svc.cluster.local:8100" + prompt: | + The agent-runtimes project repo is cloned at /workspace/project. + Change to that directory before doing any work. + + Find the spec file in /workspace/project/spec/ (the spec was + written in an earlier pipeline stage — look for a file whose name matches + the concept title '${item.title}' as a slug, e.g. spec/.md). + Read that spec carefully. + + Write a decomposition plan to /workspace/.agent-output/decompose.json with + exactly this structure: + { + "project_id": "${item.project_id}", + "space_id": "${item.space_id}", + "parent_item_uuid": "${item.uuid}", + "tasks": [ + { + "title": "...", + "description": "...", + "flow_state": "needs-tests", + "spec_ids": ["WT-EVAL-1", "..."], + "test_files": ["tests/triggers/test_schema.py", "..."], + "tags": ["airouter-eligible"] + } + ] + } + + Include "airouter-eligible" in tags only for single-file, self-contained + tasks that Qwen3.6 can handle alone. Omit tags for complex multi-file tasks. + + After writing decompose.json, run this Python command to create the work items: + + python3 -c " + import sys, os + sys.path.insert(0, '/opt/agent') + cp_url = os.environ.get('CP_URL', 'http://controlplane.agent-runtimes.svc.cluster.local:8100').rstrip('/') + from actions.decompose_work_items import decompose_work_items_action + decompose_work_items_action( + {'cp_url': cp_url, 'plan_path': '/workspace/.agent-output/decompose.json'}, + {} + ) + print('decompose_work_items: done') + " \ No newline at end of file diff --git a/task-templates/workflow/sonnet-impl-narrow@1.yaml b/task-templates/workflow/sonnet-impl-narrow@1.yaml new file mode 100644 index 0000000..c218a71 --- /dev/null +++ b/task-templates/workflow/sonnet-impl-narrow@1.yaml @@ -0,0 +1,22 @@ +name: sonnet-impl-narrow +version: 1 +description: Single-file impl of a tagged spec requirement against existing tests, sonnet in worktree +runtime: sonnet-impl-narrow +input: + state: test-validated + tags_forbidden: [security-sensitive, multi-file, algorithmic-large] + required_artifacts: [test_file, impl_skeleton, prompt_pack] +output: + state: impl-green +scope_budget: + files_modified_max: 1 + lines_diff_max: 80 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: code-sonnet-tdd-repo + prompt: "Implement the failing test. Test file: ${artifacts.test_file}. Skeleton: ${artifacts.impl_skeleton}." +escalation: + scope_exceeded: replan-spec-opus + agent_bailed: sonnet-impl-narrow diff --git a/task-templates/workflow/sonnet-impl-ready@1.yaml b/task-templates/workflow/sonnet-impl-ready@1.yaml new file mode 100644 index 0000000..986e904 --- /dev/null +++ b/task-templates/workflow/sonnet-impl-ready@1.yaml @@ -0,0 +1,19 @@ +name: sonnet-impl-ready +version: 1 +description: Single-file impl of a tagged spec requirement against pre-validated tests, Sonnet in worktree (escalation from airouter) +runtime: code-sonnet-tdd-repo +input: + state: scaffolded + tags_forbidden: [security-sensitive, multi-file, algorithmic-large] + required_artifacts: [] +output: + state: impl-green +scope_budget: + files_modified_max: 3 + lines_diff_max: 200 + wall_clock_seconds_max: 1800 +nodes: + - id: agent + kind: agent + template: code-sonnet-tdd-repo + prompt: "Implement the task. ${metadata.automation.test_files}${metadata.automation.spec_ids}Task description: ${artifacts.item_title}. ${artifacts.item_body}" diff --git a/task-templates/workflow/spec-draft-opus@1.yaml b/task-templates/workflow/spec-draft-opus@1.yaml new file mode 100644 index 0000000..ca63a3b --- /dev/null +++ b/task-templates/workflow/spec-draft-opus@1.yaml @@ -0,0 +1,47 @@ +name: spec-draft-opus +version: 1 +description: Draft a spec from an idea using spec-writer agent +runtime: spec-writing-airouter-repo +input: + state: idea + tags_required: [] + tags_forbidden: [security-sensitive] + required_artifacts: [] +output: + state: spec-draft +scope_budget: + files_modified_max: 5 + lines_diff_max: 300 + wall_clock_seconds_max: 3600 +nodes: + - id: agent + kind: agent + template: spec-writing-airouter-repo + cli: agentic + model: Qwen3.6 + labels: [airouter] + runtime_env: + AGENT_REPO_URL: "git@gitea.oreillyit.nz-ai-enablement:skynet/agent-runtimes-agents.git" + AGENT_BRANCH: "spec/auto-draft" + prompt: | + The agent-runtimes project repo is cloned at /workspace/project. + Change to that directory before doing any work. + + Draft a spec for the following concept and commit it to the repo. + + **Title**: ${item.title} + + ${item.body} + + Steps: + 1. cd /workspace/project + 2. Read existing specs in spec/ to understand the format + 3. Create a slug from the title (lowercase, hyphens instead of spaces) + 4. Write the spec to spec/.md following the project spec format + 5. git add spec/.md && git commit -m "spec: auto-draft ${item.title}" && git push + 6. Call task_complete with a one-sentence summary + + The spec should have numbered requirement IDs, clear acceptance criteria, + and error handling. Follow the format of existing specs in spec/. +escalation: + scope_exceeded: replan-spec-opus \ No newline at end of file diff --git a/task-templates/workflow/test-validate@1.yaml b/task-templates/workflow/test-validate@1.yaml new file mode 100644 index 0000000..79936f2 --- /dev/null +++ b/task-templates/workflow/test-validate@1.yaml @@ -0,0 +1,31 @@ +name: test-validate +version: 1 +description: Validate tests are runnable and non-failing by default +runtime: script +input: + state: test-write-pending + tags_required: [] + required_artifacts: [test_file] +output: + state: test-validated +scope_budget: + files_modified_max: 1 + lines_diff_max: 50 + wall_clock_seconds_max: 600 +nodes: + - id: preflight-test-collection + kind: script + cmd: scripts/workflow/preflight-test-collection + on_fail: refuse_dispatch + - id: preflight-fixture-lint + kind: script + cmd: scripts/workflow/preflight-fixture-lint + on_fail: refuse_dispatch + - id: preflight-test-fail-mode + kind: script + cmd: scripts/workflow/preflight-test-fail-mode + on_fail: continue + - id: state-tx + kind: script + cmd: scripts/workflow/state-tx + on_fail: continue \ No newline at end of file diff --git a/task-templates/workflow/test-write-minimax@2.yaml b/task-templates/workflow/test-write-minimax@2.yaml new file mode 100644 index 0000000..62a5110 --- /dev/null +++ b/task-templates/workflow/test-write-minimax@2.yaml @@ -0,0 +1,18 @@ +name: test-write-minimax +version: 2 +description: Write tests from skeletons using MiniMax test-writer +runtime: minimax-test-writer +input: + state: needs-tests + tags_required: [test-needed] + required_artifacts: [test_file] +output: + state: test-write-pending +scope_budget: + files_modified_max: 1 + lines_diff_max: 150 + wall_clock_seconds_max: 1200 +nodes: + - id: agent + kind: agent + template: minimax-test-writer \ No newline at end of file diff --git a/task-templates/workflow/test-write@1.yaml b/task-templates/workflow/test-write@1.yaml new file mode 100644 index 0000000..283a73b --- /dev/null +++ b/task-templates/workflow/test-write@1.yaml @@ -0,0 +1,36 @@ +name: test-write +version: 1 +description: Write xfail tests from spec before coding begins (model-agnostic, WT-PIPE-2) +runtime: test-writing-repo +input: + state: needs-tests + tags_forbidden: [] + required_artifacts: [] +output: + state: tests-written +scope_budget: + files_modified_max: 3 + lines_diff_max: 400 + wall_clock_seconds_max: 2400 +nodes: + - id: agent + kind: agent + template: test-writing-repo + prompt: | + You are writing pytest tests for the following task. + + Work item UUID: ${item.uuid} + Spec IDs: ${metadata.automation.spec_ids} + Expected test files: ${metadata.automation.test_files} + Task description: ${item.body} + + Rules: + - Write tests to exactly the files listed in test_files (create if missing, append if existing) + - Mark EVERY test @pytest.mark.xfail(strict=True, reason=": ") + - Tests must FAIL when the implementation does not exist yet + - Write minimal import stubs if needed to prevent ImportError — no real logic + - Run: python -m pytest --collect-only to verify collection before committing + - Commit and push to the work branch + + After writing tests, run the specified test files to confirm they are collected + and fail appropriately (xfail with strict=True).