Skip to content

Commit a424aaa

Browse files
test(e2e): align live suite with the v2.0 orchestrator output contract
The live global-research suite (CI-skipped, so the drift went unnoticed) still asserted the pre-v2.0 orchestrator's log lines — "Search task N/N queued", "GOVERNANCE REVIEW BY SAGE", "EDITORIAL REVIEW BY MORGAN", "RESEARCH PUBLISHED" — none of which exist anywhere in the source since the v2.0 refactor (shared WorkflowOrchestrator + phases.ts + RunLogger, verified absent on origin/main too). Every scenario failed against a pipeline that was actually completing successfully. Rewritten against the real, current contract — stricter than before: * stdout phase banners (STEP 1-4, HITL) and phase summaries ("SEARCH PHASE COMPLETE — n/N results", "SYNTHESIS COMPLETE (n chars)", "Compliance Score: … Recommendation: …") * economics/metadata report (tokens, cost, active nodes, context id) * NEW: the machine-readable RunLogger JSON is parsed from the "Run log saved to <path>" line and its `outcome` field is asserted as the authoritative terminal verdict (PUBLISHED on the golden path), plus real token totals and per-phase task records. A governance REJECTED verdict is treated as a legitimate clean stop (the reviewer gate doing its job on live LLM output), never as a pass for a broken pipeline: search→write→governance is always asserted, and whenever the pipeline proceeds the full editorial + HITL + PUBLISHED chain is required. Also tightens the OSV SARIF step comment per Copilot review: `always()` covers OSV-Scanner failing after writing the file; the `hashFiles` guard covers OSV being skipped entirely. Verification (live, real LLM via OpenRouter, Docker stack): * global-research: npm run test:e2e:live — 4/4 scenarios PASS (golden path PUBLISHED, governance CONDITIONAL with recorded task tokens, metadata report, 3-searcher fault-tolerance run). * blog-team: full Docker stack (redis+gateway+researcher+writer+editor) + board dev server — research → write → editorial 8.7/10 → task AWAITING_VALIDATION on the state:update stream → PUBLISH decision emitted through the board's Vite proxy socket (the Approve button path) → gateway ack {ok:true} → outcome PUBLISHED, Board: FINISHED, run log clean, exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4350964 commit a424aaa

2 files changed

Lines changed: 134 additions & 88 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,12 @@ jobs:
107107
--format=sarif
108108
--output=osv.sarif
109109
- name: Upload OSV SARIF
110-
# `always()` so the SARIF still reaches the Security tab when an earlier
111-
# gate (e.g. the audit) fails — but guarded on the file actually existing:
112-
# if the audit fails, OSV-Scanner never runs and an unguarded upload adds
113-
# a bogus second failure ("Path does not exist: osv.sarif") that masks the
114-
# real one.
110+
# `always()` so the SARIF still uploads even when the OSV-Scanner step
111+
# itself errors after writing the file (it runs continue-on-error). The
112+
# `hashFiles` guard covers the other path: when an earlier gate (e.g. the
113+
# audit) fails, OSV-Scanner is skipped and osv.sarif never exists — an
114+
# unguarded upload then adds a bogus second failure ("Path does not
115+
# exist: osv.sarif") that masks the real one.
115116
if: always() && hashFiles('osv.sarif') != ''
116117
uses: github/codeql-action/upload-sarif@v4
117118
with:

tests/e2e/global-research-live.test.ts

