Files
gstack/test/capy-captain-flow.test.ts
Garry Tanandcapy-ai[bot] 0cab97447c v1.91.314.0 feat(autoplan): carry the owner's answer forward, gate ELI5 field, stacked approvals import (#3583)
* refactor: share the decision-trail append rules (lib/decision-trail.ts)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(autoplan): carry the owner's answer to a later gate that asks nothing new, gate.json eli5, stacked approvals import

A later gate revision with nothing new is answered with the owner's recorded words
(CARRIED); a 'do it' also covers new auto items that change no scope, cost or
direction; an unreviewed post-cap delta gets one cross-family reviewer first
(CARRY_CHECK, gstack-autoplan check). gate.json gains eli5 and the Captain brief
requires ## ELI5 on runs that carry it. gstack-approvals import --stack writes
every branch or none. The first next prints REVIEW_ESTIMATE.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry-forward, ELI5 section and stacked approvals import

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(autoplan): carry only fully owner-answered gates; void stale delta checks

- a pending delta check is voided (reservation released) when the owner answers,
  the plan is revised, or a new gate is written; a check bound to an older delta is void
- carry refuses a gate with any approval item still pending or answered in item tokens
- "do it" never covers an option that changes the plan; isDoIt ignores Captain-relayed prefixes
- captain brief rebuilds ELI5 from the current plan and lists items taken under "do it"
- gstack-approvals import --stack locates every branch before writing any
- DRIVER.md back under its 162-line budget

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: carry-forward conditions, delta-check voiding and import --stack match the final code

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog for autoplan carry-forward (unreleased; stamped at merge)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: stamp v1.91.314.0

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-29 09:58:34 +00:00

108 lines
5.6 KiB
TypeScript

/**
* The Capy Captain flow end to end on the simulated Capy host, through the
* CLIs only: an autoplan run on a plan with the four brief sections reaches
* its gate and prints the Capy RELAY line; the Captain brief carries the
* gate's rec_hash; a relayed `rec` answered with that hash approves; the
* approval record imports the owner's exact words once; and its ## Approvals
* table is mirrored to GSTACK_APPROVALS_DIR. The negative control: the same
* run without GSTACK_CAPY prints no RELAY line.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ROOT, resultOf } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, writePlan } from './helpers/autoplan-capy';
const r = capyRunner();
afterAll(() => r.cleanup());
const PLAN = `# Retry backoff for the webhook worker
## ELI5
A failed webhook now waits longer before each retry, so the partner stops dropping them.
## Before → after
Before: a failed webhook retries every second until it is dropped. After: retries back off from 500 ms to 30 s.
## Why it matters
Bursty retries hit the partner's rate limit and drop about 2% of deliveries.
## What caused this work
Issue #41: "webhooks vanish under load".
## Big decisions
1. Exponential backoff with jitter rather than a fixed delay. Would flip if the partner publishes a retry-after header.
## Implementation plan
Add exponential backoff to the webhook worker's retry loop in lib/worker.ts: base 500 ms, factor 2, cap 30 s, jitter 10%.
Persist the attempt count on the job row so a restarted worker resumes the schedule. Tests cover the cap, the jitter bounds and the resume path.
## Review record
`;
const bin = (name: string, args: string[], extra: Record<string, string> = {}, cwd = ROOT) =>
spawnSync('bun', [path.join(ROOT, 'bin', name), ...args], { cwd, env: { ...r.env, ...extra }, encoding: 'utf8', timeout: 120_000 });
describe('Capy Captain flow', () => {
test('gate → RELAY line → Captain brief with rec_hash → relayed answer → approval record and mirror', () => {
const dir = path.join(r.host.base, 'captain');
const plan = writePlan(dir, PLAN);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--no-dx']).status).toBe(0);
phaseVia(r, out, 'ceo');
const eng = phaseVia(r, out, 'eng');
const gateOut = eng.outside.stdout + eng.next.stdout;
expect(gateOut).toContain('status=gate_pending');
expect(gateOut).toContain(`RELAY: gstack-owner-brief render --captain --out ${out} `);
const brief = bin('gstack-owner-brief', ['render', '--captain', '--out', out, '--json']);
expect(brief.status, brief.stderr).toBe(0);
const j = JSON.parse(brief.stdout);
expect(j.missing).toEqual([]);
expect(j.brief).toStartWith('**Decision needed:** approve the plan "Retry backoff for the webhook worker".');
for (const h of ['### Before → after', '### Why it matters', '### What caused this work', '### Big decisions', '### What we need from you']) expect(j.brief).toContain(h);
const answer = r.ap(['answer', '--out', out, '--gate-rev', String(j.gate_rev), '--reply', 'rec', '--rec-hash', j.rec_hash, '--by', 'agent:GSTA-1', '--owner-reply', 'rec']);
expect(answer.status, answer.stderr + answer.stdout).toBe(0);
expect(answer.stdout).toMatch(/^APPROVED: gate_rev=1/m);
const repo = path.join(r.host.base, 'repo');
fs.mkdirSync(repo, { recursive: true });
spawnSync('git', ['init', '-q', '-b', 'fix/backoff'], { cwd: repo, timeout: 30_000 });
const mirror = path.join(r.host.base, 'drive-approvals');
const env = { GSTACK_APPROVALS_DIR: mirror, GSTACK_PROJECT_SLUG: 'acme-worker' };
const imp = bin('gstack-approvals', ['import', '--autoplan', out, '--by', 'Garry'], env, repo);
expect(imp.status, imp.stderr).toBe(0);
expect(imp.stdout).toMatch(/^APPROVAL_IMPORTED: n=1 skipped=0 /);
expect(bin('gstack-approvals', ['import', '--autoplan', out, '--by', 'Garry'], env, repo).stdout).toMatch(/^APPROVAL_IMPORTED: n=0 skipped=1 /);
const table = bin('gstack-approvals', ['render'], env, repo).stdout;
expect(table).toContain('Approve the plan "Retry backoff for the webhook worker"');
expect(table).toContain('“rec” (Garry, relayed by agent:GSTA-1) | approved |');
expect(fs.readdirSync(path.join(mirror, 'acme-worker')).length).toBe(1);
});
test('without GSTACK_CAPY the gate prints no RELAY line', () => {
const dir = path.join(r.host.base, 'plain');
const plan = writePlan(dir, PLAN);
const out = path.join(dir, 'run');
expect(r.ap(['next', '--out', out, '--plan', plan, '--no-ui', '--no-dx']).status).toBe(0);
phaseVia(r, out, 'ceo');
expect(r.ap(['next', '--out', out]).status).toBe(0);
for (const voice of ['native', 'outside']) expect(r.review(path.join(out, `eng-${voice}-prompt.md`)).status).toBe(0);
const run = JSON.parse(fs.readFileSync(path.join(out, 'run.json'), 'utf8'));
const submit = (voice: string, model: string) => r.ap(['submit', '--out', out, '--phase', 'eng', '--voice', voice, '--result', resultOf(out, 'eng', voice as 'native' | 'outside'), '--model', model, '--attempt', run.phases.eng[voice].attempt, '--runner', 'host-subagent'], { GSTACK_CAPY: '' });
expect(submit('native', 'anthropic/claude-opus-5-5').status).toBe(0);
const last = submit('outside', 'openai/gpt-6.1-sol');
expect(last.stdout).toContain('status=gate_pending');
expect(last.stdout).toMatch(/^GATE: gate_rev=1 /m);
expect(last.stdout).not.toContain('RELAY: gstack-owner-brief');
});
});