agent-orchestrator/experiments/D4-wrapper-cache-plan.md

516 lines
15 KiB
Markdown

# D4 Wrapper Cache Implementation Plan
## Scope
Two intercepts only, targeting the two largest agent-side waste buckets from the D4 analysis:
| Pattern | D4 count (10-session run) | % of agent-side traffic |
|---------|--------------------------|------------------------|
| `gh pr list --repo R --head B --json ... --limit 1` | 598 | 65.3% |
| `gh issue view N --json ...` | 75 | 8.2% |
Combined: **673 / 916 = 73.5%** of all agent-side `gh` calls.
Everything else passes through unchanged.
---
## Storage
### Location
```
$AO_DATA_DIR/.ghcache/$AO_SESSION/
```
- `$AO_DATA_DIR` = sessions directory (already available in agent env)
- `$AO_SESSION` = session ID (already available in agent env)
- `.ghcache` is invisible to `listMetadata()` (`metadata.ts:366` filters `name.startsWith(".")`)
### File format
Each cache entry = two files:
```
{key}.stdout # exact gh stdout (byte-identical to what real gh returned)
{key}.ts # single line: epoch seconds when entry was written
```
Example:
```
$AO_DATA_DIR/.ghcache/issue-30-fix/
pr-discovery-feat--issue-30.stdout # [{"number":57,"url":"...","title":"..."}]
pr-discovery-feat--issue-30.ts # 1713567890
issue-ctx-31.stdout # {"number":31,"title":"...","body":"..."}
issue-ctx-31.ts # 1713567850
```
### Why two files instead of embedding timestamp
The `.stdout` file is returned to the agent as-is via `cat`. No parsing, no stripping, no risk of corrupting JSON output. The `.ts` file is a one-line epoch used only by the cache-freshness check.
---
## Intercept 1: PR Discovery
### What agents are doing
From D4 traces, every agent session repeatedly runs:
```
gh pr list --repo iamasx/api-test --head feat/issue-X --json number,url,title,headRefName,baseRefName,isDraft --limit 1
```
The 10-session run showed:
- 75 identical calls for `feat/issue-30`
- 74 identical calls for `feat/issue-27`
- 74 identical calls for `feat/issue-26`
- 73 identical calls for `feat/issue-22`
- ...
Once branch `feat/issue-30` has PR #57, that answer never changes. Yet the agent keeps asking.
### Match conditions
ALL must be true:
- `$1` = `pr`, `$2` = `list`
- `--head <branch>` flag present
- `--limit 1` present
- None of these flags present: `--search`, `--state`, `--assignee`, `--label`, `--jq`, `--template`
If any condition fails → passthrough to real `gh`.
### Cache key
```
pr-discovery-{sanitized_branch}
```
Where `sanitized_branch` = `--head` value with non-alphanumeric chars (except `.`, `_`, `-`) replaced by `-`:
```bash
safe_branch=$(printf '%s' "$head_val" | tr -c 'a-zA-Z0-9._-' '-')
```
### TTL
- **Positive result** (non-empty JSON array, i.e., stdout is not `[]`): **infinite** within session.
- **Negative result** (`[]` or empty): **not cached**. The PR might be created at any moment.
### Cache-read flow
```
1. Parse args, extract --head value and --repo value
2. Compute cache_key = "pr-discovery-{safe_branch}"
3. If cache file exists (positive = infinite TTL):
cat "$cache_dir/$cache_key.stdout"
exit 0
4. Else:
call real gh, capture stdout to tmpfile
if exit_code == 0 AND stdout is not "[]" and not empty:
write tmpfile → $cache_dir/$cache_key.stdout
write epoch → $cache_dir/$cache_key.ts
cat tmpfile
exit $exit_code
```
### Integration with `gh pr create` write intercept
The existing `pr/create` intercept (wrapper lines 212-244) already captures the PR URL and number after a successful `gh pr create`. Extend it to also populate the PR discovery cache:
After the existing metadata writes:
```bash
# Populate PR discovery cache so subsequent gh pr list --head hits cache
_branch="$(read_ao_metadata branch)"
if [[ -n "$_branch" && -n "$pr_url" && -n "$pr_number" ]]; then
_safe_branch=$(printf '%s' "$_branch" | tr -c 'a-zA-Z0-9._-' '-')
_cache_key="pr-discovery-${_safe_branch}"
_cache_dir="$(ao_cache_dir)"
if [[ -n "$_cache_dir" ]]; then
# Write a minimal but valid gh pr list JSON response.
# Contains only the fields we know. If the agent later asks for
# fields not present here, those fields will be null/missing in
# the cached response. This is acceptable because:
# - The most common query shape includes these exact fields
# - Any missing-field issue self-corrects on the next real call
# if the agent retries or asks for different fields
_draft_val="${report_draft:-false}"
printf '[{"number":%s,"url":"%s","headRefName":"%s","isDraft":%s}]\n' \
"$pr_number" "$pr_url" "$_branch" "$_draft_val" \
> "$_cache_dir/${_cache_key}.stdout.tmp.$$"
mv "$_cache_dir/${_cache_key}.stdout.tmp.$$" "$_cache_dir/${_cache_key}.stdout"
date +%s > "$_cache_dir/${_cache_key}.ts"
fi
fi
```
This means: the moment `gh pr create` succeeds, the very next `gh pr list --head` for that branch hits cache with zero API calls.
### Projected reduction
From D4 10-session data: 598 calls → ~10 (one real call per session, the first discovery). **~588 calls eliminated (98%).**
---
## Intercept 2: Issue Context
### What agents are doing
From D4 traces, agents repeatedly fetch the same issue:
```
gh issue view 31 --json number,title,body,state,labels,assignees
```
- issue 31 viewed 9 times
- issue 30 viewed 9 times
- issue 29 viewed 9 times
- issues 28, 27, 26, 24, 23, 22 viewed 7 times each
- issue 25 viewed 6 times
Issue metadata (title, body, labels) does not change during a typical agent work session. Fetching once is sufficient.
### Match conditions
ALL must be true:
- `$1` = `issue`, `$2` = `view`
- Third positional arg is a number (the issue identifier)
- None of these flags present: `--web`, `--comments`, `--jq`, `--template`
If any condition fails → passthrough.
### Cache key
```
issue-ctx-{issue_id}
```
The issue ID is the numeric identifier extracted from the positional arg. No sanitization needed (it's a number).
### TTL
**300 seconds (5 minutes).** Issue content can change externally (someone edits the title or adds a label), but this is rare during an active agent session. A 5-minute window is conservative enough to avoid serving truly stale data while eliminating most repeated fetches.
### Cache-read flow
```
1. Parse args, extract issue ID (first positional arg after "issue view")
2. Compute cache_key = "issue-ctx-{issue_id}"
3. If cache file exists AND age < 300 seconds:
cat "$cache_dir/$cache_key.stdout"
exit 0
4. Else:
call real gh, capture stdout to tmpfile
if exit_code == 0:
write tmpfile → $cache_dir/$cache_key.stdout
write epoch → $cache_dir/$cache_key.ts
cat tmpfile
exit $exit_code
```
### Age check
```bash
ao_cache_fresh() {
local cache_key="$1" max_age="$2"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
local ts_file="$cache_dir/${cache_key}.ts"
local stdout_file="$cache_dir/${cache_key}.stdout"
[[ -f "$stdout_file" && -f "$ts_file" ]] || return 1
# max_age=0 means infinite TTL
[[ "$max_age" -eq 0 ]] && return 0
local cached_ts now
cached_ts=$(cat "$ts_file" 2>/dev/null) || return 1
now=$(date +%s)
(( now - cached_ts < max_age ))
}
```
### Projected reduction
From D4 10-session data: 75 calls → ~20 (one real call per issue per 5-minute window). **~55 calls eliminated (73%).**
---
## Helper Functions
Added to `AO_METADATA_HELPER` (sourced by both `gh` and `git` wrappers):
### `read_ao_metadata()`
```bash
read_ao_metadata() {
local key="$1"
local ao_dir="${AO_DATA_DIR:-}"
local ao_session="${AO_SESSION:-}"
[[ -z "$ao_dir" || -z "$ao_session" ]] && return 1
case "$ao_session" in */* | *..*) return 1 ;; esac
case "$ao_dir" in
"$HOME"/.ao/* | "$HOME"/.agent-orchestrator/* | /tmp/*) ;;
*) return 1 ;;
esac
local metadata_file="$ao_dir/$ao_session"
[[ -f "$metadata_file" ]] || return 1
[[ "$key" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1
local line
line=$(grep "^${key}=" "$metadata_file" 2>/dev/null | head -1) || return 1
printf '%s' "${line#*=}"
}
```
### `ao_cache_dir()`
```bash
ao_cache_dir() {
local ao_dir="${AO_DATA_DIR:-}"
local ao_session="${AO_SESSION:-}"
[[ -z "$ao_dir" || -z "$ao_session" ]] && return 1
case "$ao_session" in */* | *..*) return 1 ;; esac
case "$ao_dir" in
"$HOME"/.ao/* | "$HOME"/.agent-orchestrator/* | /tmp/*) ;;
*) return 1 ;;
esac
local d="$ao_dir/.ghcache/$ao_session"
mkdir -p "$d" 2>/dev/null || return 1
printf '%s' "$d"
}
```
### `ao_cache_fresh()`
As defined in the issue-context section above.
### `ao_cache_read()`
```bash
ao_cache_read() {
local cache_key="$1"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
cat "$cache_dir/${cache_key}.stdout"
}
```
### `ao_cache_write()`
```bash
# Stdin is piped in. Writes atomically.
ao_cache_write() {
local cache_key="$1"
local cache_dir
cache_dir="$(ao_cache_dir)" || return 1
local tmp="$cache_dir/${cache_key}.stdout.tmp.$$"
cat > "$tmp" && mv "$tmp" "$cache_dir/${cache_key}.stdout"
date +%s > "$cache_dir/${cache_key}.ts"
}
```
---
## New Wrapper Structure
The `GH_WRAPPER` dispatch changes from:
```bash
# Current:
log_gh_invocation "$@"
case "$1/$2" in
pr/create) ... ;;
*) exec "$real_gh" "$@" ;;
esac
```
To:
```bash
log_gh_invocation "$@"
# ── Cacheable reads ──────────────────────────────────────
# 1. PR discovery: gh pr list --head <B> --limit 1
if [[ "$1" == "pr" && "$2" == "list" ]]; then
<parse args, check match conditions>
if <matched>; then
cache_key="pr-discovery-${safe_branch}"
if ao_cache_fresh "$cache_key" 0; then
ao_cache_read "$cache_key"
exit 0
fi
<call real gh, capture stdout>
if [[ $exit_code -eq 0 ]] && <stdout is not empty/[]>; then
<write to cache>
fi
<output stdout>
exit $exit_code
fi
fi
# 2. Issue context: gh issue view <N> --json ...
if [[ "$1" == "issue" && "$2" == "view" ]]; then
<parse args, check match conditions>
if <matched>; then
cache_key="issue-ctx-${issue_id}"
if ao_cache_fresh "$cache_key" 300; then
ao_cache_read "$cache_key"
exit 0
fi
<call real gh, capture stdout>
if [[ $exit_code -eq 0 ]]; then
<write to cache>
fi
<output stdout>
exit $exit_code
fi
fi
# ── Write intercepts (existing, enhanced) ────────────────
case "$1/$2" in
pr/create)
<existing pr create logic>
<NEW: populate pr-discovery cache after success>
exit $exit_code
;;
*)
exec "$real_gh" "$@"
;;
esac
```
---
## Session-Manager Changes
### Make PATH wrappers universal for all agents
**`packages/core/src/session-manager.ts`**
At the `setupWorkspaceHooks` call site (~line 1524), add wrapper installation:
```typescript
// Setup agent hooks for automatic metadata updates
try {
if (plugins.agent.setupWorkspaceHooks) {
await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir });
}
// Always install shared wrappers — every agent gets gh/git interception
await setupPathWrapperWorkspace(workspacePath);
} catch (err) {
await cleanupWorktreeAndMetadata();
throw err;
}
```
At all 3 `runtime.create()` sites (lines ~1257, ~1608, ~2697), inject PATH and GH_PATH after the agent environment spread:
```typescript
environment: {
...environment,
PATH: buildAgentPath(environment["PATH"] ?? process.env["PATH"]),
GH_PATH: PREFERRED_GH_PATH,
AO_SESSION: sessionId,
AO_DATA_DIR: sessionsDir,
// ... rest unchanged
},
```
### Remove per-plugin boilerplate
Remove `buildAgentPath()` and `setupPathWrapperWorkspace()` calls from:
- `packages/plugins/agent-codex/src/index.ts`
- `packages/plugins/agent-aider/src/index.ts`
- `packages/plugins/agent-opencode/src/index.ts`
- `packages/plugins/agent-cursor/src/index.ts`
These plugins no longer need to import or call these functions. The session manager handles it.
### Bump wrapper version
`packages/core/src/agent-workspace-hooks.ts:35`:
```typescript
const WRAPPER_VERSION = "0.4.0";
```
Existing workspaces auto-refresh when the version marker doesn't match.
---
## Cache Cleanup
### On session delete/archive
In `deleteMetadata()` (`metadata.ts:263`), add cleanup of the session's cache directory:
```typescript
// Clean up gh cache for this session
const cachePath = join(dataDir, ".ghcache", sessionId);
try { rmSync(cachePath, { recursive: true, force: true }); } catch { /* best effort */ }
```
### No cross-session leakage
Each session has its own cache subdirectory keyed by `$AO_SESSION`. A restored session gets a fresh directory. No stale data from previous runs.
---
## Safety
| Scenario | Handling |
|----------|---------|
| `$AO_DATA_DIR` or `$AO_SESSION` unset | All cache functions return 1 (failure). Wrapper falls through to real `gh`. Agent sees no difference. |
| Cache directory creation fails (permissions, disk full) | `ao_cache_dir` returns 1. Cache write silently skipped. Real `gh` is called. |
| `gh pr list --head` returns `[]` (no PR yet) | Not cached. Every subsequent call goes to real `gh` until a PR exists. |
| Agent uses `--jq` or `--template` | Match conditions reject these flags. Passthrough to real `gh`. |
| Agent uses different `--json` field sets for the same branch/issue | Cached stdout is the exact response from the first call. If the first call had `--json number,url,title,...` and a later call asks for `--json number,url` only, the cached response has _more_ fields than requested — `gh` clients tolerate this. If the later call asks for fields _not_ in the cached response, it gets a response without those fields. This is a theoretical edge case — the D4 traces show agents use the same `--json` field set consistently. |
| Concurrent cache writes from parallel commands | Atomic write pattern: write to `$file.tmp.$$`, then `mv`. Same pattern used by existing metadata writes. |
| Clock skew (container, VM) | Only affects TTL accuracy. A few seconds of drift on a 300s TTL is negligible. |
---
## Files Changed
| File | Change |
|------|--------|
| `packages/core/src/agent-workspace-hooks.ts` | Add cache helpers to `AO_METADATA_HELPER`. Add read intercepts + cache-write-on-create to `GH_WRAPPER`. Bump `WRAPPER_VERSION`. |
| `packages/core/src/session-manager.ts` | Add `setupPathWrapperWorkspace()` call. Add `buildAgentPath()` + `GH_PATH` to all 3 environment sites. |
| `packages/core/src/metadata.ts` | Add `.ghcache` cleanup in `deleteMetadata()`. |
| `packages/plugins/agent-codex/src/index.ts` | Remove `buildAgentPath()`, `setupPathWrapperWorkspace()`, `GH_PATH` from plugin. |
| `packages/plugins/agent-aider/src/index.ts` | Same removal. |
| `packages/plugins/agent-opencode/src/index.ts` | Same removal. |
| `packages/plugins/agent-cursor/src/index.ts` | Same removal. |
| `packages/core/src/__tests__/agent-workspace-hooks.test.ts` | Tests for cache helpers, PR discovery intercept, issue context intercept, `gh pr create` cache population, passthrough on unsupported flags, TTL expiry. |
---
## Projected Impact
From the D4 10-session / 22-minute run (916 agent-side `gh` calls):
| Pattern | Before | After | Saved |
|---------|--------|-------|-------|
| `gh pr list --head` | 598 | ~10 | ~588 (98%) |
| `gh issue view` | 75 | ~20 | ~55 (73%) |
| **Subtotal (in scope)** | **673** | **~30** | **~643 (96%)** |
| Everything else (unchanged) | 243 | 243 | 0 |
| **Total** | **916** | **~273** | **~643 (70%)** |
The two intercepts eliminate ~70% of all agent-side `gh` traffic and ~96% of the traffic they target.