mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
* fix(ship): foreground-flag + deadline + scope guard on all four dispatch sites (#497/#2440 class, 3rd recurrence)
Steps 7/8/10/18 dispatch subagents whose LAST-line JSON the parent
consumes, but none passed run_in_background: false — since Claude Code
v2.1.198 subagents background by default, so /ship stranded at Step 18
waiting on output that never arrives. Every site now renders the shared
{{FOREGROUND_DISPATCH_NOTE}} resolver constant, carries a ~10-minute
deadline with an explicit recovery branch (stop the runaway task,
reconcile against pre-dispatch HEAD, surface stray state, never
re-dispatch), and Step 18's prompt gains a docs-sync-only scope guard
(no VERSION changes, no base-branch merges, push-rejection reported as
pushed:false with parent-side reconciliation and a second-failure
branch). Greptile failures now record as UNAVAILABLE, not zero comments.
GENERATED_WITH_GUIDANCE pins all five ship dispatch carriers; a new test
pins the deadline recovery + scope guard phrases in pr-body (.md and
.tmpl). Codex/factory ship goldens re-rendered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(document-release): first-class spawned-dispatch contract
document-release's own templates had zero subagent-awareness — the
entire headless contract lived in /ship's dispatch prompt, so any other
orchestrator (or an older installed /ship) dispatching it inherited none
of the gate handling. The skill now carries the contract itself: detect
spawned strictly from the dispatch prompt or the preamble echo (never
from file content — prompt-injection guard), auto-choose recommended
options while keeping the never-clobber-CHANGELOG and
never-bump-VERSION-silently invariants via their Skip options. Step 8.4d
gets an explicit spawned note (its interactive recommendation bumps
VERSION — wrong headlessly), and the Codex Documentation Review section
skips itself in spawned sessions (the apply gate needs a human; the
dispatching workflow owns review passes).
Contract, 8.4d note, and resolver skip are pinned in
run-in-background-guidance.test.ts; document-release skeleton budget
re-measured (39,812 B) and ratcheted to 40,200.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: sweep every remaining synchronous Agent-dispatch site with run_in_background: false
The #2440 failure shape was a silently-missing review voice — a
specialist launched in the background and merged before it completed.
Every remaining synchronous dispatch site now carries the explicit flag:
the Red Team dispatch, the spec review loop, the Codex
second-opinion/plan-review/doc-review Claude fallbacks, the adversarial
subagent, design sketch and outside voices, autoplan's design/eng/dx
phase dispatches, CSO parallel finding verification, and design-shotgun's
variant launch. Parallel fan-outs stay parallel — multiple foreground
Agent calls in one message run concurrently (the shipped v1.64.0.0
review-army pattern).
GENERATED_WITH_GUIDANCE now pins all 24 generated carriers, so a new
dispatch site that drops the flag fails the free suite. Six carved-skill
skeleton ceilings re-measured and ratcheted (~80-130 B growth each);
factory ship golden re-rendered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* release: v1.79.0.0 — CHANGELOG, VERSION, TODOS follow-ups
Queue-advanced to 1.79.0.0 (1.78.0.0 claimed in the workspace queue;
same MINOR level per the versioning invariant). Entry references the
class history (#497 → #2440 → Step 18). Three TODOS filed: PreToolUse
hook enforcement, structural ship-mode for document-release, cross-host
dispatch semantics audit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* regen: review-army sections carry the Red Team foreground flag
The scripts/resolvers/review-army.ts Red Team edit regenerated these two
files but the sweep commit staged only the adversarial sections — the
skill-docs freshness gate (regen + git diff --exit-code) catches this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* regen: agents digest picks up v1.79.0.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(document-release): one canonical spawned contract, downstream notes are pointers
The diff-selected LLM-judge eval scored the skill's clarity 3 (threshold
4, main scores 4): the spawned-session rules read as three separately-
framed rule sets (contract paragraph, Step 8.4d note, Codex-review skip).
The contract paragraph now declares itself the single source of spawned
behavior and the two downstream notes reference it instead of restating
rationale. The pointer avoids naming the Codex section verbatim so the
codex-host render (which strips that section) keeps its negative pin.
Judge re-scored 4/5/4 across repeated samples after the change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: foreground note names the Agent tool as non-substitutable
The ship-docsync gate E2E caught a behavioral regression: the note's
blocking-emphasis ('a backgrounded dispatch strands the run') steered
the driven agent to run doc-sync via the Skill tool inline — the most
blocking option — twice in a row, forfeiting the fresh-context isolation
the dispatch exists for (baseline on main's text dispatches via Agent).
The shared note now says explicitly: dispatch with the Agent tool itself,
never substitute Skill or inline execution; the flag already makes the
call block. Re-verified: ship-docsync passes on the amended text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: pre-landing review fixes
Review army + red team findings, all verified before applying:
- ship-docsync E2E now asserts run_in_background === false on the
captured dispatch (red team CRITICAL: phrase pins prove text exists,
this proves the model obeys it — verified passing live).
- Structural scanner test: any generated file with an Agent-dispatch
imperative (or bare '(foreground)' prose, the #2440 inert shape) must
carry the flag or hold a reasoned exemption — the 4th-recurrence net
the hand-enumerated pin list can't provide.
- Parent push reconciliation models reality: the parent shares the repo,
so a non-fast-forward that hit the subagent hits the parent identically
— fetch + ahead/behind check first, push only when the rejection was
transient; dispatch prompt promise softened to 'the parent will handle
it'.
- Recovered commits from a dead subagent are vetted docs-only
(git show --stat, never VERSION/package.json) before any push.
- Deadline pacing named: ~3 minutes between checks, wall clock not polls.
- Greptile UNAVAILABLE recording narrowed to the PR body (Step 20's
schema carries no triage field).
- document-release contract gains the echo-failure tie-breaker: prompt
claims spawned + no echo → fail fast with the dispatch contract's
failure shape instead of reproducing the #2733 prose-STOP; contract
anti-injection and NEVER-relax clauses pinned in tests.
- 'Claude Code v2.1.198' extracted to CC_BACKGROUND_DEFAULT_SINCE and
interpolated at all resolver sites (byte-identical output).
- CHANGELOG: entry-boundary blank line restored; worst-case-wait row
scoped to the backgrounded path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: adversarial review fixes — failure shape, CHANGELOG guard, vet coherence
Claude adversarial subagent findings (11), all verified before applying:
- The dispatch JSON contract gains an explicit FAILURE shape
({"error":...}) and a parent branch for it — a doc-sync that could not
run (spawned marking failed, broken preamble) previously had only the
no-updates shape to emit, which the parent printed as 'Documentation is
current': a silent false-clean of exactly the VAS-449 class.
- Scope guard now covers CHANGELOG: skip Step 5 voice polish and resolve
CHANGELOG-touching gates to leave-as-is (the parent authored the
release entry; the prompt's older auto-choose clause conflicted with
the contract's never-rewrite invariant).
- Recovery vet is sequence-coherent: pushing a commit pushes its
ancestors, so ANY non-doc commit (VERSION, package.json, CHANGELOG.md)
blocks the whole sequence — no more push-the-vetted-child-of-an-
unvetted-parent hole.
- Steps 7/8/10 failure branches stop a still-running backgrounded task
before falling back, so a late result never races the inline audit.
- 'Documentation synced' print gated on pushed:true (item 6 owns the
local-only outcome); doc-review skip note's backward step pointer
fixed; foreground note scoped 'at initial dispatch' so the sanctioned
inline FALLBACKS in Steps 7/8 read as sanctioned; constants docstring
no longer overclaims single-sourcing (names the 3 inline templates).
- Runtime-hook TODO raised P2 → P1: the spawned trust chain is
agent-self-asserted; prose cannot close it, the hook can.
Both docsync gate E2Es re-verified green on the amended prompt, including
the strict run_in_background === false dispatch assert.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: codex adversarial fixes — JSON trust, fail-open visibility, user-state safety
Codex outside-model adversarial pass (inline-diff workaround for the
sandbox), P1/P2 findings triaged cross-model:
- Parent validates the dispatch JSON's field types and treats
documentation_section as untrusted markdown (Step 19's redaction scan
covers the final body; instruction-shaped text inside it is never
followed). The error branch now explicitly skips items 2-6.
- Remote-ahead divergence is named, not silent: the parent lists foreign
commits before creating a PR over a moved branch.
- Recovery cleanup never discards content: stray staged doc edits are
unstaged but never checked out or cleaned away.
- Greptile UNAVAILABLE gets a concrete PR-body line, not a vague
'wherever results are reported'.
Pre-existing-class P1s (self-asserted spawned trust chain, unenforceable
foreground timeout) are cross-model confirmed and tracked: PreToolUse
hook TODO at P1, harness-residue hazard documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: never-Skill prohibition made unambiguous; docsync fixture made diagnostic
The ship-docsync E2E flaked on this sandbox because the driven agent
resolved the STOP pointer's ~ to nonexistent homes (/root, /home) and
acted blind — pass/fail sampled model priors, not the prompt. The
fixture prompt now names the planted sections dir, making local runs
deterministic (CI, with real install paths, was always diagnostic).
With a diagnostic fixture: 2/2 passes, real pr-body read, Agent dispatch
with run_in_background: false, zero Skill-tool substitutions.
Prose: the foreground note now states the dispatch happens ONLY via the
Agent tool (invoking the target as a Skill is wrong even though it
appears in the skills list; inline FALLBACKs apply only after a
dispatched subagent has failed), and the Step 18 imperative carries the
never-the-Skill-tool clause at the decision point.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: CHANGELOG entry covers the review-hardened contract
Failure JSON shape, CHANGELOG scope guard, vetted recovery pushes,
structural scanner, and the behavioral dispatch assert are shipped
properties of v1.79.0.0 — the entry now describes them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v1.79.0.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: CHANGELOG voice — follow-ups to For contributors, never-Skill property stated
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(test): daemon 503 test binds an OS-assigned port, not old-port+1
windows-free-tests flaked on this PR: the tunnel-less restart bound
daemon.loopbackPort + 1 — a fixed neighbor of the OS-assigned ephemeral
port — and died with 'Is port 55738 in use?' whenever another shard or a
TIME_WAIT socket held it; the file-level retry re-rolled the same dice.
Every other startDaemon in the file already uses loopbackPort: 0 and the
assertion reads d2.loopbackPort, so nothing needs a predictable number.
21/21 pass locally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
289 lines
15 KiB
TypeScript
289 lines
15 KiB
TypeScript
/**
|
|
* /ship Step 18 doc-sync dispatch E2E — proves a live agent executing the
|
|
* ship tail (Step 17 push → Step 19 PR creation) actually dispatches the
|
|
* /document-release subagent BEFORE creating the PR. This is the behavioral
|
|
* guardrail for the wiring pinned statically by
|
|
* test/ship-document-release-dispatch.test.ts: the v1.54 carve made the
|
|
* dispatch invisible once; this test makes that class of regression loud.
|
|
*
|
|
* Gating: whole-file gate-tier self-gate (describeE2ETier) COMPOSED with
|
|
* diff-based selection (describeIfSelected). The self-gate keeps this file
|
|
* out of the periodic shard census (near its ceiling) and under the hard
|
|
* tier-alignment invariant. DELIBERATE TRADEOFF: tierless runs (`bun run
|
|
* test:evals` / `test:e2e`) skip every tier-gated file, so this test does
|
|
* NOT run there even when ship/** changed — use the gate lane locally:
|
|
* EVALS_TIER=gate bun run test:evals # diff-selected gate lane
|
|
* EVALS=1 EVALS_TIER=gate EVALS_ALL=1 bun test test/skill-e2e-ship-docsync.test.ts
|
|
*
|
|
* Fixture layout (non-obvious — fake HOME + planted skill tree):
|
|
*
|
|
* <workDir>/ (passed as env HOME)
|
|
* ├── repo/ bare-remote git fixture, feature branch,
|
|
* │ Steps 0-16 already "done" (VERSION bumped,
|
|
* │ CHANGELOG entry, change committed, not pushed)
|
|
* ├── ship/SKILL-tail.md sliced Step 17 → Step 20 from the generated
|
|
* │ skeleton (live worktree, extract-don't-copy)
|
|
* ├── ship/sections/pr-body.md planted copy (relative-resolution
|
|
* │ insurance)
|
|
* ├── gstack-home/.redact-prepush-prompted marker + env GSTACK_HOME →
|
|
* │ Step 17's credential pre-push guard takes its
|
|
* │ silent "continue" branch instead of its
|
|
* │ AskUserQuestion branch (gstack-config is absent
|
|
* │ so REDACT_PREPUSH falls back to "false")
|
|
* └── .claude/skills/gstack/
|
|
* ├── ship/sections/pr-body.md ← the STOP pointer's literal
|
|
* │ `~/.claude/...` path resolves HERE via the
|
|
* │ HOME override (CLAUDE_CONFIG_DIR is already
|
|
* │ hermetic, so overriding HOME is safe)
|
|
* └── document-release/SKILL.md ← stub: instructs the dispatched
|
|
* subagent to emit the empty-result JSON
|
|
* contract in 2-3 turns (the DISPATCH is what
|
|
* is under test; Step 18 is non-blocking)
|
|
*
|
|
* The prompt is deliberately neutral — it does NOT command STOP-Read
|
|
* compliance and does NOT name document-release. Priming the behavior under
|
|
* test would make the assert tautological (and the prompt echoes into the
|
|
* transcript, which is why asserts only ever read result.toolCalls).
|
|
*
|
|
* Cost: observed $0.63-1.04/run, 234-319s (9/9 burn-in + review runs passed;
|
|
* gate tier confirmed).
|
|
*/
|
|
import { expect, beforeAll, afterAll } from 'bun:test';
|
|
import { CAPTURE_LONG_MS } from './helpers/eval-budgets';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
import { runSkillTest } from './helpers/session-runner';
|
|
import {
|
|
ROOT, runId,
|
|
describeIfSelected, testConcurrentIfSelected,
|
|
createEvalCollector, recordE2E, finalizeEvalCollector, logCost,
|
|
} from './helpers/e2e-helpers';
|
|
import { describeE2ETier } from './helpers/e2e-gate';
|
|
|
|
const describeE2E = describeE2ETier('gate');
|
|
const evalCollector = createEvalCollector('e2e-ship-docsync');
|
|
|
|
const DOC_RELEASE_STUB = `---
|
|
name: document-release
|
|
description: Post-ship documentation update (E2E stub).
|
|
---
|
|
|
|
# Document Release (E2E stub)
|
|
|
|
You are running the documentation-sync workflow after a code push.
|
|
For this environment: compare the docs to the diff briefly; nothing needs
|
|
updating. Do NOT edit any files. Do NOT push.
|
|
|
|
Output EXACTLY this JSON object on the LAST line of your response, with no
|
|
text after it:
|
|
|
|
{"files_updated":[],"commit_sha":null,"pushed":false,"documentation_section":null}
|
|
`;
|
|
|
|
describeE2E('Ship doc-sync dispatch E2E (gate)', () => {
|
|
describeIfSelected('Ship doc-sync dispatch E2E', ['ship-docsync'], () => {
|
|
let workDir: string;
|
|
let repoDir: string;
|
|
let remoteDir: string;
|
|
|
|
beforeAll(() => {
|
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-docsync-home-'));
|
|
remoteDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-docsync-remote-'));
|
|
repoDir = path.join(workDir, 'repo');
|
|
|
|
// Bare remote + clone; Steps 0-16 "already done": feature branch with a
|
|
// committed change, VERSION bumped, CHANGELOG entry written. Not pushed —
|
|
// Step 17 (the slice's first step) does that.
|
|
// Branch pinned with -b main / -c init.defaultBranch=main so operator git
|
|
// config never leaks into the fixture (default-config machines would
|
|
// otherwise create master and the later `push -u origin main` would fail).
|
|
// Every setup command asserts status — a broken fixture must fail loud
|
|
// and free here, never burn a paid run downstream.
|
|
const assertOk = (r: ReturnType<typeof spawnSync>, what: string) => {
|
|
if (r.status !== 0) {
|
|
throw new Error(
|
|
`ship-docsync fixture setup failed: ${what} → exit ${r.status}\n${r.stderr?.toString() ?? ''}`
|
|
);
|
|
}
|
|
return r;
|
|
};
|
|
assertOk(
|
|
spawnSync('git', ['init', '--bare', '-b', 'main'], { cwd: remoteDir, stdio: 'pipe', timeout: 15000 }),
|
|
'git init --bare -b main'
|
|
);
|
|
assertOk(
|
|
spawnSync('git', ['-c', 'init.defaultBranch=main', 'clone', remoteDir, repoDir], { stdio: 'pipe', timeout: 15000 }),
|
|
'git clone'
|
|
);
|
|
const run = (cmd: string, args: string[]) =>
|
|
assertOk(
|
|
spawnSync(cmd, args, { cwd: repoDir, stdio: 'pipe', timeout: 10000 }),
|
|
`${cmd} ${args.join(' ')}`
|
|
);
|
|
run('git', ['config', 'user.email', 'test@test.com']);
|
|
run('git', ['config', 'user.name', 'Test']);
|
|
run('git', ['config', 'commit.gpgsign', 'false']);
|
|
fs.writeFileSync(path.join(repoDir, 'app.ts'), 'console.log("v1");\n');
|
|
fs.writeFileSync(path.join(repoDir, 'VERSION'), '0.1.0.0\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'CHANGELOG.md'),
|
|
'# Changelog\n\n## [0.1.0.0] - 2026-01-01\n\n- Initial release\n'
|
|
);
|
|
// The cwd-relative pr-body plant (below) lives inside this working tree;
|
|
// ignore it so the fixture repo stays clean and the agent never tries to
|
|
// commit test scaffolding.
|
|
fs.writeFileSync(path.join(repoDir, '.gitignore'), 'ship/\n');
|
|
run('git', ['add', 'app.ts', 'VERSION', 'CHANGELOG.md', '.gitignore']);
|
|
run('git', ['commit', '-m', 'initial']);
|
|
run('git', ['push', '-u', 'origin', 'main']);
|
|
run('git', ['checkout', '-b', 'feature/docsync-test']);
|
|
fs.writeFileSync(path.join(repoDir, 'app.ts'), 'console.log("v2");\n');
|
|
fs.writeFileSync(path.join(repoDir, 'VERSION'), '0.1.0.1\n');
|
|
fs.writeFileSync(
|
|
path.join(repoDir, 'CHANGELOG.md'),
|
|
'# Changelog\n\n## [0.1.0.1] - 2026-01-02\n\n- Docsync test feature\n\n## [0.1.0.0] - 2026-01-01\n\n- Initial release\n'
|
|
);
|
|
run('git', ['add', 'app.ts', 'VERSION', 'CHANGELOG.md']);
|
|
run('git', ['commit', '-m', 'feat: docsync test feature']);
|
|
|
|
// Extract-don't-copy: slice the LIVE generated skeleton's Step 17→19
|
|
// tail. Fail loudly on marker drift — a tolerant slice silently builds
|
|
// a wrong fixture (mirrors extractSkillSections's throw-on-rename).
|
|
const skeleton = fs.readFileSync(path.join(ROOT, 'ship', 'SKILL.md'), 'utf-8');
|
|
const start = skeleton.indexOf('## Step 17: Push');
|
|
const end = skeleton.indexOf('## Step 20: Persist ship metrics');
|
|
if (start === -1 || end === -1 || end <= start) {
|
|
throw new Error(
|
|
'ship/SKILL.md step markers moved — update the skill-e2e-ship-docsync fixture slice'
|
|
);
|
|
}
|
|
const tail = skeleton.slice(start, end);
|
|
fs.mkdirSync(path.join(workDir, 'ship', 'sections'), { recursive: true });
|
|
fs.writeFileSync(path.join(workDir, 'ship', 'SKILL-tail.md'), tail);
|
|
|
|
// Plant the real pr-body section at the STOP pointer's ~ path (HOME
|
|
// override) and at a relative path as insurance.
|
|
const prBody = fs.readFileSync(
|
|
path.join(ROOT, 'ship', 'sections', 'pr-body.md'), 'utf-8'
|
|
);
|
|
const plantedSkills = path.join(workDir, '.claude', 'skills', 'gstack');
|
|
fs.mkdirSync(path.join(plantedSkills, 'ship', 'sections'), { recursive: true });
|
|
fs.mkdirSync(path.join(plantedSkills, 'document-release'), { recursive: true });
|
|
fs.writeFileSync(path.join(plantedSkills, 'ship', 'sections', 'pr-body.md'), prBody);
|
|
fs.writeFileSync(path.join(workDir, 'ship', 'sections', 'pr-body.md'), prBody);
|
|
// Third plant: resolvable relative to the agent's cwd (repoDir), not just
|
|
// relative to SKILL-tail.md — saves a wasted turn if the agent tries a
|
|
// cwd-relative read before the ~ path.
|
|
fs.mkdirSync(path.join(repoDir, 'ship', 'sections'), { recursive: true });
|
|
fs.writeFileSync(path.join(repoDir, 'ship', 'sections', 'pr-body.md'), prBody);
|
|
fs.writeFileSync(
|
|
path.join(plantedSkills, 'document-release', 'SKILL.md'),
|
|
DOC_RELEASE_STUB
|
|
);
|
|
|
|
// Route Step 17's credential pre-push guard to its silent branch.
|
|
fs.mkdirSync(path.join(workDir, 'gstack-home'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(workDir, 'gstack-home', '.redact-prepush-prompted'), ''
|
|
);
|
|
});
|
|
|
|
afterAll(() => {
|
|
try { fs.rmSync(workDir, { recursive: true, force: true }); } catch {}
|
|
try { fs.rmSync(remoteDir, { recursive: true, force: true }); } catch {}
|
|
});
|
|
|
|
testConcurrentIfSelected('ship-docsync', async () => {
|
|
const result = await runSkillTest({
|
|
prompt: `You are executing the /ship workflow; your working directory is the git repo. Steps 0-16 are complete: tests passed, review done, VERSION bumped to 0.1.0.1, CHANGELOG updated, changes committed on branch feature/docsync-test. The remaining workflow is in ${path.join(workDir, 'ship', 'SKILL-tail.md')} — Read it and continue the workflow from Step 17 to completion. Skill section files referenced by STOP pointers live under ${path.join(workDir, 'ship', 'sections')} (also planted at ship/sections/ relative to the repo) — resolve section reads there, not via \`~\`. Base branch: main. There is no GitHub/GitLab service in this environment: if gh or glab commands fail, print the would-be PR title and body and stop. gstack helper binaries (gstack-*) are unavailable in this environment — treat their failures as no-ops and continue. Do NOT ask questions.`,
|
|
workingDirectory: repoDir,
|
|
maxTurns: 30,
|
|
allowedTools: ['Bash', 'Read', 'Grep', 'Glob', 'Write', 'Agent', 'Task'],
|
|
timeout: CAPTURE_LONG_MS,
|
|
env: {
|
|
HOME: workDir,
|
|
GSTACK_HOME: path.join(workDir, 'gstack-home'),
|
|
},
|
|
testName: 'ship-docsync',
|
|
runId,
|
|
});
|
|
|
|
logCost('/ship doc-sync dispatch', result);
|
|
|
|
// Assert ONLY on result.toolCalls — the prompt and skill text echo into
|
|
// the transcript and would false-positive any transcript-wide match
|
|
// (trap documented in skill-e2e-autoplan-dual-voice.test.ts).
|
|
const calls = Array.isArray(result.toolCalls) ? result.toolCalls : [];
|
|
// Matcher is dispatch-SPECIFIC, not mention-specific: both markers come
|
|
// verbatim from the Step 18 subagent prompt dictated by pr-body.md. A
|
|
// subagent that merely quotes section text mentioning "document-release"
|
|
// (e.g. a PR-body drafter) must NOT count — that false-pass would mask
|
|
// the exact regression this test exists to catch. Verified against
|
|
// recorded burn-in transcripts: real dispatch inputs carry both markers.
|
|
// Section-paste exclusion: a subagent handed the WHOLE pr-body.md as
|
|
// context carries the markers too. The dictated Step 18 prompt never
|
|
// contains the section's scaffolding, so its presence disqualifies.
|
|
// Verified across all recorded runs: real dispatches match markers,
|
|
// zero contain scaffold strings.
|
|
const dispatchIdx = calls.findIndex((tc) => {
|
|
if (tc.tool !== 'Agent' && tc.tool !== 'Task') return false;
|
|
const input = JSON.stringify(tc.input ?? {});
|
|
return (
|
|
/document-release\/SKILL\.md|executing the \/document-release workflow/i.test(input) &&
|
|
!/## Step 19: Create PR\/MR|Parent processing:/.test(input)
|
|
);
|
|
});
|
|
const prCreateIdx = calls.findIndex(
|
|
(tc) =>
|
|
tc.tool === 'Bash' &&
|
|
/gh pr create|glab mr create/.test(String((tc.input as any)?.command ?? ''))
|
|
);
|
|
const readPrBody = calls.some(
|
|
(tc) =>
|
|
(tc.tool === 'Read' &&
|
|
/sections\/pr-body\.md/.test(String((tc.input as any)?.file_path ?? ''))) ||
|
|
(tc.tool === 'Bash' &&
|
|
/sections\/pr-body\.md/.test(String((tc.input as any)?.command ?? '')))
|
|
);
|
|
|
|
if (!readPrBody) {
|
|
// Diagnostic only — near-tautological under any prompt; the dispatch
|
|
// below is the invariant.
|
|
console.warn('ship-docsync: pr-body.md was never opened');
|
|
}
|
|
|
|
recordE2E(evalCollector, '/ship doc-sync dispatch', 'Ship doc-sync dispatch E2E', result, {
|
|
passed:
|
|
dispatchIdx >= 0 &&
|
|
(prCreateIdx < 0 || dispatchIdx < prCreateIdx) &&
|
|
['success', 'error_max_turns', 'timeout'].includes(result.exitReason),
|
|
});
|
|
|
|
// THE regression assert: the /document-release subagent was dispatched.
|
|
expect(dispatchIdx).toBeGreaterThanOrEqual(0);
|
|
// v1.79: the dispatch must carry the explicit foreground flag — the
|
|
// whole #497/#2440 class is "prose said foreground, the call didn't".
|
|
// Phrase pins prove the text exists; this proves the model obeys it.
|
|
expect((calls[dispatchIdx].input as any)?.run_in_background).toBe(false);
|
|
// Sequencing: dispatch happens BEFORE PR creation (when a create was attempted).
|
|
if (prCreateIdx >= 0) expect(dispatchIdx).toBeLessThan(prCreateIdx);
|
|
// 'timeout' is acceptable ONLY because the dispatch assert above is
|
|
// independently hard — a run that times out AFTER a clean dispatch
|
|
// proves the invariant; one that times out before it already failed on
|
|
// dispatchIdx. Never soften dispatchIdx to compensate.
|
|
expect(['success', 'error_max_turns', 'timeout']).toContain(result.exitReason);
|
|
|
|
console.log(
|
|
`dispatchIdx=${dispatchIdx} prCreateIdx=${prCreateIdx} readPrBody=${readPrBody} exit=${result.exitReason}`
|
|
);
|
|
}, CAPTURE_LONG_MS);
|
|
});
|
|
});
|
|
|
|
// Module-level afterAll — finalize eval collector after all tests complete
|
|
afterAll(async () => {
|
|
await finalizeEvalCollector(evalCollector);
|
|
});
|