Lines changed: 128 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,45 @@
22
* Live E2E: Global Research Swarm — Real LLM + Dockerized Services
33
*
44
* These tests run the complete distributed pipeline end-to-end:
5-
* - 4 services in Docker: Redis, Gateway, Agent nodes (searcher×4, writer, reviewer, editor)
5+
* - Services in Docker: Redis, Gateway, Agent nodes (searcher×4, writer, reviewer, editor)
66
* - Real LLM calls via OpenRouter/OpenAI (no mocks)
77
* - Orchestrator run as compiled Node.js subprocess (dist/examples/...)
8-
* - AUTO_PUBLISH=1 skips the readline HITL prompt
8+
* - AUTO_PUBLISH=1 skips the readline HITL prompt (decision forced to PUBLISH)
99
*
1010
* Run with:
1111
* npm run test:e2e:live
1212
*
1313
* Requires:
1414
* OPENROUTER_API_KEY or OPENAI_API_KEY in .env
1515
* Docker running
16+
* (On networks whose DNS blocks nom.telemetrydeck.com, also set
17+
* KAIBAN_TELEMETRY_OPT_OUT=1 in .env — see .env.example.)
18+
*
19+
* Assertions target the v2.0 orchestrator's output contract
20+
* (examples/global-research/orchestrator.ts + phases.ts):
21+
* - stdout phase banners: STEP 1 Fan-Out → STEP 2 Fan-In → STEP 3 governance
22+
* → STEP 4 editorial → HUMAN DECISION REQUIRED (HITL)
23+
* - phase summaries: "SEARCH PHASE COMPLETE — n/N results",
24+
* "SYNTHESIS COMPLETE (n chars)", "Compliance Score: … Recommendation: …"
25+
* - economics/metadata: "Tokens used:", "Estimated cost: $", "Active nodes:"
26+
* - the machine-readable run log (RunLogger): "Run log saved to <path>" — the
27+
* JSON's `outcome` field is the authoritative terminal verdict
28+
* (PUBLISHED | REVISED | REJECTED | FAILED | STOPPED).
29+
*
30+
* A governance REJECTED verdict is a legitimate terminal state (the reviewer
31+
* gate doing its job on live LLM output), so scenarios accept it as a clean
32+
* stop — but when the pipeline proceeds, the full editorial + HITL + PUBLISHED
33+
* chain is asserted strictly.
1634
*
1735
* Scenarios:
1836
* 1. Golden Path — full pipeline (search → write → review → edit → publish)
1937
* 2. Governance output — structured compliance review present in output
2038
* 3. ResearchContext — metadata fields (nodes, tokens, cost) reported
21-
* 4. Fault tolerance — CHAOS_MODE: some searchers crash, pipeline still completes
39+
* 4. Fault tolerance — partial searcher failure tolerated, pipeline completes
2240
*/
2341
import { describe, it, expect } from "vitest";
2442
import { spawnSync } from "child_process";
43+
import { readFileSync } from "fs";
2544
import { resolve } from "path";
2645
import * as dotenv from "dotenv";
2746

@@ -63,58 +82,90 @@ function runOrchestrator(
6382
};
6483
}
6584

85+
/** Shape of the RunLogger JSON flushed to examples/global-research/runs/ */
86+
interface RunLogFile {
87+
query: string;
88+
contextId: string;
89+
numSearchers: number;
90+
tasks: Array<{ phase: string; agentId: string; outputTokens: number }>;
91+
errors: unknown[];
92+
totalTokens: number;
93+
totalCost: number;
94+
outcome: string;
95+
}
96+
97+
/** Parse the "Run log saved to <path>" line and load the JSON run log. */
98+
function readRunLog(stdout: string): RunLogFile {
99+
const m = stdout.match(/Run log saved to (.+\.json)/);
100+
expect(m, "orchestrator must flush a run log").not.toBeNull();
101+
return JSON.parse(readFileSync((m as RegExpMatchArray)[1], "utf8")) as RunLogFile;
102+
}
103+
104+
/** True when the governance gate rejected the report (a legitimate clean stop). */
105+
function governanceRejected(stdout: string): boolean {
106+
return /Governance review REJECTED the report\. Workflow stopped\./.test(
107+
stdout,
108+
);
109+
}
110+
111+
function echo(label: string, stdout: string, stderr = ""): void {
112+
console.log(`\n── Orchestrator stdout (${label}) ──────────────────────────`);
113+
console.log(stdout.slice(0, 6000));
114+
if (stderr.trim()) {
115+
console.log("\n── stderr ────────────────────────────────────────────────");
116+
console.log(stderr.slice(0, 1000));
117+
}
118+
}
119+
66120
// ── Test suite ────────────────────────────────────────────────────────────
67121

