feat: migrate missing harnesses, templates, and workflows from agent-runtimes

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.
This commit is contained in:
Paul O'Reilly
2026-06-23 08:59:01 +12:00
parent 770ba97170
commit f5968cfab5
45 changed files with 1566 additions and 101 deletions

13
MEMORY.md Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
# 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
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"
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 ==="

View File

@@ -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 "$@"

View File

@@ -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"

View File

@@ -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": "<branch you tried>",
"test_files": ["..."]
}
```
Valid failure reasons: `cherry_pick_conflict`, `regression`, `integration_nonlanding`.
## Commit message format
```
feat(<spec-ids>): integrate ${item.uuid[:8]}
Integrates coding work for: ${item.title}
Spec IDs: ${metadata.automation.spec_ids}
Work item: ${item.uuid}
```

View File

@@ -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

View File

@@ -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: <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 <file>
python -m pytest --collect-only <test_file>
```
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] = []
```

View File

@@ -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

View File

@@ -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.)

View File

@@ -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: []

View File

@@ -0,0 +1,3 @@
version: 1
defaults: {}
rules: []

View File

@@ -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

View File

@@ -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

View File

@@ -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
# <value>` 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/<pid>/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-<z-ai-id>." >&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" <<EOF
{
"apiKeyHelper": "cat $STAGED_KEY"
}
EOF
fi
# settings.json holds a command (a path), not a credential value.
chown "$AGENT_USER:" "$SETTINGS_FILE" 2>/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"

21
memory/decisions.md Normal file
View File

@@ -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.

25
memory/gotchas-gitea.md Normal file
View File

@@ -0,0 +1,25 @@
# Gitea Gotchas
## `/login` returns 404 — use Basic Auth or `/users/<username>/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/<username>/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 <token>`) 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.

7
memory/gotchas-tokens.md Normal file
View File

@@ -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": <unix_seconds>}`. 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.

View File

@@ -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/<username>/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.

17
memory/process-lessons.md Normal file
View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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."

View File

@@ -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: <one-line reason>"
escalation:
contract_ambiguous: review-spec-opus

View File

@@ -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

View File

@@ -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 <files>
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.

View File

@@ -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/<slug>.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')
"

View File

@@ -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

View File

@@ -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}"

View File

@@ -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/<slug>.md following the project spec format
5. git add spec/<slug>.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

View File

@@ -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

View File

@@ -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

View File

@@ -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="<spec-id>: <description>")
- 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 <test_file> 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).