@llm4ts/flow 2.31.1 → 2.33.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 +1 -1
- package/dist/Approval.d.ts.map +1 -1
- package/dist/Artifacts.d.ts +2 -2
- package/dist/Artifacts.d.ts.map +1 -1
- package/dist/BenchReport.d.ts +2 -2
- package/dist/BenchReport.d.ts.map +1 -1
- package/dist/Chat.d.ts +5 -2
- package/dist/Chat.d.ts.map +1 -1
- package/dist/Chat.js +7 -4
- package/dist/Chat.js.map +1 -1
- package/dist/CostLedger.d.ts +2 -2
- package/dist/CostLedger.d.ts.map +1 -1
- package/dist/Diagnostics.d.ts +27 -0
- package/dist/Diagnostics.d.ts.map +1 -0
- package/dist/Diagnostics.js +84 -0
- package/dist/Diagnostics.js.map +1 -0
- package/dist/Equiv.d.ts +3 -3
- package/dist/Equiv.d.ts.map +1 -1
- package/dist/Flow.d.ts +16 -1
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +29 -3
- package/dist/Flow.js.map +1 -1
- package/dist/FlowError.d.ts +15 -2
- package/dist/FlowError.d.ts.map +1 -1
- package/dist/FlowError.js +15 -0
- package/dist/FlowError.js.map +1 -1
- package/dist/FlowEvents.d.ts +12 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +11 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/Gates.d.ts +1 -1
- package/dist/Gates.d.ts.map +1 -1
- package/dist/Judgment.d.ts +2 -2
- package/dist/Judgment.d.ts.map +1 -1
- package/dist/Landing.d.ts +1 -1
- package/dist/Landing.d.ts.map +1 -1
- package/dist/NodePreflight.d.ts +1 -1
- package/dist/NodePreflight.d.ts.map +1 -1
- package/dist/Pack.d.ts +25 -1
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +38 -2
- package/dist/Pack.js.map +1 -1
- package/dist/Persistence.d.ts +2 -2
- package/dist/Persistence.d.ts.map +1 -1
- package/dist/PlanExecution.d.ts +1 -1
- package/dist/PlanExecution.d.ts.map +1 -1
- package/dist/Port.d.ts +57 -0
- package/dist/Port.d.ts.map +1 -0
- package/dist/Port.js +134 -0
- package/dist/Port.js.map +1 -0
- package/dist/ProgramJudge.d.ts +1 -1
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/ProgramJudge.js +6 -3
- package/dist/ProgramJudge.js.map +1 -1
- package/dist/Replay.d.ts +2 -2
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Retro.d.ts +51 -4
- package/dist/Retro.d.ts.map +1 -1
- package/dist/Retro.js +204 -12
- package/dist/Retro.js.map +1 -1
- package/dist/Review.d.ts +38 -6
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +147 -8
- package/dist/Review.js.map +1 -1
- package/dist/ReviewCache.d.ts +1 -1
- package/dist/ReviewCache.d.ts.map +1 -1
- package/dist/Reviewer.d.ts +2 -0
- package/dist/Reviewer.d.ts.map +1 -1
- package/dist/Reviewer.js +3 -1
- package/dist/Reviewer.js.map +1 -1
- package/dist/Roster.d.ts +1 -1
- package/dist/Roster.d.ts.map +1 -1
- package/dist/RosterSeats.d.ts +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/Stall.d.ts +29 -0
- package/dist/Stall.d.ts.map +1 -0
- package/dist/Stall.js +49 -0
- package/dist/Stall.js.map +1 -0
- package/dist/Stories.d.ts +8 -1
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +7 -1
- package/dist/Stories.js.map +1 -1
- package/dist/WorkQueue.d.ts +63 -0
- package/dist/WorkQueue.d.ts.map +1 -0
- package/dist/WorkQueue.js +117 -0
- package/dist/WorkQueue.js.map +1 -0
- package/package.json +6 -2
- package/src/Chat.ts +15 -7
- package/src/Diagnostics.ts +105 -0
- package/src/Flow.ts +48 -3
- package/src/FlowError.ts +16 -0
- package/src/FlowEvents.ts +14 -0
- package/src/Pack.ts +63 -1
- package/src/Port.ts +178 -0
- package/src/ProgramJudge.ts +3 -0
- package/src/Retro.ts +237 -5
- package/src/Review.ts +202 -8
- package/src/Reviewer.ts +3 -1
- package/src/Stall.ts +85 -0
- package/src/Stories.ts +14 -0
- package/src/WorkQueue.ts +194 -0
package/src/Stall.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Stopping conditions the model does not control (ADR 0027 decision 11).
|
|
2
|
+
// Pure helpers over a chat's chunk stream; `Chat` applies them when asked.
|
|
3
|
+
import * as Duration from "effect/Duration"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Ref from "effect/Ref"
|
|
6
|
+
import * as Stream from "effect/Stream"
|
|
7
|
+
import type { LlmError } from "@llm4ts/core/Errors"
|
|
8
|
+
import type { LlmChunk } from "@llm4ts/core/Models"
|
|
9
|
+
import { Stalled } from "./FlowError.ts"
|
|
10
|
+
|
|
11
|
+
export interface StallOptions {
|
|
12
|
+
/** The same tool call with the same arguments this many times in a row ends the turn. Default 5. */
|
|
13
|
+
readonly repeats?: number
|
|
14
|
+
/** No chunk for this long ends the turn; absent means never (a slow seat looks like silence). */
|
|
15
|
+
readonly silence?: Duration.Duration
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const defaultStallRepeats = 5
|
|
19
|
+
|
|
20
|
+
/** The identity of a tool call chunk: name and arguments; `undefined` for anything else. */
|
|
21
|
+
export const toolCallKey = (chunk: LlmChunk): string | undefined =>
|
|
22
|
+
chunk.metadata.event === "tool_use"
|
|
23
|
+
? `${chunk.metadata.tool_name ?? chunk.metadata.toolName ?? ""}|${chunk.metadata.tool_input ?? chunk.metadata.toolInput ?? ""}`
|
|
24
|
+
: undefined
|
|
25
|
+
|
|
26
|
+
export interface RepeatState {
|
|
27
|
+
readonly key: string | undefined
|
|
28
|
+
readonly count: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const noRepeats: RepeatState = { key: undefined, count: 0 }
|
|
32
|
+
|
|
33
|
+
/** The repeat state after one chunk: a different call resets, the same one counts. */
|
|
34
|
+
export const nextRepeat = (state: RepeatState, chunk: LlmChunk): RepeatState => {
|
|
35
|
+
const key = toolCallKey(chunk)
|
|
36
|
+
if (key === undefined) {
|
|
37
|
+
return state
|
|
38
|
+
}
|
|
39
|
+
return key === state.key ? { key, count: state.count + 1 } : { key, count: 1 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The stream, ended with `Stalled` when one tool call repeats `repeats`
|
|
44
|
+
* times in a row (the state is shared across a chat's turns through `state`)
|
|
45
|
+
* or when no chunk arrives within `silence`.
|
|
46
|
+
*/
|
|
47
|
+
export const stallGuard = <R>(
|
|
48
|
+
stream: Stream.Stream<LlmChunk, LlmError, R>,
|
|
49
|
+
options: StallOptions,
|
|
50
|
+
state: Ref.Ref<RepeatState>
|
|
51
|
+
): Stream.Stream<LlmChunk, LlmError | Stalled, R> => {
|
|
52
|
+
const limit = Math.max(2, options.repeats ?? defaultStallRepeats)
|
|
53
|
+
const counted = Stream.mapEffect(stream, (chunk) =>
|
|
54
|
+
Effect.flatMap(
|
|
55
|
+
Ref.modify(state, (current) => {
|
|
56
|
+
const next = nextRepeat(current, chunk)
|
|
57
|
+
return [next, next] as const
|
|
58
|
+
}),
|
|
59
|
+
(next) =>
|
|
60
|
+
next.count >= limit
|
|
61
|
+
? Effect.fail(
|
|
62
|
+
Stalled.make({
|
|
63
|
+
signal: "repeated-tool-call",
|
|
64
|
+
detail: `the same tool call ${next.count} times in a row: ${(next.key ?? "").slice(0, 200)}`
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
: Effect.succeed(chunk)
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
const silence = options.silence
|
|
71
|
+
return silence === undefined
|
|
72
|
+
? counted
|
|
73
|
+
: counted.pipe(
|
|
74
|
+
Stream.timeoutOrElse({
|
|
75
|
+
duration: silence,
|
|
76
|
+
orElse: () =>
|
|
77
|
+
Stream.fail(
|
|
78
|
+
Stalled.make({
|
|
79
|
+
signal: "silence",
|
|
80
|
+
detail: `no output for ${Duration.format(silence)} (LLM4TS_STALL_MINUTES)`
|
|
81
|
+
})
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
)
|
|
85
|
+
}
|
package/src/Stories.ts
CHANGED
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
ReviewIssue,
|
|
73
73
|
ReviewResult,
|
|
74
74
|
applyTriage,
|
|
75
|
+
loadRepoReviewRules,
|
|
75
76
|
type FixPromptOptions,
|
|
76
77
|
type GateTriageOptions
|
|
77
78
|
} from "./Review.ts"
|
|
@@ -85,6 +86,7 @@ import { cachedValue, fingerprintOf } from "./ReviewCache.ts"
|
|
|
85
86
|
import { withContract, type ContractProfile } from "./AutonomyContract.ts"
|
|
86
87
|
import { fabricatedStatusIssues, unverifiedClaims } from "./Evidence.ts"
|
|
87
88
|
import type { Trailer } from "./CarriedNotes.ts"
|
|
89
|
+
import type { StallOptions } from "./Stall.ts"
|
|
88
90
|
import type { Reviewer } from "./Reviewer.ts"
|
|
89
91
|
import {
|
|
90
92
|
dependentsOf,
|
|
@@ -659,6 +661,12 @@ export interface StoriesOptions {
|
|
|
659
661
|
readonly fix?: FixPromptOptions
|
|
660
662
|
/** Test-file and marker patterns for the oracle guard; default rules when absent (ADR 0027). */
|
|
661
663
|
readonly oracleRules?: OracleRules
|
|
664
|
+
/** Independent votes of the adversarial lens per review round (ADR 0027 decision 7). Default 1. */
|
|
665
|
+
readonly votes?: number
|
|
666
|
+
/** Who applies review findings: the implementer's chat (default) or a separate fixer (ADR 0027 decision 8). */
|
|
667
|
+
readonly fixer?: "coder" | "separate"
|
|
668
|
+
/** End a coder turn that repeats one tool call or goes silent (ADR 0027 decision 11). */
|
|
669
|
+
readonly stall?: StallOptions
|
|
662
670
|
/**
|
|
663
671
|
* The autonomy contract profile for the coder's system prompt (ADR 0027
|
|
664
672
|
* decision 5). Default: the roster executor's `contract`, else `full`.
|
|
@@ -1447,6 +1455,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1447
1455
|
result
|
|
1448
1456
|
),
|
|
1449
1457
|
onTaskReply: (task, _reply, trailer, startedAt) => checkEvidence(task, trailer, startedAt),
|
|
1458
|
+
// The repository's own review rules ride along as one extra lens
|
|
1459
|
+
// (ADR 0027 decision 9), read from the epic checkout.
|
|
1460
|
+
repoRules: loadRepoReviewRules(files, context.workDir),
|
|
1461
|
+
...(options.votes === undefined ? {} : { votes: options.votes }),
|
|
1462
|
+
...(options.fixer === undefined ? {} : { fixer: options.fixer }),
|
|
1463
|
+
...(options.stall === undefined ? {} : { stall: options.stall }),
|
|
1450
1464
|
...(options.reviewers === undefined ? {} : { reviewers: options.reviewers }),
|
|
1451
1465
|
...(options.maxRounds === undefined ? {} : { maxRounds: options.maxRounds })
|
|
1452
1466
|
})
|
package/src/WorkQueue.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A mechanical work queue (ADR 0028): many small units with a filesystem
|
|
2
|
+
// "done" predicate, worked in rounds under a concurrency cap, each unit's
|
|
3
|
+
// failure becoming the next round's item, progress persisted as a ledger so
|
|
4
|
+
// a rerun resumes where the run stopped. The Bun port's shape; a story is a
|
|
5
|
+
// feature with a DAG and a judge, a queue item is a file with a predicate.
|
|
6
|
+
import * as Clock from "effect/Clock"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
9
|
+
import { Stalled, describeFlowError, type FlowError } from "./FlowError.ts"
|
|
10
|
+
import {
|
|
11
|
+
Info,
|
|
12
|
+
StageCompleted,
|
|
13
|
+
StageFailed,
|
|
14
|
+
StageStarted,
|
|
15
|
+
type FlowEventsShape
|
|
16
|
+
} from "./FlowEvents.ts"
|
|
17
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
18
|
+
|
|
19
|
+
export interface QueueItem {
|
|
20
|
+
readonly id: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Confidence = Schema.Literals(["high", "medium", "low"])
|
|
24
|
+
|
|
25
|
+
/** One line of the ledger: what happened to one item in one round. */
|
|
26
|
+
export class QueueOutcome extends Schema.Class<QueueOutcome>("QueueOutcome")({
|
|
27
|
+
id: Schema.String,
|
|
28
|
+
round: Schema.Int,
|
|
29
|
+
status: Schema.Literals(["done", "failed"]),
|
|
30
|
+
ms: Schema.Number,
|
|
31
|
+
note: Schema.optionalKey(Schema.String),
|
|
32
|
+
confidence: Schema.optionalKey(Confidence),
|
|
33
|
+
todos: Schema.optionalKey(Schema.Int),
|
|
34
|
+
at: Schema.Number
|
|
35
|
+
}) {}
|
|
36
|
+
|
|
37
|
+
const encodeOutcome = Schema.encodeSync(Schema.fromJsonString(QueueOutcome))
|
|
38
|
+
const decodeOutcome = Schema.decodeUnknownOption(Schema.fromJsonString(QueueOutcome))
|
|
39
|
+
|
|
40
|
+
/** What a unit of work reports back; the queue records it. */
|
|
41
|
+
export interface WorkResult {
|
|
42
|
+
readonly note?: string
|
|
43
|
+
readonly confidence?: "high" | "medium" | "low"
|
|
44
|
+
readonly todos?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RunQueueOptions<Item extends QueueItem> {
|
|
48
|
+
readonly label: string
|
|
49
|
+
readonly items: ReadonlyArray<Item>
|
|
50
|
+
/** The filesystem predicate: an item whose output exists is done, whatever the ledger says. */
|
|
51
|
+
readonly done: (item: Item) => Effect.Effect<boolean, FlowError>
|
|
52
|
+
/** One unit of work: implement, review, fix; the queue catches its failure. */
|
|
53
|
+
readonly work: (item: Item, round: number) => Effect.Effect<WorkResult, FlowError>
|
|
54
|
+
readonly events: FlowEventsShape
|
|
55
|
+
readonly concurrency?: number
|
|
56
|
+
/** Rounds over the items that failed; default 3. */
|
|
57
|
+
readonly maxRounds?: number
|
|
58
|
+
/** Where outcomes are appended, one JSON line each; omit to keep none. */
|
|
59
|
+
readonly ledger?: { readonly files: PlainFileStoreShape; readonly path: string }
|
|
60
|
+
/** After every round: the done items of that round, for a commit. */
|
|
61
|
+
readonly afterRound?: (
|
|
62
|
+
round: number,
|
|
63
|
+
done: ReadonlyArray<Item>,
|
|
64
|
+
failed: ReadonlyArray<Item>
|
|
65
|
+
) => Effect.Effect<void, FlowError>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface QueueReport<Item extends QueueItem> {
|
|
69
|
+
readonly outcomes: ReadonlyArray<QueueOutcome>
|
|
70
|
+
readonly rounds: number
|
|
71
|
+
/** Items still not done when the queue stopped. */
|
|
72
|
+
readonly pending: ReadonlyArray<Item>
|
|
73
|
+
/** Items that were already done before the first round (a resume). */
|
|
74
|
+
readonly skipped: ReadonlyArray<Item>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const readLedger = (
|
|
78
|
+
files: PlainFileStoreShape,
|
|
79
|
+
path: string
|
|
80
|
+
): Effect.Effect<ReadonlyArray<QueueOutcome>, FlowError> =>
|
|
81
|
+
Effect.map(files.read(path), (text) =>
|
|
82
|
+
(text ?? "")
|
|
83
|
+
.split("\n")
|
|
84
|
+
.filter((line) => line.trim().length > 0)
|
|
85
|
+
.flatMap((line) => {
|
|
86
|
+
const decoded = decodeOutcome(line)
|
|
87
|
+
return decoded._tag === "Some" ? [decoded.value] : []
|
|
88
|
+
})
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Works the items that are not done, round after round, until every item is
|
|
93
|
+
* done, the rounds run out, or a round makes no progress (typed `Stalled`).
|
|
94
|
+
*/
|
|
95
|
+
export const runQueue = Effect.fn("@llm4ts/flow/WorkQueue.run")(function* <Item extends QueueItem>(
|
|
96
|
+
options: RunQueueOptions<Item>
|
|
97
|
+
): Effect.fn.Return<QueueReport<Item>, FlowError> {
|
|
98
|
+
const concurrency = Math.max(1, options.concurrency ?? 4)
|
|
99
|
+
const maxRounds = Math.max(1, options.maxRounds ?? 3)
|
|
100
|
+
const outcomes: Array<QueueOutcome> = []
|
|
101
|
+
const record = (outcome: QueueOutcome): Effect.Effect<void, FlowError> =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
outcomes.push(outcome)
|
|
104
|
+
if (options.ledger !== undefined) {
|
|
105
|
+
yield* options.ledger.files.append(options.ledger.path, `${encodeOutcome(outcome)}\n`)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const skipped: Array<Item> = []
|
|
110
|
+
let pending: Array<Item> = []
|
|
111
|
+
for (const item of options.items) {
|
|
112
|
+
if (yield* options.done(item)) {
|
|
113
|
+
skipped.push(item)
|
|
114
|
+
} else {
|
|
115
|
+
pending.push(item)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (skipped.length > 0) {
|
|
119
|
+
yield* options.events.publish(
|
|
120
|
+
Info.make({
|
|
121
|
+
message: `${options.label}: ${skipped.length} of ${options.items.length} item(s) already done; ${pending.length} to go`
|
|
122
|
+
})
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let rounds = 0
|
|
127
|
+
while (pending.length > 0 && rounds < maxRounds) {
|
|
128
|
+
rounds += 1
|
|
129
|
+
const round = rounds
|
|
130
|
+
yield* options.events.publish(
|
|
131
|
+
Info.make({ message: `${options.label}: round ${round}, ${pending.length} item(s)` })
|
|
132
|
+
)
|
|
133
|
+
const results = yield* Effect.forEach(
|
|
134
|
+
pending,
|
|
135
|
+
(item) =>
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const stage = `${options.label} ${item.id}`
|
|
138
|
+
yield* options.events.publish(StageStarted.make({ stage, lane: item.id }))
|
|
139
|
+
const started = yield* Clock.currentTimeMillis
|
|
140
|
+
const result = yield* options.work(item, round).pipe(
|
|
141
|
+
Effect.map((value) => ({ ok: true as const, value })),
|
|
142
|
+
Effect.catch((error: FlowError) =>
|
|
143
|
+
Effect.succeed({ ok: false as const, error: describeFlowError(error) })
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
const ended = yield* Clock.currentTimeMillis
|
|
147
|
+
// The predicate decides, not the work's own account of itself.
|
|
148
|
+
const finished = result.ok && (yield* options.done(item))
|
|
149
|
+
const outcome = QueueOutcome.make({
|
|
150
|
+
id: item.id,
|
|
151
|
+
round,
|
|
152
|
+
status: finished ? "done" : "failed",
|
|
153
|
+
ms: ended - started,
|
|
154
|
+
at: ended,
|
|
155
|
+
...(result.ok
|
|
156
|
+
? {
|
|
157
|
+
...(finished
|
|
158
|
+
? {}
|
|
159
|
+
: { note: result.value.note ?? "the work ended but left no output" }),
|
|
160
|
+
...(finished && result.value.note !== undefined
|
|
161
|
+
? { note: result.value.note }
|
|
162
|
+
: {}),
|
|
163
|
+
...(result.value.confidence === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { confidence: result.value.confidence }),
|
|
166
|
+
...(result.value.todos === undefined ? {} : { todos: result.value.todos })
|
|
167
|
+
}
|
|
168
|
+
: { note: result.error })
|
|
169
|
+
})
|
|
170
|
+
yield* record(outcome)
|
|
171
|
+
yield* options.events.publish(
|
|
172
|
+
finished
|
|
173
|
+
? StageCompleted.make({ stage, lane: item.id })
|
|
174
|
+
: StageFailed.make({ stage, lane: item.id, message: outcome.note ?? "failed" })
|
|
175
|
+
)
|
|
176
|
+
return { item, finished }
|
|
177
|
+
}),
|
|
178
|
+
{ concurrency }
|
|
179
|
+
)
|
|
180
|
+
const done = results.filter((entry) => entry.finished).map((entry) => entry.item)
|
|
181
|
+
const failed = results.filter((entry) => !entry.finished).map((entry) => entry.item)
|
|
182
|
+
if (options.afterRound !== undefined) {
|
|
183
|
+
yield* options.afterRound(round, done, failed)
|
|
184
|
+
}
|
|
185
|
+
if (done.length === 0 && failed.length > 0) {
|
|
186
|
+
return yield* Stalled.make({
|
|
187
|
+
signal: "no-progress",
|
|
188
|
+
detail: `${options.label}: round ${round} finished none of its ${failed.length} item(s)`
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
pending = failed
|
|
192
|
+
}
|
|
193
|
+
return { outcomes, rounds, pending, skipped }
|
|
194
|
+
})
|