68122
describe(
69123
"Live E2E: Global Research Swarm (Real LLM + Docker)",
70124
{ timeout: 600_000 },
71125
() => {
72126
// ─────────────────────────────────────────────────────────────────────
73-
// Scenario 1 — Golden Path: all 4 stages complete and research publishes
127+
// Scenario 1 — Golden Path: all stages complete and the report publishes
74128
// ─────────────────────────────────────────────────────────────────────
75129
it("Scenario 1 — Golden Path: search → write → governance → editorial → PUBLISHED", () => {
76130
const { stdout, stderr, status } = runOrchestrator(
77131
baseEnv({
78132
QUERY: "Distributed AI Agent Systems in 2025",
79133
}),
80134
);
81-
82-
console.log(
83-
"\n── Orchestrator stdout (Scenario 1) ──────────────────────────",
84-
);
85-
console.log(stdout.slice(0, 6000));
86-
if (stderr.trim()) {
87-
console.log(
88-
"\n── stderr ────────────────────────────────────────────────────",
89-
);
90-
console.log(stderr.slice(0, 1000));
91-
}
135+
echo("Scenario 1", stdout, stderr);
92136

93137
// Gateway reachable
94138
expect(stdout).toMatch(/Gateway: OK|Gateway: UP/i);
95139

96-
// Fan-out search phase
97-
expect(stdout).toMatch(/Fan-Out.*Searcher|STEP 1/i);
98-
expect(stdout).toMatch(/Search task \d+\/\d+ queued/);
99-
expect(stdout).toMatch(/SEARCH PHASE COMPLETE/);
100-
expect(stdout).toMatch(/Succeeded: [1-9]/);
101-
102-
// Fan-in write phase
103-
expect(stdout).toMatch(/Fan-In.*Writer|STEP 2/i);
104-
expect(stdout).toMatch(/SYNTHESIS COMPLETE/);
105-
106-
// Governance review
107-
expect(stdout).toMatch(/STEP 3/i);
108-
expect(stdout).toMatch(/GOVERNANCE REVIEW BY SAGE/);
109-
expect(stdout).toMatch(/Compliance Score:/);
110-
expect(stdout).toMatch(/Recommendation:/);
111-
112-
// Editorial + HITL (auto-published)
113-
expect(stdout).toMatch(/STEP 4/i);
114-
expect(stdout).toMatch(/EDITORIAL REVIEW BY MORGAN/);
115-
expect(stdout).toMatch(
116-
/AUTO_PUBLISH.*auto-approving|RESEARCH PUBLISHED/i,
117-
);
140+
// Fan-out search phase: banner, generated sub-topics, ≥1 of 2 results
141+
expect(stdout).toMatch(/STEP 1 Fan-Out: 2 Searcher nodes/);
142+
expect(stdout).toMatch(/Sub-topics:[\s\S]*1\./);
143+
expect(stdout).toMatch(/SEARCH PHASE COMPLETE [1-2]\/2 results/);
144+
145+
// Fan-in write phase: non-empty synthesis
146+
expect(stdout).toMatch(/STEP 2 Fan-In/);
147+
expect(stdout).toMatch(/SYNTHESIS COMPLETE \([1-9]\d* chars\)/);
148+
149+
// Governance review: score + structured recommendation
150+
expect(stdout).toMatch(/STEP 3 /);
151+
expect(stdout).toMatch(/Compliance Score: \S+ +Recommendation: (APPROVED|CONDITIONAL|REJECTED)/);
152+
153+
const log = readRunLog(stdout);
154+
if (governanceRejected(stdout)) {
155+
// The governance gate stopping a weak report IS correct system behavior.
156+
expect(log.outcome).toBe("REJECTED");
157+
} else {
158+
// Editorial + HITL (AUTO_PUBLISH forces the PUBLISH decision)
159+
expect(stdout).toMatch(/STEP 4 /);
160+
expect(stdout).toMatch(/Editorial: +\S+ + Recommendation:/);
161+
expect(stdout).toMatch(/HUMAN DECISION REQUIRED \(HITL\)/);
162+
expect(stdout).toMatch(/Board: FINISHED/);
163+
expect(log.outcome).toBe("PUBLISHED");
164+
}
165+
166+
// Machine-readable verdict: real tokens were spent across the fleet
167+
expect(log.totalTokens).toBeGreaterThan(0);
168+
expect(log.tasks.length).toBeGreaterThanOrEqual(1);
118169

119170
// Clean exit
120171
expect(status).toBe(0);
@@ -129,26 +180,23 @@ describe(
129180
QUERY: "AI Safety and Alignment Research",
130181
}),
131182
);
183+
echo("Scenario 2", stdout);
132184

133-
console.log(
134-
"\n── Orchestrator stdout (Scenario 2) ──────────────────────────",
135-
);
136-
console.log(stdout.slice(0, 4000));
137-
138-
// Must reach governance stage
139-
expect(stdout).toMatch(/GOVERNANCE REVIEW BY SAGE/);
185+
// Must reach the governance stage
186+
expect(stdout).toMatch(/STEP 3 Sage \(Reviewer\) running governance compliance check/);
140187

141-
// Compliance score present
142-
expect(stdout).toMatch(/Compliance Score:/i);
143-
144-
// Recommendation is one of the valid values
145-
expect(stdout).toMatch(/APPROVED|CONDITIONAL|REJECTED/);
188+
// Compliance score + structured recommendation present
189+
expect(stdout).toMatch(/Compliance Score: \S+ +Recommendation: (APPROVED|CONDITIONAL|REJECTED)/);
146190

147191
// Either continues to editorial (approved/conditional) or stops cleanly (rejected)
148-
const reachedEditorial = /EDITORIAL REVIEW BY MORGAN/.test(stdout);
149-
const rejectedByGovernance =
150-
/Governance review REJECTED|Workflow stopped/i.test(stdout);
151-
expect(reachedEditorial || rejectedByGovernance).toBe(true);
192+
const reachedEditorial = /STEP 4 Morgan \(Editor\)/.test(stdout);
193+
expect(reachedEditorial || governanceRejected(stdout)).toBe(true);
194+
195+
// The run log records the governance task with real token usage
196+
const log = readRunLog(stdout);
197+
const govTask = log.tasks.find((t) => t.phase === "governance");
198+
expect(govTask, "governance phase must be recorded in the run log").toBeDefined();
199+
expect(govTask?.outputTokens).toBeGreaterThan(0);
152200

153201
expect(status).toBe(0);
154202
}, 600_000);
@@ -162,55 +210,52 @@ describe(
162210
QUERY: "Large Language Models and Autonomous Agents",
163211
}),
164212
);
213+
echo("Scenario 3", stdout);
165214

