@llm4ts/flow 2.45.0 → 2.47.0
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/dist/Approval.d.ts +2 -0
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Approval.js +4 -0
- package/dist/Approval.js.map +1 -1
- package/dist/AutonomyContract.d.ts +2 -2
- package/dist/AutonomyContract.d.ts.map +1 -1
- package/dist/AutonomyContract.js +9 -3
- package/dist/AutonomyContract.js.map +1 -1
- package/dist/CostReport.d.ts +27 -0
- package/dist/CostReport.d.ts.map +1 -1
- package/dist/CostReport.js +103 -2
- package/dist/CostReport.js.map +1 -1
- package/dist/FlowEvents.d.ts +25 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +25 -1
- package/dist/FlowEvents.js.map +1 -1
- package/dist/FlowRecorder.d.ts +8 -0
- package/dist/FlowRecorder.d.ts.map +1 -1
- package/dist/FlowRecorder.js +10 -3
- package/dist/FlowRecorder.js.map +1 -1
- package/dist/GateTriage.d.ts +26 -0
- package/dist/GateTriage.d.ts.map +1 -1
- package/dist/GateTriage.js +98 -5
- package/dist/GateTriage.js.map +1 -1
- package/dist/GitTool.d.ts +9 -5
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +44 -18
- package/dist/GitTool.js.map +1 -1
- package/dist/OperatorSchemas.d.ts +256 -0
- package/dist/OperatorSchemas.d.ts.map +1 -0
- package/dist/OperatorSchemas.js +204 -0
- package/dist/OperatorSchemas.js.map +1 -0
- package/dist/Retro.d.ts +2 -0
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +6 -0
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +2 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +17 -9
- package/dist/Review.js.map +1 -1
- package/dist/RunState.d.ts +26 -0
- package/dist/RunState.d.ts.map +1 -0
- package/dist/RunState.js +59 -0
- package/dist/RunState.js.map +1 -0
- package/dist/Stories.d.ts +30 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +54 -1
- package/dist/Stories.js.map +1 -1
- package/dist/StoryPlan.d.ts +6 -0
- package/dist/StoryPlan.d.ts.map +1 -1
- package/dist/StoryPlan.js +7 -0
- package/dist/StoryPlan.js.map +1 -1
- package/dist/TestEvidence.d.ts +55 -0
- package/dist/TestEvidence.d.ts.map +1 -0
- package/dist/TestEvidence.js +90 -0
- package/dist/TestEvidence.js.map +1 -0
- package/package.json +5 -2
- package/src/Approval.ts +6 -0
- package/src/AutonomyContract.ts +10 -3
- package/src/CostReport.ts +152 -2
- package/src/FlowEvents.ts +25 -1
- package/src/FlowRecorder.ts +19 -3
- package/src/GateTriage.ts +113 -7
- package/src/GitTool.ts +46 -18
- package/src/OperatorSchemas.ts +221 -0
- package/src/Retro.ts +11 -0
- package/src/Review.ts +25 -11
- package/src/RunState.ts +72 -0
- package/src/Stories.ts +113 -3
- package/src/StoryPlan.ts +7 -0
- package/src/TestEvidence.ts +137 -0
package/src/GateTriage.ts
CHANGED
|
@@ -16,6 +16,97 @@ export type GateClass = typeof GateClass.Type
|
|
|
16
16
|
export const FailureOrigin = Schema.Literals(["new", "base", "flaky"])
|
|
17
17
|
export type FailureOrigin = typeof FailureOrigin.Type
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Why a test gate's exit code proves nothing: the runner collected or ran
|
|
21
|
+
* zero tests, or test files failed to load (an import that no longer
|
|
22
|
+
* resolves, a suite that errors before its first test). Either makes the
|
|
23
|
+
* gate red whatever the runner's exit code was.
|
|
24
|
+
*/
|
|
25
|
+
export const GateReason = Schema.Literals(["zero-tests", "load-failure"])
|
|
26
|
+
export type GateReason = typeof GateReason.Type
|
|
27
|
+
|
|
28
|
+
// One suite that errored before its tests ran, per runner: Vitest's
|
|
29
|
+
// "Failed Suites", Jest's "Test suite failed to run", pytest's collection
|
|
30
|
+
// errors, JUnit's class initialisation error under Surefire.
|
|
31
|
+
const loadFailureMarkers: ReadonlyArray<RegExp> = [
|
|
32
|
+
/Failed Suites\s+\d+/u,
|
|
33
|
+
/Test suite failed to run/u,
|
|
34
|
+
/\berrors? during collection\b/u,
|
|
35
|
+
/ExceptionInInitializerError|initializationError/u
|
|
36
|
+
]
|
|
37
|
+
const vitestNoTests = /\bTests\s+no tests\b/u
|
|
38
|
+
const vitestFilesFailed = /\bTest Files\s+\d+ failed\b/u
|
|
39
|
+
// Nothing collected: Vitest, Jest, pytest, Surefire, Gradle.
|
|
40
|
+
const zeroTestsMarkers: ReadonlyArray<RegExp> = [
|
|
41
|
+
/No test files found/u,
|
|
42
|
+
vitestNoTests,
|
|
43
|
+
/No tests found, exiting with code/u,
|
|
44
|
+
/\bTests:\s+0 total\b/u,
|
|
45
|
+
/\bno tests ran\b/u,
|
|
46
|
+
/\bcollected 0 items\b/u,
|
|
47
|
+
/No tests to run\./u,
|
|
48
|
+
/No tests found for given includes/u
|
|
49
|
+
]
|
|
50
|
+
const cargoRunning = /\brunning (\d+) tests?\b/gu
|
|
51
|
+
const junitRun = /\bTests run:\s*(\d+)/gu
|
|
52
|
+
const mochaPassing = /^\s*0 passing\b/mu
|
|
53
|
+
const mochaFailing = /^\s*\d+ failing\b/mu
|
|
54
|
+
|
|
55
|
+
const everyCountZero = (pattern: RegExp, output: string): boolean => {
|
|
56
|
+
let seen = false
|
|
57
|
+
pattern.lastIndex = 0
|
|
58
|
+
for (let match = pattern.exec(output); match !== null; match = pattern.exec(output)) {
|
|
59
|
+
seen = true
|
|
60
|
+
if (Number.parseInt(match[1] ?? "0", 10) > 0) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return seen
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The reason a test runner's output cannot stand for a green gate, when
|
|
69
|
+
* it has one: `load-failure` when any suite errored before its tests ran
|
|
70
|
+
* (it wins, since a load failure also leaves tests unrun), `zero-tests`
|
|
71
|
+
* when the runner collected nothing (Vitest, Jest, pytest, cargo test,
|
|
72
|
+
* mocha, JUnit via Maven or Gradle). `undefined` means tests ran.
|
|
73
|
+
*/
|
|
74
|
+
export const testGateReasonOf = (output: string): GateReason | undefined => {
|
|
75
|
+
if (loadFailureMarkers.some((pattern) => pattern.test(output))) {
|
|
76
|
+
return "load-failure"
|
|
77
|
+
}
|
|
78
|
+
if (vitestNoTests.test(output) && vitestFilesFailed.test(output)) {
|
|
79
|
+
return "load-failure"
|
|
80
|
+
}
|
|
81
|
+
if (zeroTestsMarkers.some((pattern) => pattern.test(output))) {
|
|
82
|
+
return "zero-tests"
|
|
83
|
+
}
|
|
84
|
+
if (everyCountZero(cargoRunning, output) || everyCountZero(junitRun, output)) {
|
|
85
|
+
return "zero-tests"
|
|
86
|
+
}
|
|
87
|
+
if (mochaPassing.test(output) && !mochaFailing.test(output)) {
|
|
88
|
+
return "zero-tests"
|
|
89
|
+
}
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The reason as the fix prompt and the findings read it. */
|
|
94
|
+
export const describeGateReason = (reason: GateReason): string =>
|
|
95
|
+
reason === "zero-tests"
|
|
96
|
+
? "test gate ran zero tests (zero-tests): the runner collected no test, so its exit code proves nothing"
|
|
97
|
+
: "test files failed to load (load-failure): a suite errored before its tests ran, so the gate is red whatever its exit code"
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The reason as a failing line, so a baseline records it and triage
|
|
101
|
+
* compares it like any other line: a target whose base already ran zero
|
|
102
|
+
* tests inherits that, a change that breaks loading is charged with it.
|
|
103
|
+
*/
|
|
104
|
+
export const gateReasonLine = (reason: GateReason): string => `gate reason: ${reason}`
|
|
105
|
+
|
|
106
|
+
/** The first gate reason a result carries, if any. */
|
|
107
|
+
export const gateReasonOf = (result: ReviewResult): GateReason | undefined =>
|
|
108
|
+
result.issues.find((issue) => isGateIssue(issue) && issue.gateReason !== undefined)?.gateReason
|
|
109
|
+
|
|
19
110
|
export class GateBaseline extends Schema.Class<GateBaseline>("GateBaseline")({
|
|
20
111
|
baseCommit: Schema.String,
|
|
21
112
|
appDir: Schema.String,
|
|
@@ -87,17 +178,22 @@ const legacyGateIssuePrefix = "lint failed: "
|
|
|
87
178
|
export const isGateIssue = (issue: ReviewIssue): boolean =>
|
|
88
179
|
issue.title.startsWith(gateIssuePrefix) || issue.title.startsWith(legacyGateIssuePrefix)
|
|
89
180
|
|
|
181
|
+
/** A gate issue's comparable lines: its reason first, then its normalized output. */
|
|
182
|
+
const failingLinesOfIssue = (
|
|
183
|
+
issue: ReviewIssue,
|
|
184
|
+
roots: ReadonlyArray<string>
|
|
185
|
+
): ReadonlyArray<string> => [
|
|
186
|
+
...(issue.gateReason === undefined ? [] : [gateReasonLine(issue.gateReason)]),
|
|
187
|
+
...normalizeGateOutput(issue.description, roots)
|
|
188
|
+
]
|
|
189
|
+
|
|
90
190
|
/** Every gate issue's normalized failing lines, in order, each once. */
|
|
91
191
|
export const failingLinesOf = (
|
|
92
192
|
result: ReviewResult,
|
|
93
193
|
roots: ReadonlyArray<string>
|
|
94
194
|
): ReadonlyArray<string> =>
|
|
95
195
|
Array.from(
|
|
96
|
-
new Set(
|
|
97
|
-
result.issues
|
|
98
|
-
.filter(isGateIssue)
|
|
99
|
-
.flatMap((issue) => normalizeGateOutput(issue.description, roots))
|
|
100
|
-
)
|
|
196
|
+
new Set(result.issues.filter(isGateIssue).flatMap((issue) => failingLinesOfIssue(issue, roots)))
|
|
101
197
|
)
|
|
102
198
|
|
|
103
199
|
export interface GateTriage {
|
|
@@ -134,18 +230,28 @@ export const triageGates = (
|
|
|
134
230
|
issues.push(issue)
|
|
135
231
|
continue
|
|
136
232
|
}
|
|
137
|
-
const lines =
|
|
233
|
+
const lines = failingLinesOfIssue(issue, roots)
|
|
138
234
|
const fresh = lines.filter((line) => !known.has(line))
|
|
139
235
|
inherited.push(...lines.filter((line) => known.has(line)))
|
|
140
236
|
if (lines.length > 0 && fresh.length === 0) {
|
|
141
237
|
continue
|
|
142
238
|
}
|
|
143
239
|
newLines.push(...fresh)
|
|
240
|
+
// A fresh reason keeps its sentence, so the fix prompt says what the
|
|
241
|
+
// runner's output meant, not only which lines were new.
|
|
242
|
+
const reasonLine = issue.gateReason === undefined ? undefined : gateReasonLine(issue.gateReason)
|
|
243
|
+
const freshOutput = fresh.filter((line) => line !== reasonLine)
|
|
244
|
+
const description =
|
|
245
|
+
issue.gateReason !== undefined && fresh.includes(reasonLine ?? "")
|
|
246
|
+
? [describeGateReason(issue.gateReason), ...freshOutput].join("\n")
|
|
247
|
+
: fresh.length > 0
|
|
248
|
+
? fresh.join("\n")
|
|
249
|
+
: issue.description
|
|
144
250
|
issues.push(
|
|
145
251
|
ReviewIssue.make({
|
|
146
252
|
...issue,
|
|
147
253
|
origin: "new",
|
|
148
|
-
description
|
|
254
|
+
description
|
|
149
255
|
})
|
|
150
256
|
)
|
|
151
257
|
}
|
package/src/GitTool.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { MergeConflict, ProcessError, type FlowError } from "./FlowError.ts"
|
|
|
6
6
|
import { Began, Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
7
7
|
import { timeEffect } from "./Timing.ts"
|
|
8
8
|
import { guarded } from "./CapabilityGuard.ts"
|
|
9
|
+
import { RunStateDirectory } from "./RunState.ts"
|
|
9
10
|
|
|
10
11
|
export class BranchCreated extends Schema.TaggedClass<BranchCreated>()("Created", {}) {}
|
|
11
12
|
export class BranchAlreadyExists extends Schema.TaggedClass<BranchAlreadyExists>()(
|
|
@@ -149,15 +150,20 @@ const problem = (result: ProcessResult): string => {
|
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
|
-
* The
|
|
153
|
-
*
|
|
154
|
-
* commits and
|
|
155
|
-
*
|
|
153
|
+
* The run's own state under `workDir/.llm4ts/`: traces, the cost ledger,
|
|
154
|
+
* plans, transcripts, epics. `commitAll` never stages it — it grows while
|
|
155
|
+
* the run commits and belongs to the machine, not the repository (a
|
|
156
|
+
* committed trace was read by every reviewer at real cost). `commitPaths`
|
|
157
|
+
* stages exactly what it is given, so a flow that keeps something there on
|
|
158
|
+
* purpose (a forked pack, a port ledger) names it.
|
|
156
159
|
*/
|
|
157
|
-
export const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
export const runStatePaths: ReadonlyArray<string> = [RunStateDirectory]
|
|
161
|
+
|
|
162
|
+
/** Whether a repository-relative path is the run state or inside it. */
|
|
163
|
+
export const isRunStatePath = (path: string): boolean => {
|
|
164
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\.\//u, "")
|
|
165
|
+
return normalized === RunStateDirectory || normalized.startsWith(`${RunStateDirectory}/`)
|
|
166
|
+
}
|
|
161
167
|
|
|
162
168
|
export const makeGitTool = (
|
|
163
169
|
process: ProcessExecutorShape,
|
|
@@ -277,12 +283,14 @@ export const makeGitTool = (
|
|
|
277
283
|
const checkout = (name: string): Effect.Effect<void, FlowError> =>
|
|
278
284
|
write("git checkout", runOrFail(["checkout", name]).pipe(Effect.asVoid))
|
|
279
285
|
|
|
280
|
-
// Stage everything, then take the
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
286
|
+
// Stage everything, then take the run state back out of the index:
|
|
287
|
+
// `reset -- <path>` leaves an ignored or missing path alone and keeps a
|
|
288
|
+
// previously committed plan or trace at its HEAD version (so neither its
|
|
289
|
+
// change nor its deletion is staged), whereas an `:(exclude)` pathspec
|
|
290
|
+
// makes `add` refuse the ignored files outright. The `.gitignore` the
|
|
291
|
+
// runner leaves there (`RunState`) already keeps new files out of `add`.
|
|
284
292
|
const addAll = runOrFail(["add", "-A"]).pipe(
|
|
285
|
-
Effect.andThen(runOrFail(["reset", "-q", "--", ...
|
|
293
|
+
Effect.andThen(runOrFail(["reset", "-q", "--", ...runStatePaths])),
|
|
286
294
|
Effect.asVoid
|
|
287
295
|
)
|
|
288
296
|
|
|
@@ -294,9 +302,15 @@ export const makeGitTool = (
|
|
|
294
302
|
}
|
|
295
303
|
// "nothing to commit, working tree clean" when the tree is clean;
|
|
296
304
|
// "nothing added to commit but untracked files present" when the only
|
|
297
|
-
// changes are files this commit was not asked to stage
|
|
305
|
+
// changes are files this commit was not asked to stage; "no changes
|
|
306
|
+
// added to commit" when the only changes are to tracked files it was
|
|
307
|
+
// not asked to stage (a plan under the run state, revised by the run).
|
|
298
308
|
const detail = problem(result)
|
|
299
|
-
if (
|
|
309
|
+
if (
|
|
310
|
+
detail.includes("nothing to commit") ||
|
|
311
|
+
detail.includes("nothing added to commit") ||
|
|
312
|
+
detail.includes("no changes added to commit")
|
|
313
|
+
) {
|
|
300
314
|
return new NothingToCommit()
|
|
301
315
|
}
|
|
302
316
|
return yield* ProcessError.make({
|
|
@@ -311,13 +325,27 @@ export const makeGitTool = (
|
|
|
311
325
|
// `add -A -- <paths>` stages additions, modifications, AND deletions of
|
|
312
326
|
// exactly those paths; a plain `add` would refuse a deleted file. `--only`
|
|
313
327
|
// on commit would also work but ignores paths that were never tracked.
|
|
328
|
+
// A path under the run state is force-added: it is wanted (a forked
|
|
329
|
+
// pack, a port ledger) where the runner's own ignore file would refuse
|
|
330
|
+
// it. Any other path keeps the repository's ignore rules.
|
|
314
331
|
const commitPathsEffect = (
|
|
315
332
|
message: string,
|
|
316
333
|
paths: ReadonlyArray<string>
|
|
317
|
-
): Effect.Effect<CommitResult, FlowError> =>
|
|
318
|
-
paths.
|
|
334
|
+
): Effect.Effect<CommitResult, FlowError> => {
|
|
335
|
+
const underRunState = paths.filter(isRunStatePath)
|
|
336
|
+
const elsewhere = paths.filter((path) => !isRunStatePath(path))
|
|
337
|
+
return paths.length === 0
|
|
319
338
|
? Effect.succeed(new NothingToCommit())
|
|
320
|
-
:
|
|
339
|
+
: Effect.gen(function* () {
|
|
340
|
+
if (elsewhere.length > 0) {
|
|
341
|
+
yield* runOrFail(["add", "-A", "--", ...elsewhere])
|
|
342
|
+
}
|
|
343
|
+
if (underRunState.length > 0) {
|
|
344
|
+
yield* runOrFail(["add", "-A", "-f", "--", ...underRunState])
|
|
345
|
+
}
|
|
346
|
+
return yield* commitStaged(message)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
321
349
|
|
|
322
350
|
return {
|
|
323
351
|
init: write(
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import * as Option from "effect/Option"
|
|
2
|
+
import * as Schema from "effect/Schema"
|
|
3
|
+
import { AuthStatus, Availability } from "@llm4ts/core/Models"
|
|
4
|
+
import { BoardStatus } from "./BoardSync.ts"
|
|
5
|
+
import { CostReport } from "./CostReport.ts"
|
|
6
|
+
import { RunOutcome } from "./FlowRecorder.ts"
|
|
7
|
+
import { StoryStatus } from "./Stories.ts"
|
|
8
|
+
|
|
9
|
+
/** Where a run the operator started stands, as the registry last recorded it. */
|
|
10
|
+
export const OperatorRunState = Schema.Literals([
|
|
11
|
+
"starting",
|
|
12
|
+
"running",
|
|
13
|
+
"exited",
|
|
14
|
+
"interrupted",
|
|
15
|
+
"lost"
|
|
16
|
+
])
|
|
17
|
+
export type OperatorRunState = typeof OperatorRunState.Type
|
|
18
|
+
|
|
19
|
+
/** The typed knobs a client may set when it starts a run; every one is optional. */
|
|
20
|
+
export const OperatorKnobs = Schema.Struct({
|
|
21
|
+
task: Schema.optionalKey(Schema.String),
|
|
22
|
+
epic: Schema.optionalKey(Schema.String),
|
|
23
|
+
pack: Schema.optionalKey(Schema.String),
|
|
24
|
+
executors: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
25
|
+
coder: Schema.optionalKey(Schema.String),
|
|
26
|
+
judgeRounds: Schema.optionalKey(Schema.Int),
|
|
27
|
+
mergeRevisions: Schema.optionalKey(Schema.Int),
|
|
28
|
+
/** The branch to land. */
|
|
29
|
+
land: Schema.optionalKey(Schema.String),
|
|
30
|
+
flags: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
31
|
+
requestId: Schema.optionalKey(Schema.String)
|
|
32
|
+
})
|
|
33
|
+
export type OperatorKnobs = typeof OperatorKnobs.Type
|
|
34
|
+
|
|
35
|
+
/** One registry record: a run the operator started, appended on every state change. */
|
|
36
|
+
export class OperatorRun extends Schema.Class<OperatorRun>("OperatorRun")({
|
|
37
|
+
runId: Schema.String,
|
|
38
|
+
repo: Schema.String,
|
|
39
|
+
flow: Schema.String,
|
|
40
|
+
args: Schema.Array(Schema.String),
|
|
41
|
+
knobs: OperatorKnobs,
|
|
42
|
+
startedAt: Schema.Number,
|
|
43
|
+
pid: Schema.optionalKey(Schema.Int),
|
|
44
|
+
tracePath: Schema.optionalKey(Schema.String),
|
|
45
|
+
logDir: Schema.String,
|
|
46
|
+
state: OperatorRunState,
|
|
47
|
+
exitCode: Schema.optionalKey(Schema.Int),
|
|
48
|
+
signal: Schema.optionalKey(Schema.String),
|
|
49
|
+
endedAt: Schema.optionalKey(Schema.Number),
|
|
50
|
+
requestId: Schema.optionalKey(Schema.String)
|
|
51
|
+
}) {}
|
|
52
|
+
|
|
53
|
+
export const decodeOperatorRunLine = Schema.decodeUnknownOption(Schema.fromJsonString(OperatorRun))
|
|
54
|
+
export const encodeOperatorRunLine = Schema.encodeSync(Schema.fromJsonString(OperatorRun))
|
|
55
|
+
|
|
56
|
+
/** The last record per run, newest `startedAt` first; a line that does not decode is skipped. */
|
|
57
|
+
export const latestRuns = (lines: ReadonlyArray<string>): ReadonlyArray<OperatorRun> => {
|
|
58
|
+
const byRun = new Map<string, OperatorRun>()
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.trim().length === 0) continue
|
|
61
|
+
for (const run of Option.toArray(decodeOperatorRunLine(line))) byRun.set(run.runId, run)
|
|
62
|
+
}
|
|
63
|
+
return [...byRun.values()].sort((a, b) => b.startedAt - a.startedAt)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const LaneStatusOut = Schema.Struct({
|
|
67
|
+
id: Schema.String,
|
|
68
|
+
status: Schema.Literals(["running", "done", "failed"]),
|
|
69
|
+
stage: Schema.optionalKey(Schema.String),
|
|
70
|
+
executor: Schema.optionalKey(Schema.String),
|
|
71
|
+
activity: Schema.optionalKey(Schema.String),
|
|
72
|
+
lastEventAgeMs: Schema.Int,
|
|
73
|
+
costUsd: Schema.Number
|
|
74
|
+
})
|
|
75
|
+
export type LaneStatusOut = typeof LaneStatusOut.Type
|
|
76
|
+
|
|
77
|
+
export class RunStatus extends Schema.Class<RunStatus>("RunStatus")({
|
|
78
|
+
runId: Schema.String,
|
|
79
|
+
state: OperatorRunState,
|
|
80
|
+
ended: Schema.optionalKey(RunOutcome),
|
|
81
|
+
elapsedMs: Schema.Int,
|
|
82
|
+
lanes: Schema.Array(LaneStatusOut),
|
|
83
|
+
stories: Schema.Array(Schema.Struct({ id: Schema.String, status: BoardStatus })),
|
|
84
|
+
tokens: Schema.Int,
|
|
85
|
+
costUsd: Schema.Number,
|
|
86
|
+
warnings: Schema.Array(Schema.String),
|
|
87
|
+
seq: Schema.Int,
|
|
88
|
+
summary: Schema.String
|
|
89
|
+
}) {}
|
|
90
|
+
|
|
91
|
+
export const MeaningfulEvent = Schema.Struct({
|
|
92
|
+
seq: Schema.Int,
|
|
93
|
+
at: Schema.Number,
|
|
94
|
+
kind: Schema.String,
|
|
95
|
+
lane: Schema.optionalKey(Schema.String),
|
|
96
|
+
text: Schema.String
|
|
97
|
+
})
|
|
98
|
+
export type MeaningfulEvent = typeof MeaningfulEvent.Type
|
|
99
|
+
|
|
100
|
+
export class RunWaitResult extends Schema.Class<RunWaitResult>("RunWaitResult")({
|
|
101
|
+
events: Schema.Array(MeaningfulEvent),
|
|
102
|
+
seq: Schema.Int,
|
|
103
|
+
ended: Schema.optionalKey(RunOutcome),
|
|
104
|
+
exited: Schema.Boolean,
|
|
105
|
+
summary: Schema.String
|
|
106
|
+
}) {}
|
|
107
|
+
|
|
108
|
+
export const ConnectorHealthOut = Schema.Struct({
|
|
109
|
+
id: Schema.String,
|
|
110
|
+
availability: Availability,
|
|
111
|
+
authStatus: AuthStatus,
|
|
112
|
+
// "auth": the CLI's own login check answered; "version": only `--version`
|
|
113
|
+
// ran, which proves no live login; "unknown": nothing more can be said.
|
|
114
|
+
probe: Schema.Literals(["version", "auth", "unknown"]),
|
|
115
|
+
latencyMs: Schema.optionalKey(Schema.Int)
|
|
116
|
+
})
|
|
117
|
+
export type ConnectorHealthOut = typeof ConnectorHealthOut.Type
|
|
118
|
+
|
|
119
|
+
export const ExecutorHealthOut = Schema.Struct({
|
|
120
|
+
id: Schema.String,
|
|
121
|
+
harness: Schema.String,
|
|
122
|
+
roles: Schema.Array(Schema.String),
|
|
123
|
+
disabled: Schema.Boolean,
|
|
124
|
+
healthy: Schema.optionalKey(Schema.Boolean)
|
|
125
|
+
})
|
|
126
|
+
export type ExecutorHealthOut = typeof ExecutorHealthOut.Type
|
|
127
|
+
|
|
128
|
+
export class PreflightResult extends Schema.Class<PreflightResult>("PreflightResult")({
|
|
129
|
+
llm4ts: Schema.String,
|
|
130
|
+
node: Schema.String,
|
|
131
|
+
connectors: Schema.Array(ConnectorHealthOut),
|
|
132
|
+
executors: Schema.Array(ExecutorHealthOut),
|
|
133
|
+
notes: Schema.Array(Schema.String)
|
|
134
|
+
}) {}
|
|
135
|
+
|
|
136
|
+
export class StoryDetail extends Schema.Class<StoryDetail>("StoryDetail")({
|
|
137
|
+
id: Schema.String,
|
|
138
|
+
title: Schema.String,
|
|
139
|
+
state: Schema.optionalKey(
|
|
140
|
+
Schema.Struct({
|
|
141
|
+
status: StoryStatus,
|
|
142
|
+
branch: Schema.String,
|
|
143
|
+
worktree: Schema.String,
|
|
144
|
+
executor: Schema.optionalKey(Schema.String)
|
|
145
|
+
})
|
|
146
|
+
),
|
|
147
|
+
board: Schema.optionalKey(
|
|
148
|
+
Schema.Struct({ status: BoardStatus, detail: Schema.optionalKey(Schema.String) })
|
|
149
|
+
),
|
|
150
|
+
tasks: Schema.Array(Schema.Struct({ title: Schema.String, completed: Schema.Boolean })),
|
|
151
|
+
findings: Schema.optionalKey(Schema.String),
|
|
152
|
+
verdict: Schema.optionalKey(Schema.Json),
|
|
153
|
+
gateLogs: Schema.Array(Schema.String)
|
|
154
|
+
}) {}
|
|
155
|
+
|
|
156
|
+
export class Diagnosis extends Schema.Class<Diagnosis>("Diagnosis")({
|
|
157
|
+
runId: Schema.String,
|
|
158
|
+
story: Schema.optionalKey(Schema.String),
|
|
159
|
+
digest: Schema.String,
|
|
160
|
+
failure: Schema.optionalKey(
|
|
161
|
+
Schema.Struct({
|
|
162
|
+
stage: Schema.optionalKey(Schema.String),
|
|
163
|
+
message: Schema.String,
|
|
164
|
+
lane: Schema.optionalKey(Schema.String)
|
|
165
|
+
})
|
|
166
|
+
),
|
|
167
|
+
gateLogTails: Schema.Array(Schema.Struct({ path: Schema.String, tail: Schema.String })),
|
|
168
|
+
transcriptTail: Schema.Array(Schema.String)
|
|
169
|
+
}) {}
|
|
170
|
+
|
|
171
|
+
export class CostsOut extends Schema.Class<CostsOut>("CostsOut")({
|
|
172
|
+
report: CostReport,
|
|
173
|
+
/** The run's cost: its trace, topped up from its ledger line where the trace was short. */
|
|
174
|
+
total: Schema.Number,
|
|
175
|
+
ledger: Schema.optionalKey(Schema.Number),
|
|
176
|
+
/** USD the ledger line supplied beyond the trace, when the trace lost lines. */
|
|
177
|
+
fromLedger: Schema.optionalKey(Schema.Number),
|
|
178
|
+
/** The trace says more than the ledger line, which no line can fill. */
|
|
179
|
+
differs: Schema.Boolean
|
|
180
|
+
}) {}
|
|
181
|
+
|
|
182
|
+
export class UnknownRepo extends Schema.TaggedError<UnknownRepo>()("UnknownRepo", {
|
|
183
|
+
message: Schema.String
|
|
184
|
+
}) {}
|
|
185
|
+
|
|
186
|
+
export class UnknownRun extends Schema.TaggedError<UnknownRun>()("UnknownRun", {
|
|
187
|
+
message: Schema.String
|
|
188
|
+
}) {}
|
|
189
|
+
|
|
190
|
+
export class UnknownStory extends Schema.TaggedError<UnknownStory>()("UnknownStory", {
|
|
191
|
+
message: Schema.String
|
|
192
|
+
}) {}
|
|
193
|
+
|
|
194
|
+
export class UnknownFlow extends Schema.TaggedError<UnknownFlow>()("UnknownFlow", {
|
|
195
|
+
message: Schema.String
|
|
196
|
+
}) {}
|
|
197
|
+
|
|
198
|
+
export class NotAllowed extends Schema.TaggedError<NotAllowed>()("NotAllowed", {
|
|
199
|
+
message: Schema.String
|
|
200
|
+
}) {}
|
|
201
|
+
|
|
202
|
+
/** A start knob that would reach the flow's argv or environment in an unsafe shape. */
|
|
203
|
+
export class InvalidKnob extends Schema.TaggedError<InvalidKnob>()("InvalidKnob", {
|
|
204
|
+
knob: Schema.String,
|
|
205
|
+
message: Schema.String
|
|
206
|
+
}) {}
|
|
207
|
+
|
|
208
|
+
export class RunInProgress extends Schema.TaggedError<RunInProgress>()("RunInProgress", {
|
|
209
|
+
message: Schema.String,
|
|
210
|
+
runId: Schema.String
|
|
211
|
+
}) {}
|
|
212
|
+
|
|
213
|
+
/** An epic id that is not a folder under the repository's `.llm4ts/epics/`. */
|
|
214
|
+
export class UnknownEpic extends Schema.TaggedError<UnknownEpic>()("UnknownEpic", {
|
|
215
|
+
message: Schema.String
|
|
216
|
+
}) {}
|
|
217
|
+
|
|
218
|
+
/** A state file that exists but could not be read or parsed; the message names its path. */
|
|
219
|
+
export class ReadFailed extends Schema.TaggedError<ReadFailed>()("ReadFailed", {
|
|
220
|
+
message: Schema.String
|
|
221
|
+
}) {}
|
package/src/Retro.ts
CHANGED
|
@@ -431,6 +431,17 @@ const runLinePrefixes = [
|
|
|
431
431
|
"retro:"
|
|
432
432
|
]
|
|
433
433
|
|
|
434
|
+
/** One story's digest section, whatever its outcome; `undefined` when the plan has no such story. */
|
|
435
|
+
export const renderStoryDigest = (
|
|
436
|
+
inputs: RetroInputs,
|
|
437
|
+
storyId: string,
|
|
438
|
+
share: number = perStoryShare
|
|
439
|
+
): string | undefined => {
|
|
440
|
+
const trace = traceView(inputs.trace)
|
|
441
|
+
const entry = inputs.stories.find((candidate) => candidate.story.id === storyId)
|
|
442
|
+
return entry === undefined ? undefined : storySection(inputs, trace, entry, share)
|
|
443
|
+
}
|
|
444
|
+
|
|
434
445
|
/** The digest a retro seat is given: code-built, deterministic, capped. */
|
|
435
446
|
export const renderRetroDigest = (inputs: RetroInputs): string => {
|
|
436
447
|
const trace = traceView(inputs.trace)
|
package/src/Review.ts
CHANGED
|
@@ -19,7 +19,14 @@ import {
|
|
|
19
19
|
type FlowError
|
|
20
20
|
} from "./FlowError.ts"
|
|
21
21
|
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
GateBaseline,
|
|
24
|
+
describeGateReason,
|
|
25
|
+
gateIssuePrefix,
|
|
26
|
+
isGateIssue,
|
|
27
|
+
testGateReasonOf,
|
|
28
|
+
triageGates
|
|
29
|
+
} from "./GateTriage.ts"
|
|
23
30
|
import {
|
|
24
31
|
type OracleRules,
|
|
25
32
|
checkOracle,
|
|
@@ -75,6 +82,8 @@ export class ReviewIssue extends Schema.Class<ReviewIssue>("ReviewIssue")({
|
|
|
75
82
|
origin: Schema.optionalKey(Schema.Literals(["new", "base", "flaky"])),
|
|
76
83
|
/** How a gate ended: a red exit, killed at the timeout, or a signal exit. */
|
|
77
84
|
gateClass: Schema.optionalKey(Schema.Literals(["red", "hang", "crash"])),
|
|
85
|
+
/** Why a test gate's output could not stand for green: no tests ran, or test files failed to load. */
|
|
86
|
+
gateReason: Schema.optionalKey(Schema.Literals(["zero-tests", "load-failure"])),
|
|
78
87
|
/** Where the gate's full output was written, when the caller asked for a log. */
|
|
79
88
|
logPath: Schema.optionalKey(Schema.String)
|
|
80
89
|
}) {}
|
|
@@ -538,7 +547,11 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
538
547
|
)
|
|
539
548
|
)
|
|
540
549
|
)
|
|
541
|
-
const
|
|
550
|
+
const output = result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n")
|
|
551
|
+
// A runner that collected nothing, or whose files failed to load, is red
|
|
552
|
+
// whatever it exited with: a green exit over zero tests proves nothing.
|
|
553
|
+
const reason = result === undefined ? undefined : testGateReasonOf(output)
|
|
554
|
+
const failed = result === undefined || result.exitCode !== 0 || reason !== undefined
|
|
542
555
|
yield* events.publish(
|
|
543
556
|
Timed.make({
|
|
544
557
|
kind: "gate",
|
|
@@ -550,16 +563,15 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
550
563
|
)
|
|
551
564
|
let logPath: string | undefined
|
|
552
565
|
if (options.log !== undefined) {
|
|
553
|
-
|
|
554
|
-
result === undefined ? "" : [...result.stdout, ...result.stderr].join("\n").trim()
|
|
555
|
-
yield* options.log.files.writeAtomic(options.log.path, output)
|
|
566
|
+
yield* options.log.files.writeAtomic(options.log.path, output.trim())
|
|
556
567
|
logPath = options.log.path
|
|
557
568
|
}
|
|
569
|
+
// No summary line to count and a reason not to trust the run: zero passed.
|
|
558
570
|
const passed =
|
|
559
571
|
result === undefined
|
|
560
572
|
? undefined
|
|
561
|
-
: passedCountIn(
|
|
562
|
-
if (
|
|
573
|
+
: (passedCountIn(output) ?? (reason === undefined ? undefined : 0))
|
|
574
|
+
if (!failed) {
|
|
563
575
|
return ReviewResult.make({
|
|
564
576
|
issues: [],
|
|
565
577
|
summary: "lint passed",
|
|
@@ -568,16 +580,18 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
568
580
|
}
|
|
569
581
|
const seconds =
|
|
570
582
|
options.timeout === undefined ? 0 : Math.round(Duration.toSeconds(options.timeout))
|
|
583
|
+
const problem =
|
|
584
|
+
result === undefined
|
|
585
|
+
? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
|
|
586
|
+
: processProblem(result.stdout, result.stderr, result.exitCode)
|
|
571
587
|
return ReviewResult.make({
|
|
572
588
|
issues: [
|
|
573
589
|
ReviewIssue.make({
|
|
574
590
|
severity: "Critical",
|
|
575
591
|
title: `${gateIssuePrefix}${label}`,
|
|
576
|
-
description:
|
|
577
|
-
result === undefined
|
|
578
|
-
? `gate killed: no exit after ${seconds} seconds (LLM4TS_GATE_TIMEOUT)`
|
|
579
|
-
: processProblem(result.stdout, result.stderr, result.exitCode),
|
|
592
|
+
description: reason === undefined ? problem : `${describeGateReason(reason)}\n\n${problem}`,
|
|
580
593
|
gateClass: result === undefined ? "hang" : gateClassOf(result.exitCode),
|
|
594
|
+
...(reason === undefined ? {} : { gateReason: reason }),
|
|
581
595
|
...(logPath === undefined ? {} : { logPath })
|
|
582
596
|
})
|
|
583
597
|
],
|
package/src/RunState.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The run's own state lives under `<repo>/.llm4ts/`: traces, the cost
|
|
2
|
+
// ledger, plans, transcripts, epics. It belongs to the machine the run
|
|
3
|
+
// happened on, not to the repository: a committed trace is read by every
|
|
4
|
+
// reviewer at real cost, and a coder that reaches it can wipe an earlier
|
|
5
|
+
// run. So the first write under a `.llm4ts/` directory leaves a
|
|
6
|
+
// `.gitignore` there that ignores everything, and a flow commit never
|
|
7
|
+
// stages the directory (see `GitTool.commitAll`).
|
|
8
|
+
import * as Effect from "effect/Effect"
|
|
9
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
|
+
|
|
11
|
+
/** The directory name, as a path segment. */
|
|
12
|
+
export const RunStateDirectory = ".llm4ts"
|
|
13
|
+
|
|
14
|
+
/** What `<dir>/.llm4ts/.gitignore` holds: everything under it is ignored. */
|
|
15
|
+
export const runStateIgnoreContents = "*\n"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The glob every CLI seat is denied by default (ADR 0027 amendment,
|
|
19
|
+
* 2026-10-22): the run state, relative to the seat's working directory.
|
|
20
|
+
*/
|
|
21
|
+
export const runStateDeniedPaths: ReadonlyArray<string> = [`${RunStateDirectory}/**`]
|
|
22
|
+
|
|
23
|
+
const ignoreName = ".gitignore"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The `<dir>/.llm4ts` prefix of `path` when the path has a `.llm4ts`
|
|
27
|
+
* segment; the first such segment, so a worktree nested under the run
|
|
28
|
+
* state (`.llm4ts/port/shards/1/.llm4ts/…`) still ignores the outer one
|
|
29
|
+
* first. Separators are kept as written.
|
|
30
|
+
*/
|
|
31
|
+
export const runStateRootOf = (path: string): string | undefined => {
|
|
32
|
+
const match = /^(.*?)(^|[\\/])\.llm4ts(?=[\\/]|$)/.exec(path)
|
|
33
|
+
return match === null ? undefined : `${match[1] ?? ""}${match[2] ?? ""}${RunStateDirectory}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The store with one rule on top: the first write under any `.llm4ts/`
|
|
38
|
+
* directory creates its `.gitignore` (`*`) when none exists, so nothing
|
|
39
|
+
* the run writes there is ever staged. An ignore file the repository
|
|
40
|
+
* already has is left as it is: a team that commits its own flows or
|
|
41
|
+
* forked packs keeps its exceptions. Idempotent to wrap twice.
|
|
42
|
+
*/
|
|
43
|
+
export const ignoringRunState = (store: PlainFileStoreShape): PlainFileStoreShape => {
|
|
44
|
+
const ensured = new Set<string>()
|
|
45
|
+
const ensureIgnore = (path: string): Effect.Effect<void, never> => {
|
|
46
|
+
const root = runStateRootOf(path)
|
|
47
|
+
if (root === undefined || ensured.has(root)) {
|
|
48
|
+
return Effect.void
|
|
49
|
+
}
|
|
50
|
+
const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/"
|
|
51
|
+
const ignorePath = `${root}${separator}${ignoreName}`
|
|
52
|
+
if (path === ignorePath) {
|
|
53
|
+
return Effect.void
|
|
54
|
+
}
|
|
55
|
+
return store.read(ignorePath).pipe(
|
|
56
|
+
Effect.flatMap((existing) =>
|
|
57
|
+
existing === undefined ? store.writeAtomic(ignorePath, runStateIgnoreContents) : Effect.void
|
|
58
|
+
),
|
|
59
|
+
// A store that cannot write the ignore file is about to fail the
|
|
60
|
+
// write itself, with its own error; nothing to add here.
|
|
61
|
+
Effect.catch(() => Effect.void),
|
|
62
|
+
Effect.tap(() => Effect.sync(() => void ensured.add(root)))
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...store,
|
|
67
|
+
writeAtomic: (path, contents) =>
|
|
68
|
+
ensureIgnore(path).pipe(Effect.andThen(store.writeAtomic(path, contents))),
|
|
69
|
+
append: (path, contents) =>
|
|
70
|
+
ensureIgnore(path).pipe(Effect.andThen(store.append(path, contents)))
|
|
71
|
+
}
|
|
72
|
+
}
|