@capagents/forgeqe 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
package/src/code/loop.ts
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { CodeEvent, CodeResult, CodeReview, Usage } from "../types.ts"
|
|
5
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
6
|
+
import { addCost, addUsage, costOf } from "../usage.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import { buildHandoff, type KnownDefect } from "./handoff.ts"
|
|
9
|
+
import type { TestCase } from "../cases/types.ts"
|
|
10
|
+
import { buildProfile, profileSection } from "./profile.ts"
|
|
11
|
+
import { LIVE_BROWSER_SERVER, liveBrowser } from "./livebrowser.ts"
|
|
12
|
+
import { addWrittenTest, notesSection, readNotes, saveNotes } from "./notes.ts"
|
|
13
|
+
import { canReplay, replayRun, savedReplay, type Replay } from "./replay.ts"
|
|
14
|
+
import { openSession, READ_AGENT, TEST_AGENT, type OpencodeSession, type PromptOutcome } from "./runtime.ts"
|
|
15
|
+
import { failureContext } from "./slices.ts"
|
|
16
|
+
import { moveToAttention } from "./attention.ts"
|
|
17
|
+
import { recordMapping } from "./traceability.ts"
|
|
18
|
+
import {
|
|
19
|
+
blockedChallengePrompt,
|
|
20
|
+
carryOnPrompt,
|
|
21
|
+
firstPrompt,
|
|
22
|
+
browserMismatch,
|
|
23
|
+
fixPrompt,
|
|
24
|
+
LIVE_ON_NOTE,
|
|
25
|
+
missingCommandPrompt,
|
|
26
|
+
nextAttemptPrompt,
|
|
27
|
+
noBrowserPrompt,
|
|
28
|
+
preparePrompt,
|
|
29
|
+
repeatedFailurePrompt,
|
|
30
|
+
restoredNote,
|
|
31
|
+
RESUMED_NOTE,
|
|
32
|
+
reviewFixPrompt,
|
|
33
|
+
stallPrompt,
|
|
34
|
+
standardsFixPrompt,
|
|
35
|
+
type AgentReport,
|
|
36
|
+
type ExistingTest,
|
|
37
|
+
} from "./prompt.ts"
|
|
38
|
+
import { openReviewer, type Reviewer } from "./review.ts"
|
|
39
|
+
import { runTestCommand } from "./testrun.ts"
|
|
40
|
+
import { changedFiles, jobChanges, ownPaths, preexisting, protectPatterns, restoreRemoved, restoreSnapshot, snapshot, snapshotFiles } from "./worktree.ts"
|
|
41
|
+
import { loadSkills } from "../skills/registry.ts"
|
|
42
|
+
import { domainNotes } from "../domain.ts"
|
|
43
|
+
|
|
44
|
+
function realpathOr(dir: string): string {
|
|
45
|
+
try {
|
|
46
|
+
return realpathSync(dir)
|
|
47
|
+
} catch {
|
|
48
|
+
return path.resolve(dir)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reviewer rejections of a passing test before the job stops. */
|
|
53
|
+
const MAX_REJECTIONS = 2
|
|
54
|
+
|
|
55
|
+
const FATAL_ERRORS = ["ProviderAuthError", "APIError", "ContextOverflowError", "ContentFilterError"]
|
|
56
|
+
/** Turn errors after which the same session is worth reopening on a rerun (the model was unreachable or refused the key). */
|
|
57
|
+
const RESUMABLE_ERRORS = ["ProviderAuthError", "APIError"]
|
|
58
|
+
|
|
59
|
+
export const SESSION_FILE = "scribe-session.json"
|
|
60
|
+
type SavedSession = { sessionId: string; repo: string; stage: "reading" | "writing"; liveOn?: boolean; at?: string }
|
|
61
|
+
|
|
62
|
+
/** Scribe's session on this run and repo, left by a job that stopped before it finished. */
|
|
63
|
+
export function savedSession(runDir: string, repo: string): SavedSession | null {
|
|
64
|
+
try {
|
|
65
|
+
const s = JSON.parse(readFileSync(path.join(runDir, SESSION_FILE), "utf8")) as SavedSession
|
|
66
|
+
return s.sessionId && s.repo === repo ? s : null
|
|
67
|
+
} catch {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A test run's output without its numbers (timings, line numbers, counts), to tell the same failure again. */
|
|
73
|
+
/** One line of the attempt log a fresh session starts from: what ran, how it ended, and what Scribe said it did. */
|
|
74
|
+
function attemptLine(attempt: number, outcome: string, detail: string, notes?: string): string {
|
|
75
|
+
const clip = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1)}…` : t)
|
|
76
|
+
return [`${attempt}: ${outcome}`, detail ? `: ${clip(detail.replace(/\s+/g, " ").trim(), 240)}` : "", notes ? ` (Scribe: ${clip(notes.replace(/\s+/g, " ").trim(), 240)})` : ""].join("")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The first line of a test run's output that names the error. */
|
|
80
|
+
function firstError(output: string): string {
|
|
81
|
+
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean)
|
|
82
|
+
return lines.find((l) => /error|exception|failed|timeout|expected|strict mode/i.test(l) && !/^at /.test(l)) || lines[0] || ""
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function failureKey(output: string): string {
|
|
86
|
+
return output.replace(/\d+/g, "#").replace(/\s+/g, " ").trim()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Hand a passed browser run to Scribe and loop until the test it writes passes when ForgeQE runs it.
|
|
91
|
+
* The agent's own claim of success does not count. An attempt passes only when ForgeQE's run of the reported
|
|
92
|
+
* command exits 0 with a browser launched under it, and a read-only reviewer approves the change against the goal.
|
|
93
|
+
*/
|
|
94
|
+
export async function runCodeAgent(opts: {
|
|
95
|
+
config: AppConfig
|
|
96
|
+
repo: string
|
|
97
|
+
runDir: string
|
|
98
|
+
signal?: AbortSignal
|
|
99
|
+
onEvent?: (e: CodeEvent) => void
|
|
100
|
+
/** Replaces openSession; tests use it to script the agent. */
|
|
101
|
+
open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
|
|
102
|
+
/** Replaces runTestCommand. */
|
|
103
|
+
runTest?: typeof runTestCommand
|
|
104
|
+
/** Replaces the Scribe reviewer. */
|
|
105
|
+
reviewer?: Reviewer
|
|
106
|
+
/** Replaces replayRun. */
|
|
107
|
+
replay?: typeof replayRun
|
|
108
|
+
/** The test case the run came from: the test is named and tagged for it, and mapped to it once it passes. */
|
|
109
|
+
testCase?: TestCase
|
|
110
|
+
/** The run found a functional defect: the test asserts the correct behaviour, marked as an expected failure. */
|
|
111
|
+
knownDefect?: KnownDefect
|
|
112
|
+
/** The test the case already has, which failed when ForgeQE ran it: the job updates it. */
|
|
113
|
+
existingTest?: ExistingTest
|
|
114
|
+
}): Promise<CodeResult> {
|
|
115
|
+
const given = path.resolve(opts.repo)
|
|
116
|
+
if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
|
|
117
|
+
const repo = realpathSync(given)
|
|
118
|
+
const { config } = opts
|
|
119
|
+
const emit = opts.onEvent || (() => {})
|
|
120
|
+
let handoff = buildHandoff(opts.runDir, config.memory.dir, { knownDefect: opts.knownDefect })
|
|
121
|
+
if (!handoff.success && !opts.knownDefect) throw new Error("The coding agent only takes runs that passed. This run failed.")
|
|
122
|
+
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`
|
|
123
|
+
const shown = path.relative(process.cwd(), handoff.file)
|
|
124
|
+
const handed = [plural(handoff.steps, "step"), handoff.checks ? plural(handoff.checks, "check") : "", handoff.pages ? `${plural(handoff.pages, "page")} from page memory` : ""]
|
|
125
|
+
emit({ type: "code", kind: "info", text: `Handoff written: ${shown && !shown.startsWith("..") ? shown : handoff.file} (${handed.filter(Boolean).join(", ")})` })
|
|
126
|
+
const maxAttempts = Math.max(1, config.code.maxAttempts)
|
|
127
|
+
|
|
128
|
+
const result: CodeResult = {
|
|
129
|
+
status: "failed",
|
|
130
|
+
repo,
|
|
131
|
+
attempts: 0,
|
|
132
|
+
maxAttempts,
|
|
133
|
+
reused: [],
|
|
134
|
+
changed: [],
|
|
135
|
+
notes: "",
|
|
136
|
+
output: "",
|
|
137
|
+
usage: { input: 0, output: 0 },
|
|
138
|
+
model: "",
|
|
139
|
+
handoff: handoff.file,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const readDirs = [...new Set([given, opts.runDir, config.memory.dir].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
|
|
143
|
+
const own = ownPaths(repo, [opts.runDir, config.memory.dir])
|
|
144
|
+
// How the run's browser was set up, to tell Scribe when its test's browser differs (headless, another browser).
|
|
145
|
+
const runBrowser = (() => {
|
|
146
|
+
try {
|
|
147
|
+
return (JSON.parse(readFileSync(path.join(opts.runDir, "run.json"), "utf8")) as { browser?: { headless?: boolean; channel?: string } }).browser
|
|
148
|
+
} catch {
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
})()
|
|
152
|
+
const before = snapshot(repo, own)
|
|
153
|
+
const outside = [opts.runDir, config.memory.dir].map((d) => realpathOr(d)).filter((d) => path.relative(repo, d).startsWith(".."))
|
|
154
|
+
let session: OpencodeSession | null = null
|
|
155
|
+
const reviewer = opts.reviewer || openReviewer({ config, repo, readDirs, signal: opts.signal, onEvent: emit, open: opts.open })
|
|
156
|
+
const runTest = opts.runTest || runTestCommand
|
|
157
|
+
let challenged = false
|
|
158
|
+
let stalls = 0
|
|
159
|
+
let lastFailure = ""
|
|
160
|
+
let rejections = 0
|
|
161
|
+
/** The passing, goal-approved version, kept while the agent fixes coding standards in one more round. */
|
|
162
|
+
let tidy: { files: Map<string, Buffer | null>; report: AgentReport; review: CodeReview; output: string; browsers: string[] } | null = null
|
|
163
|
+
let tidying = false
|
|
164
|
+
let sameFailures = 0
|
|
165
|
+
const live = config.code.liveBrowser ? liveBrowser(opts.runDir) : null
|
|
166
|
+
let liveOn = false
|
|
167
|
+
const replaying = Boolean(config.code.replay) && canReplay(opts.runDir)
|
|
168
|
+
const tools = () => (live ? { tools: { [`${LIVE_BROWSER_SERVER}_*`]: liveOn } } : {})
|
|
169
|
+
const earlier = savedSession(opts.runDir, repo)
|
|
170
|
+
const notesDir = config.memory.enabled ? config.memory.dir : ""
|
|
171
|
+
const known = notesDir ? readNotes(notesDir, repo) : null
|
|
172
|
+
let stage: SavedSession["stage"] = "reading"
|
|
173
|
+
let keepSession = true
|
|
174
|
+
let runtimeFailed = false
|
|
175
|
+
const saveSession = () => {
|
|
176
|
+
if (!session) return
|
|
177
|
+
const saved: SavedSession = { sessionId: session.sessionId, repo, stage, liveOn, at: new Date().toISOString() }
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(opts.runDir, { recursive: true })
|
|
180
|
+
writeFileSync(path.join(opts.runDir, SESSION_FILE), `${JSON.stringify(saved, null, 2)}\n`)
|
|
181
|
+
} catch {
|
|
182
|
+
// Only a rerun after a crash reads it.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let replay: Replay | null = null
|
|
186
|
+
/** The replay's page snapshots, from the pass that reached the run's last page first, and its failing steps'. */
|
|
187
|
+
const replaySnapshots = (): string[] => {
|
|
188
|
+
const passes = [...(replay?.passes || [])].sort((a, b) => Number(b.reached) - Number(a.reached))
|
|
189
|
+
return passes.flatMap((p) => [...(p.pages || []).map((pg) => pg.snapshot), ...p.steps.flatMap((st) => (st.snapshot ? [st.snapshot] : []))])
|
|
190
|
+
}
|
|
191
|
+
const switchLiveOn = (): string => {
|
|
192
|
+
if (!live || liveOn) return ""
|
|
193
|
+
liveOn = true
|
|
194
|
+
saveSession()
|
|
195
|
+
emit({ type: "code", kind: "info", text: "Scribe's live browser is on to look at the failing step" })
|
|
196
|
+
return `\n\n${LIVE_ON_NOTE}`
|
|
197
|
+
}
|
|
198
|
+
const keepHandoff = () => {
|
|
199
|
+
if (existsSync(handoff.file)) return
|
|
200
|
+
mkdirSync(path.dirname(handoff.file), { recursive: true })
|
|
201
|
+
writeFileSync(handoff.file, handoff.markdown)
|
|
202
|
+
}
|
|
203
|
+
// code.max_tokens holds during an attempt too: the turn is stopped as soon as the job's total passes it, instead of
|
|
204
|
+
// after the attempt (one long attempt can use millions of tokens on its own).
|
|
205
|
+
let overAt: number | null = null
|
|
206
|
+
const watchBudget = (e: CodeEvent) => {
|
|
207
|
+
emit(e)
|
|
208
|
+
if (e.kind !== "usage" || !config.code.maxTokens || overAt !== null) return
|
|
209
|
+
const used = addUsage(addUsage(spent, e.usage), reviewer.usage)
|
|
210
|
+
if (used.input + used.output < config.code.maxTokens) return
|
|
211
|
+
overAt = used.input + used.output
|
|
212
|
+
void session?.abort()
|
|
213
|
+
}
|
|
214
|
+
const sessionOptions = (resume?: string) => ({
|
|
215
|
+
repo,
|
|
216
|
+
profile: config.llm,
|
|
217
|
+
domain: domainNotes(config),
|
|
218
|
+
model: config.code.model,
|
|
219
|
+
bin: config.code.opencode,
|
|
220
|
+
readDirs,
|
|
221
|
+
hide: privateEnvNames(config),
|
|
222
|
+
signal: opts.signal,
|
|
223
|
+
onEvent: watchBudget,
|
|
224
|
+
protect: protectPatterns(repo, own, outside),
|
|
225
|
+
stallAfter: config.code.stallAfter ?? 5,
|
|
226
|
+
agent: { ...TEST_AGENT, steps: config.code.maxSteps ?? 50 },
|
|
227
|
+
agents: [READ_AGENT],
|
|
228
|
+
skills: loadSkills(config.skills, repo).skills,
|
|
229
|
+
...(live ? { mcp: { [LIVE_BROWSER_SERVER]: live.config } } : {}),
|
|
230
|
+
...(resume ? { resume } : {}),
|
|
231
|
+
})
|
|
232
|
+
// Each attempt after the first runs in a new session from a brief of the job so far (nextAttemptPrompt), so the
|
|
233
|
+
// model is not sent every earlier attempt's tool calls again. Usage of closed sessions is kept here.
|
|
234
|
+
let spent: Usage = { input: 0, output: 0 }
|
|
235
|
+
const attemptLog: string[] = []
|
|
236
|
+
let nextTask: string | null = null
|
|
237
|
+
let notesText: string | undefined = known ? notesSection(known) : undefined
|
|
238
|
+
// The repository's test setup, read by ForgeQE without a model; Scribe starts from it instead of a reading turn.
|
|
239
|
+
const repoProfile = buildProfile(repo)
|
|
240
|
+
const profileText = repoProfile ? profileSection(repoProfile) : undefined
|
|
241
|
+
try {
|
|
242
|
+
session = await (opts.open || openSession)(sessionOptions(earlier?.sessionId))
|
|
243
|
+
result.model = session.model
|
|
244
|
+
result.sessionId = session.sessionId
|
|
245
|
+
const resumed = Boolean(earlier && session.resumed)
|
|
246
|
+
if (resumed) result.resumed = true
|
|
247
|
+
if (earlier) {
|
|
248
|
+
emit({
|
|
249
|
+
type: "code",
|
|
250
|
+
kind: "info",
|
|
251
|
+
text: resumed ? "Scribe reopens its session from the earlier job on this run, which stopped before it finished" : "Scribe's session from the earlier job on this run is gone; it starts a new one",
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
saveSession()
|
|
255
|
+
emit({ type: "code", kind: "info", text: `Scribe, the coding agent (${session.model}), reads the handoff and writes a test in ${repo}` })
|
|
256
|
+
|
|
257
|
+
let prepared = true
|
|
258
|
+
const reads = !resumed && !known && !repoProfile
|
|
259
|
+
if (repoProfile && !resumed) {
|
|
260
|
+
emit({
|
|
261
|
+
type: "code",
|
|
262
|
+
kind: "info",
|
|
263
|
+
text: `ForgeQE read the repository's test setup itself (${repoProfile.pageObjects.length} page objects, ${repoProfile.baseClasses.length} base classes, ${repoProfile.settings.length} settings files${repoProfile.testIdAttribute ? `, test ids in ${repoProfile.testIdAttribute.value}` : ""}); Scribe starts from it`,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
if (known && !resumed) {
|
|
267
|
+
const n = known.tests.length
|
|
268
|
+
emit({ type: "code", kind: "info", text: `Scribe starts from its notes on this repository (${known.at.slice(0, 10)}${n ? `, ${plural(n, "test")} written before` : ""}) instead of reading it again` })
|
|
269
|
+
}
|
|
270
|
+
const saved = replaying && resumed ? savedReplay(opts.runDir) : null
|
|
271
|
+
if (replaying) {
|
|
272
|
+
const replayText = reads ? "ForgeQE replays the run's steps in Playwright while Scribe reads the repository" : "ForgeQE replays the run's steps in Playwright"
|
|
273
|
+
emit({ type: "code", kind: "info", text: saved ? "ForgeQE uses the Playwright replay the earlier job finished" : replayText })
|
|
274
|
+
}
|
|
275
|
+
const [r, prep]: [Replay | null, PromptOutcome | null] = await Promise.all([
|
|
276
|
+
!replaying ? null : saved || (opts.replay || replayRun)({ runDir: opts.runDir, signal: opts.signal, hide: privateEnvNames(config), log: (text) => emit({ type: "code", kind: "info", text }) }),
|
|
277
|
+
reads ? session.prompt(preparePrompt(handoff.goal, replaying), { structured: false, agent: READ_AGENT.name }) : null,
|
|
278
|
+
])
|
|
279
|
+
if (prep && !prep.error && notesDir) saveNotes(notesDir, repo, prep.text)
|
|
280
|
+
if (prep && !prep.error && prep.text.trim()) notesText = `Your notes on this repository, from reading it at the start of this job:\n${prep.text.trim()}`
|
|
281
|
+
if (r) {
|
|
282
|
+
replay = r
|
|
283
|
+
result.replay = {
|
|
284
|
+
dir: r.dir,
|
|
285
|
+
...(r.error ? { error: r.error } : {}),
|
|
286
|
+
passes: r.passes.map((p) => ({ setup: p.setup, failed: p.steps.filter((s) => !s.ok && !s.skipped).map((s) => s.n), reached: p.reached })),
|
|
287
|
+
}
|
|
288
|
+
handoff = buildHandoff(opts.runDir, config.memory.dir, { replay, knownDefect: opts.knownDefect })
|
|
289
|
+
// The replay's failure (its error, screenshot, and page snapshot) is in the handoff; the live browser waits for a repeated failure.
|
|
290
|
+
liveOn = Boolean(live) && Boolean(resumed && earlier?.liveOn)
|
|
291
|
+
} else if (resumed && earlier?.liveOn) {
|
|
292
|
+
liveOn = Boolean(live)
|
|
293
|
+
}
|
|
294
|
+
if (opts.signal?.aborted) {
|
|
295
|
+
result.status = "stopped"
|
|
296
|
+
prepared = false
|
|
297
|
+
} else if (prep?.error && FATAL_ERRORS.some((name) => prep.error!.startsWith(name))) {
|
|
298
|
+
result.notes = prep.error
|
|
299
|
+
runtimeFailed = RESUMABLE_ERRORS.some((name) => prep.error!.startsWith(name))
|
|
300
|
+
emit({ type: "code", kind: "info", text: `Scribe stopped: ${prep.error}` })
|
|
301
|
+
prepared = false
|
|
302
|
+
}
|
|
303
|
+
if (live && prepared) {
|
|
304
|
+
emit({
|
|
305
|
+
type: "code",
|
|
306
|
+
kind: "info",
|
|
307
|
+
text: liveOn ? "Scribe's live browser is on, as it was when the earlier job stopped" : "Scribe gets a live browser (Playwright, set up like ForgeQE's) if a test failure repeats",
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
let prompt =
|
|
311
|
+
resumed && earlier?.stage === "writing"
|
|
312
|
+
? carryOnPrompt(liveOn)
|
|
313
|
+
: `${resumed ? `${RESUMED_NOTE}\n\n` : ""}${firstPrompt(handoff, { ...(live ? { liveBrowserDir: live.dir, liveOn } : {}), ...(known ? { notes: notesSection(known) } : {}), ...(profileText ? { profile: profileText } : {}), ...(opts.existingTest ? { existing: opts.existingTest } : {}) })}`
|
|
314
|
+
if (prepared) {
|
|
315
|
+
stage = "writing"
|
|
316
|
+
saveSession()
|
|
317
|
+
}
|
|
318
|
+
let report: AgentReport = {}
|
|
319
|
+
let note = ""
|
|
320
|
+
const overBudget = (): number | null => {
|
|
321
|
+
if (!config.code.maxTokens || !session) return null
|
|
322
|
+
const used = addUsage(addUsage(spent, session.usage), reviewer.usage)
|
|
323
|
+
return used.input + used.output >= config.code.maxTokens ? used.input + used.output : null
|
|
324
|
+
}
|
|
325
|
+
/** The tidy-up round broke the test: put back the version that passed, and pass with the standards issues noted. */
|
|
326
|
+
const finishWithKept = (attempt: number, why: string) => {
|
|
327
|
+
restoreSnapshot(repo, before, tidy!.files)
|
|
328
|
+
report = { ...tidy!.report }
|
|
329
|
+
result.testFile = report.test_file
|
|
330
|
+
result.command = report.command
|
|
331
|
+
result.changed = report.changed || []
|
|
332
|
+
result.output = tidy!.output
|
|
333
|
+
result.browsers = tidy!.browsers
|
|
334
|
+
result.status = "passed"
|
|
335
|
+
const left = tidy!.review.issues.filter((i) => i.kind === "standards")
|
|
336
|
+
result.review = { ...tidy!.review, approved: true, standardsLeft: true }
|
|
337
|
+
result.notes = `The test passes and tests the goal. ${why[0].toUpperCase()}${why.slice(1)}, so ForgeQE put back the version that passed. Coding standards still to fix: ${left.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
|
|
338
|
+
if (notesDir && report.test_file && report.command) addWrittenTest(notesDir, repo, { goal: handoff.goal, test_file: report.test_file, command: report.command, changed: result.changed })
|
|
339
|
+
emit({ type: "code", kind: "test", ok: true, attempt, text: `${why}; kept the version that passed (${report.test_file}), with the coding-standard issues in the notes` })
|
|
340
|
+
}
|
|
341
|
+
// The tidy-up round for coding standards does not count against max_attempts.
|
|
342
|
+
let allowed = maxAttempts
|
|
343
|
+
for (let attempt = 1; prepared && attempt <= allowed; attempt++) {
|
|
344
|
+
const over = overBudget()
|
|
345
|
+
if (over !== null) {
|
|
346
|
+
result.notes = `Stopped: the job used ${over.toLocaleString("en")} tokens, over code.max_tokens (${config.code.maxTokens!.toLocaleString("en")}). ${result.notes || ""}`.trim()
|
|
347
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: over the token budget (code.max_tokens ${config.code.maxTokens!.toLocaleString("en")})` })
|
|
348
|
+
break
|
|
349
|
+
}
|
|
350
|
+
result.attempts = attempt
|
|
351
|
+
if (nextTask !== null) {
|
|
352
|
+
spent = addUsage(spent, session!.usage)
|
|
353
|
+
await session!.close()
|
|
354
|
+
session = await (opts.open || openSession)(sessionOptions())
|
|
355
|
+
result.sessionId = session.sessionId
|
|
356
|
+
saveSession()
|
|
357
|
+
emit({ type: "code", kind: "info", text: `Scribe starts attempt ${attempt} in a fresh session, from a summary of the job so far` })
|
|
358
|
+
prompt = nextAttemptPrompt({
|
|
359
|
+
handoff,
|
|
360
|
+
notes: [profileText, notesText].filter(Boolean).join("\n\n") || undefined,
|
|
361
|
+
testFile: report.test_file,
|
|
362
|
+
command: report.command,
|
|
363
|
+
changed: changedFiles(repo, jobChanges(before) || report.changed || []),
|
|
364
|
+
attempts: attemptLog,
|
|
365
|
+
task: nextTask,
|
|
366
|
+
...(live ? { liveBrowserDir: live.dir, liveOn } : {}),
|
|
367
|
+
})
|
|
368
|
+
nextTask = null
|
|
369
|
+
}
|
|
370
|
+
const reply = await session!.prompt(note + prompt, tools())
|
|
371
|
+
if (overAt !== null && !opts.signal?.aborted) {
|
|
372
|
+
restoreRemoved(before)
|
|
373
|
+
keepHandoff()
|
|
374
|
+
result.notes = `Stopped: the job used ${(overAt as number).toLocaleString("en")} tokens, over code.max_tokens (${config.code.maxTokens!.toLocaleString("en")}), during attempt ${attempt}. ${report.notes || ""}`.trim()
|
|
375
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: over the token budget (code.max_tokens ${config.code.maxTokens!.toLocaleString("en")}) during attempt ${attempt}` })
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
const restored = restoreRemoved(before)
|
|
379
|
+
keepHandoff()
|
|
380
|
+
if (restored.length) emit({ type: "code", kind: "info", text: `Put back ${restored.join(", ")}: the coding agent removed or reverted files that existed before the job` })
|
|
381
|
+
note = restored.length ? `${restoredNote(restored)}\n\n` : ""
|
|
382
|
+
if (opts.signal?.aborted) {
|
|
383
|
+
result.status = "stopped"
|
|
384
|
+
break
|
|
385
|
+
}
|
|
386
|
+
if (reply.stalled) {
|
|
387
|
+
const { command, count } = reply.stalled
|
|
388
|
+
if (stalls++ === 0) {
|
|
389
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${command} failed ${count} times; the agent is asked to change approach` })
|
|
390
|
+
const on = switchLiveOn()
|
|
391
|
+
prompt = stallPrompt(reply.stalled, Boolean(live)) + on
|
|
392
|
+
session.resetStall?.()
|
|
393
|
+
continue
|
|
394
|
+
}
|
|
395
|
+
result.notes = `Stopped: the coding agent ran ${command} ${count} times and it failed each time, again after being asked to change approach. Last output:\n${reply.stalled.output}`
|
|
396
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: ${command} kept failing (${count} times) after the agent was asked to change approach` })
|
|
397
|
+
break
|
|
398
|
+
}
|
|
399
|
+
if (reply.report) report = { ...report, ...reply.report }
|
|
400
|
+
result.notes = report.notes || reply.text.slice(0, 500)
|
|
401
|
+
if (reply.error && FATAL_ERRORS.some((name) => reply.error!.startsWith(name))) {
|
|
402
|
+
result.notes = reply.error
|
|
403
|
+
runtimeFailed = RESUMABLE_ERRORS.some((name) => reply.error!.startsWith(name))
|
|
404
|
+
emit({ type: "code", kind: "info", text: `Scribe stopped: ${reply.error}` })
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
if (report.status === "blocked" && !challenged) {
|
|
408
|
+
challenged = true
|
|
409
|
+
report = { ...report, status: undefined }
|
|
410
|
+
prompt = blockedChallengePrompt(handoff) + switchLiveOn()
|
|
411
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `reported blocked; ForgeQE's browser reached the app, so the agent compares the two browsers first` })
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (report.status === "blocked") {
|
|
415
|
+
result.status = "blocked"
|
|
416
|
+
result.notes = report.blocked_reason || report.notes || result.notes
|
|
417
|
+
result.testFile = report.test_file
|
|
418
|
+
result.changed = report.changed || []
|
|
419
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `blocked: ${result.notes || "the application blocked the test"}` })
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
if (!report.command || !report.test_file) {
|
|
423
|
+
prompt = missingCommandPrompt()
|
|
424
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: "no test command reported yet" })
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
result.testFile = report.test_file
|
|
428
|
+
result.command = report.command
|
|
429
|
+
result.reused = report.reused || []
|
|
430
|
+
result.changed = report.changed || []
|
|
431
|
+
|
|
432
|
+
emit({ type: "code", kind: "info", text: `ForgeQE runs the test: ${report.command}` })
|
|
433
|
+
const run = await runTest({
|
|
434
|
+
command: report.command,
|
|
435
|
+
cwd: repo,
|
|
436
|
+
timeoutSeconds: config.code.testTimeoutSeconds,
|
|
437
|
+
signal: opts.signal,
|
|
438
|
+
save: path.join(opts.runDir, "test-runs", `attempt-${attempt}`),
|
|
439
|
+
hide: privateEnvNames(config),
|
|
440
|
+
})
|
|
441
|
+
// The failing element on the test's page and on the replay's: read from saved snapshots, not a live browser.
|
|
442
|
+
const files = { log: run.log, pageSnapshot: run.pageSnapshot, context: run.ok ? [] : failureContext(run.output, run.pageSnapshot, replaySnapshots()) }
|
|
443
|
+
session.resetStall?.()
|
|
444
|
+
result.output = run.output
|
|
445
|
+
result.browsers = run.browsers
|
|
446
|
+
if (opts.signal?.aborted) {
|
|
447
|
+
result.status = "stopped"
|
|
448
|
+
break
|
|
449
|
+
}
|
|
450
|
+
const secs = Math.round(run.ms / 1000)
|
|
451
|
+
if (run.ok) lastFailure = ""
|
|
452
|
+
if (tidying && run.ok && !run.browsers.length && !report.remote_browser) {
|
|
453
|
+
finishWithKept(attempt, "after the coding-standard fix the test opened no browser")
|
|
454
|
+
break
|
|
455
|
+
}
|
|
456
|
+
if (run.ok && !run.browsers.length && !report.remote_browser) {
|
|
457
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} exited 0 in ${secs}s but opened no browser (attempt ${attempt}/${maxAttempts})` })
|
|
458
|
+
attemptLog.push(attemptLine(attempt, "exited 0 but opened no browser", "", report.notes))
|
|
459
|
+
nextTask = noBrowserPrompt(report.command)
|
|
460
|
+
continue
|
|
461
|
+
}
|
|
462
|
+
if (run.ok) {
|
|
463
|
+
const where = run.browsers.length ? run.browsers.join(", ") : `remote browser (${report.remote_browser})`
|
|
464
|
+
emit({ type: "code", kind: "info", text: `${report.test_file} passed in ${secs}s in ${where}; a reviewer checks it against the goal` })
|
|
465
|
+
const changes = jobChanges(before) ?? undefined
|
|
466
|
+
// After a rejection or a tidy-up, the reviewer checks its own earlier issues instead of starting over.
|
|
467
|
+
const verdict = await reviewer.review({ handoff, report, output: run.output, browsers: run.browsers, seconds: secs, changes, before: preexisting(before), ...(result.review ? { earlier: result.review } : {}) })
|
|
468
|
+
if (opts.signal?.aborted) {
|
|
469
|
+
result.status = "stopped"
|
|
470
|
+
break
|
|
471
|
+
}
|
|
472
|
+
if (!verdict.review) {
|
|
473
|
+
result.notes = `The test passed, but the review did not finish: ${verdict.error}`
|
|
474
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: result.notes })
|
|
475
|
+
break
|
|
476
|
+
}
|
|
477
|
+
result.review = verdict.review
|
|
478
|
+
const correctness = verdict.review.issues.filter((i) => i.kind !== "standards")
|
|
479
|
+
const standards = verdict.review.issues.filter((i) => i.kind === "standards")
|
|
480
|
+
// Coding standards alone never fail a test that works and tests the goal.
|
|
481
|
+
if (tidying && correctness.length) {
|
|
482
|
+
// The tidy-up changed what the test does: back to the version that passed.
|
|
483
|
+
finishWithKept(attempt, `the coding-standard fix changed what the test checks (${verdict.review.summary})`)
|
|
484
|
+
break
|
|
485
|
+
}
|
|
486
|
+
if (verdict.review.approved || (!correctness.length && standards.length)) {
|
|
487
|
+
if (standards.length && !tidying && config.code.tidy !== false) {
|
|
488
|
+
tidy = { files: snapshotFiles(repo, jobChanges(before) || result.changed), report: { ...report }, review: verdict.review, output: run.output, browsers: run.browsers }
|
|
489
|
+
tidying = true
|
|
490
|
+
allowed = Math.max(allowed, attempt + 1)
|
|
491
|
+
emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and tests the goal; the reviewer found ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"}, so the agent fixes ${standards.length === 1 ? "it" : "them"} in one more round (the passing version is kept if that breaks it)` })
|
|
492
|
+
attemptLog.push(attemptLine(attempt, "passed and tests the goal; coding-standard issues to fix", standards.map((i) => i.problem).join(" "), report.notes))
|
|
493
|
+
nextTask = standardsFixPrompt(standards)
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
result.status = "passed"
|
|
497
|
+
if (standards.length) {
|
|
498
|
+
result.review = { ...verdict.review, approved: true, standardsLeft: true }
|
|
499
|
+
result.notes = `The test passes and tests the goal. Coding standards still to fix: ${standards.map((i) => `${i.file ? `${i.file}: ` : ""}${i.problem}`).join(" ")}`
|
|
500
|
+
} else result.review = { ...verdict.review, approved: true }
|
|
501
|
+
if (notesDir) addWrittenTest(notesDir, repo, { goal: handoff.goal, test_file: report.test_file, command: report.command, changed: result.changed })
|
|
502
|
+
emit({ type: "code", kind: "test", ok: true, attempt, text: `${report.test_file} passed and was approved (attempt ${attempt}/${allowed})${standards.length ? `; ${standards.length} coding-standard ${standards.length === 1 ? "issue" : "issues"} left in the notes` : ""}` })
|
|
503
|
+
break
|
|
504
|
+
}
|
|
505
|
+
const n = verdict.review.issues.length
|
|
506
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `review found ${n} ${n === 1 ? "issue" : "issues"}: ${verdict.review.summary} (attempt ${attempt}/${maxAttempts})` })
|
|
507
|
+
// A second rejection usually means the goal and the page disagree (a test case the page cannot satisfy), not a
|
|
508
|
+
// fix the next attempt will find: stop and report the issues instead of spending the remaining attempts.
|
|
509
|
+
if (++rejections >= MAX_REJECTIONS) {
|
|
510
|
+
result.notes = `Stopped: the test passes, but the reviewer did not approve it ${rejections} times. Latest review: ${verdict.review.summary}`
|
|
511
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: the reviewer did not approve the test ${rejections} times; its issues are in the result` })
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
attemptLog.push(attemptLine(attempt, "passed; the reviewer asked for changes", verdict.review.summary, report.notes))
|
|
515
|
+
nextTask = reviewFixPrompt(verdict.review.summary, verdict.review.issues)
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
if (tidying) {
|
|
519
|
+
finishWithKept(attempt, `the coding-standard fix made the test fail (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`})`)
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
const why = run.exitCode === null ? `timed out after ${config.code.testTimeoutSeconds}s` : `exit ${run.exitCode}`
|
|
523
|
+
const mismatch = browserMismatch(runBrowser, run.browsers, report.command)
|
|
524
|
+
if (mismatch) emit({ type: "code", kind: "info", text: `The test's browser differs from the run's: ${run.browsers.join(", ")}` })
|
|
525
|
+
const key = failureKey(run.output)
|
|
526
|
+
sameFailures = key && key === lastFailure ? sameFailures + 1 : 0
|
|
527
|
+
lastFailure = key
|
|
528
|
+
if (sameFailures >= 2) {
|
|
529
|
+
result.notes = `Stopped: the test failed the same way ${sameFailures + 1} times in a row (apart from timings and line numbers), again after the agent was asked to look at the failing step first. Last output:\n${run.output.split("\n").slice(-40).join("\n")}`
|
|
530
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `stopped: ${report.test_file} failed the same way ${sameFailures + 1} times in a row` })
|
|
531
|
+
break
|
|
532
|
+
}
|
|
533
|
+
if (sameFailures === 1) {
|
|
534
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed the same way as the last attempt, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
535
|
+
attemptLog.push(attemptLine(attempt, `failed, ${why}, the same way as before`, firstError(run.output), report.notes))
|
|
536
|
+
nextTask = repeatedFailurePrompt(sameFailures + 1, report.command, run.exitCode, run.output, files) + mismatch + switchLiveOn()
|
|
537
|
+
continue
|
|
538
|
+
}
|
|
539
|
+
emit({ type: "code", kind: "test", ok: false, attempt, text: `${report.test_file} failed, ${why} (attempt ${attempt}/${maxAttempts})` })
|
|
540
|
+
attemptLog.push(attemptLine(attempt, `failed, ${why}`, firstError(run.output), report.notes))
|
|
541
|
+
nextTask = fixPrompt(attempt, report.command, run.exitCode, run.output, files) + mismatch
|
|
542
|
+
}
|
|
543
|
+
keepSession = result.status === "stopped" || runtimeFailed
|
|
544
|
+
if (report.test_name) result.testName = report.test_name
|
|
545
|
+
if (opts.knownDefect) result.knownDefect = true
|
|
546
|
+
const tc = opts.testCase
|
|
547
|
+
if (result.status === "passed" && tc) {
|
|
548
|
+
recordMapping(repo, {
|
|
549
|
+
caseId: tc.id,
|
|
550
|
+
title: tc.title,
|
|
551
|
+
source: tc.source,
|
|
552
|
+
...(tc.url ? { url: tc.url } : {}),
|
|
553
|
+
status: opts.knownDefect ? "known_defect" : "automated",
|
|
554
|
+
...(result.testFile ? { testFile: result.testFile } : {}),
|
|
555
|
+
...(result.testName ? { testName: result.testName } : {}),
|
|
556
|
+
...(result.command ? { command: result.command } : {}),
|
|
557
|
+
...(opts.knownDefect?.bug ? { bug: opts.knownDefect.bug } : {}),
|
|
558
|
+
})
|
|
559
|
+
}
|
|
560
|
+
// An unfinished job (not one that can be resumed) leaves no failing test among the tests: it goes to needs-attention.
|
|
561
|
+
if ((result.status === "failed" || result.status === "blocked") && !keepSession && config.code.needsAttention !== false) {
|
|
562
|
+
const attention = moveToAttention({
|
|
563
|
+
repo,
|
|
564
|
+
tree: before,
|
|
565
|
+
testFile: result.testFile,
|
|
566
|
+
command: result.command,
|
|
567
|
+
caseId: tc?.id,
|
|
568
|
+
title: tc?.title,
|
|
569
|
+
url: tc?.url,
|
|
570
|
+
why: result.notes || `The test did not pass after ${result.attempts} attempts.`,
|
|
571
|
+
output: result.output,
|
|
572
|
+
review: result.review,
|
|
573
|
+
})
|
|
574
|
+
if (attention) {
|
|
575
|
+
result.attention = attention
|
|
576
|
+
emit({ type: "code", kind: "info", text: `The unfinished test was moved to ${attention.dir}/ with a README on why and how to pick it up${attention.patch ? "; its edits to existing files are in changes.patch" : ""}` })
|
|
577
|
+
if (tc) recordMapping(repo, { caseId: tc.id, title: tc.title, source: tc.source, ...(tc.url ? { url: tc.url } : {}), status: "needs_attention", attention: attention.dir, ...(result.command ? { command: result.command } : {}) })
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
} finally {
|
|
581
|
+
if (session) {
|
|
582
|
+
result.usage = addUsage(spent, session.usage)
|
|
583
|
+
if (session.skillsUsed?.length) result.skills = [...session.skillsUsed]
|
|
584
|
+
await session.close()
|
|
585
|
+
}
|
|
586
|
+
await reviewer.close()
|
|
587
|
+
// With code.model the agent runtime picks the model, so there is no price for it in llms.json; the reviewer is
|
|
588
|
+
// priced at its own profile (code.review_profile). The job's cost is unknown when either part has no price.
|
|
589
|
+
const scribeCost = costOf(result.usage, config.code.model ? undefined : config.llm.price)
|
|
590
|
+
const reviewCost = reviewer.usage.input || reviewer.usage.output ? costOf(reviewer.usage, reviewer.price) : undefined
|
|
591
|
+
const reviewed = Boolean(reviewer.usage.input || reviewer.usage.output)
|
|
592
|
+
result.usage = addUsage(result.usage, reviewer.usage)
|
|
593
|
+
result.cost = scribeCost && (!reviewed || reviewCost) ? addCost(scribeCost, reviewCost) : undefined
|
|
594
|
+
if (!keepSession) rmSync(path.join(opts.runDir, SESSION_FILE), { force: true })
|
|
595
|
+
try {
|
|
596
|
+
mkdirSync(opts.runDir, { recursive: true })
|
|
597
|
+
writeFileSync(path.join(opts.runDir, "code.json"), `${JSON.stringify(result, null, 2)}\n`)
|
|
598
|
+
} catch (e) {
|
|
599
|
+
emit({ type: "code", kind: "info", text: `Could not save code.json: ${(e as Error).message}` })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return result
|
|
603
|
+
}
|