166-
console.log(
167-
"\n── Orchestrator stdout (Scenario 3) ──────────────────────────",
168-
);
169-
console.log(stdout.slice(0, 4000));
170-
171-
// ResearchContext populated message
172-
expect(stdout).toMatch(
173-
/ResearchContext populated with \d+ search results/,
174-
);
215+
// Search results flowed into the shared ResearchContext
216+
expect(stdout).toMatch(/SEARCH PHASE COMPLETE [1-2]\/2 results/);
175217

176-
// Active nodes tracked
177-
expect(stdout).toMatch(/Active nodes:/);
218+
// Economics/metadata report: tokens, cost, active nodes, context id
219+
expect(stdout).toMatch(/Tokens used: +[1-9]\d*/);
220+
expect(stdout).toMatch(/Estimated cost: +\$\d+\.\d+/);
221+
expect(stdout).toMatch(/Active nodes: +.*writer/);
222+
expect(stdout).toMatch(/Context ID: [0-9a-f-]{36}/);
178223

179-
// If published: economics report section
180-
if (/RESEARCH PUBLISHED/.test(stdout)) {
181-
expect(stdout).toMatch(/Nodes Active:/);
182-
expect(stdout).toMatch(/Started:/);
183-
expect(stdout).toMatch(/Completed:/);
184-
}
224+
// Machine-readable metadata mirrors the CLI report
225+
const log = readRunLog(stdout);
226+
expect(log.totalTokens).toBeGreaterThan(0);
227+
expect(log.totalCost).toBeGreaterThan(0);
228+
expect(log.contextId).toMatch(/^[0-9a-f-]{36}$/);
185229

186230
expect(status).toBe(0);
187231
}, 600_000);
188232

189233
// ─────────────────────────────────────────────────────────────────────
190-
// Scenario 4 — Chaos Mode: ~20% crash rate, pipeline still completes
234+
// Scenario 4 — Fault tolerance: partial searcher failure tolerated
191235
// ─────────────────────────────────────────────────────────────────────
192236
it("Scenario 4 — Chaos Mode: searcher crashes tolerated, pipeline completes", () => {
193-
// CHAOS_MODE is passed via env to the searcher containers via docker-compose
194-
// but for the orchestrator we just verify it handles partial failures gracefully.
195-
// We use NUM_SEARCHERS=3 so there is headroom even if 1 searcher fails permanently.
237+
// CHAOS_MODE crash-injection is a property of the searcher *containers*
238+
// (set via docker-compose env at stack-up time). From the orchestrator's
239+
// side we verify the fan-out phase degrades gracefully: with 3 sub-topics
240+
// across the searcher pool, the pipeline must complete with at least one
241+
// successful search result even if individual searchers fail or restart.
196242
const { stdout, status } = runOrchestrator(
197243
baseEnv({
198244
QUERY: "Fault Tolerance in Distributed AI Systems",
199245
NUM_SEARCHERS: "3",
200246
}),
201247
);
248+
echo("Scenario 4", stdout);
202249

203-
console.log(
204-
"\n── Orchestrator stdout (Scenario 4) ──────────────────────────",
205-
);
206-
console.log(stdout.slice(0, 4000));
207-
208-
// Search phase must complete (with at least 1 result)
209-
expect(stdout).toMatch(/SEARCH PHASE COMPLETE/);
210-
expect(stdout).toMatch(/Succeeded: [1-9]/);
250+
// Search phase must complete with at least 1 of 3 results
251+
expect(stdout).toMatch(/SEARCH PHASE COMPLETE [1-3]\/3 results/);
211252

212253
// Writer must have received enough data to proceed
213-
expect(stdout).toMatch(/SYNTHESIS COMPLETE|ResearchContext populated/);
254+
expect(stdout).toMatch(/SYNTHESIS COMPLETE \([1-9]\d* chars\)/);
255+
256+
// Terminal verdict is a legitimate outcome, with no unhandled errors
257+
const log = readRunLog(stdout);
258+
expect(["PUBLISHED", "REVISED", "REJECTED"]).toContain(log.outcome);
214259

215260
expect(status).toBe(0);
216261
}, 600_000);

0 commit comments

Comments
 (0)