@llm4ts/flow 2.35.1 → 2.35.3
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/FlowEvents.d.ts +17 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +28 -2
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +2 -2
- package/dist/GitTool.js.map +1 -1
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +21 -4
- package/dist/Review.js.map +1 -1
- package/dist/Roster.d.ts +2 -0
- package/dist/Roster.d.ts.map +1 -1
- package/dist/Roster.js +27 -14
- package/dist/Roster.js.map +1 -1
- package/dist/RosterSeats.d.ts.map +1 -1
- package/dist/RosterSeats.js +3 -0
- package/dist/RosterSeats.js.map +1 -1
- package/dist/Stories.d.ts +20 -0
- package/dist/Stories.d.ts.map +1 -1
- package/dist/Stories.js +65 -6
- package/dist/Stories.js.map +1 -1
- package/dist/Timing.d.ts +11 -3
- package/dist/Timing.d.ts.map +1 -1
- package/dist/Timing.js +22 -5
- package/dist/Timing.js.map +1 -1
- package/package.json +2 -2
- package/src/FlowEvents.ts +30 -2
- package/src/GitTool.ts +7 -3
- package/src/Review.ts +35 -2
- package/src/Roster.ts +35 -13
- package/src/RosterSeats.ts +3 -0
- package/src/Stories.ts +141 -46
- package/src/Timing.ts +43 -14
package/src/Review.ts
CHANGED
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
passedCountIn
|
|
29
29
|
} from "./OracleGuard.ts"
|
|
30
30
|
import { cachedReview, fingerprintOf } from "./ReviewCache.ts"
|
|
31
|
+
import { withCallPurpose } from "./Timing.ts"
|
|
31
32
|
import {
|
|
33
|
+
Began,
|
|
32
34
|
ReviewFindingDemoted,
|
|
33
35
|
Info,
|
|
34
36
|
JudgmentObserved,
|
|
@@ -493,6 +495,8 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
493
495
|
}
|
|
494
496
|
const label = command.join(" ")
|
|
495
497
|
const started = yield* Clock.currentTimeMillis
|
|
498
|
+
// A gate or a setup can run for many minutes: say it started, not only when it ends.
|
|
499
|
+
yield* events.publish(Began.make({ kind: "gate", label }))
|
|
496
500
|
const run = guarded(
|
|
497
501
|
Capabilities.Exec(executable),
|
|
498
502
|
`lint: ${label}`,
|
|
@@ -526,6 +530,13 @@ export const lintCommand = Effect.fn("@llm4ts/flow/Review.lintCommand")(function
|
|
|
526
530
|
ran === undefined ? Effect.void : Effect.annotateCurrentSpan(attr.gateExit, ran.exitCode)
|
|
527
531
|
)
|
|
528
532
|
)
|
|
533
|
+
).pipe(
|
|
534
|
+
// A command that could not run still ends what its `Began` started.
|
|
535
|
+
Effect.tapError(() =>
|
|
536
|
+
Effect.flatMap(Clock.currentTimeMillis, (now) =>
|
|
537
|
+
events.publish(Timed.make({ kind: "gate", label, ms: now - started, failed: true }))
|
|
538
|
+
)
|
|
539
|
+
)
|
|
529
540
|
)
|
|
530
541
|
const failed = result === undefined || result.exitCode !== 0
|
|
531
542
|
yield* events.publish(
|
|
@@ -895,14 +906,21 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
895
906
|
const chosen = screened.reviewers
|
|
896
907
|
const cache = options.cache
|
|
897
908
|
const votes = Math.max(1, options.votes ?? 1)
|
|
909
|
+
// Each lens is its own call; the roster's lease says which, so four
|
|
910
|
+
// parallel reviewer leases read as four questions, not one repeated.
|
|
911
|
+
const lensCall = (lens: Reviewer, vote: number): Effect.Effect<ReviewResult, FlowError> =>
|
|
912
|
+
withCallPurpose(
|
|
913
|
+
`${lens.name} lens${votes > 1 && lens.name === adversarialReviewer.name ? ` · vote ${vote + 1} of ${votes}` : ""}`,
|
|
914
|
+
reviewWith(options.reviewerService, options.events, lens, options.taskTitle, diff)
|
|
915
|
+
)
|
|
898
916
|
const reviewVote = (lens: Reviewer, vote: number): Effect.Effect<ReviewResult, FlowError> =>
|
|
899
917
|
cache === undefined
|
|
900
|
-
?
|
|
918
|
+
? lensCall(lens, vote)
|
|
901
919
|
: cachedReview(
|
|
902
920
|
cache.files,
|
|
903
921
|
join(cache.dir, `${fileSlug(lens.name)}${vote === 0 ? "" : `-vote${vote}`}.json`),
|
|
904
922
|
fingerprintOf([lens.name, lensPrompt(lens), String(vote), options.taskTitle, diff]),
|
|
905
|
-
|
|
923
|
+
lensCall(lens, vote)
|
|
906
924
|
)
|
|
907
925
|
// Votes multiply only the adversarial lens: its recall is what independent
|
|
908
926
|
// eyes improve; the concern lenses are cheap scoped questions.
|
|
@@ -925,6 +943,21 @@ export const reviewAndFixLoop = Effect.fn("@llm4ts/flow/Review.reviewAndFixLoop"
|
|
|
925
943
|
}))
|
|
926
944
|
)
|
|
927
945
|
const parallelism = options.parallelism ?? 0
|
|
946
|
+
if (chosen.length > 1) {
|
|
947
|
+
yield* options.events.publish(
|
|
948
|
+
Info.make({
|
|
949
|
+
message: `review round ${round} of "${options.taskTitle}": ${chosen.length} lenses ${
|
|
950
|
+
parallelism === 1 ? "one at a time" : "in parallel"
|
|
951
|
+
} (${chosen
|
|
952
|
+
.map((lens) =>
|
|
953
|
+
lens.name === adversarialReviewer.name && votes > 1
|
|
954
|
+
? `${lens.name} ×${votes}`
|
|
955
|
+
: lens.name
|
|
956
|
+
)
|
|
957
|
+
.join(", ")})`
|
|
958
|
+
})
|
|
959
|
+
)
|
|
960
|
+
}
|
|
928
961
|
const results =
|
|
929
962
|
parallelism > 0
|
|
930
963
|
? yield* Effect.forEach(chosen, run, { concurrency: parallelism })
|
package/src/Roster.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as Semaphore from "effect/Semaphore"
|
|
|
15
15
|
import type { LlmError } from "@llm4ts/core/Errors"
|
|
16
16
|
import { RosterExhausted, type FlowError } from "./FlowError.ts"
|
|
17
17
|
import {
|
|
18
|
+
Began,
|
|
18
19
|
ExecutorExcluded,
|
|
19
20
|
ExecutorLeased,
|
|
20
21
|
ExecutorReleased,
|
|
@@ -406,6 +407,8 @@ export interface LeaseOptions {
|
|
|
406
407
|
readonly prefer?: string
|
|
407
408
|
/** Who is asking, for the events (a story's worktree). */
|
|
408
409
|
readonly label?: string
|
|
410
|
+
/** What the call is for beyond its role (a review lens), for the events. */
|
|
411
|
+
readonly purpose?: string
|
|
409
412
|
/**
|
|
410
413
|
* The executor holding this context's coder, taken when independence
|
|
411
414
|
* cannot be had now: no executor outside `avoid` can ever take the role,
|
|
@@ -515,6 +518,9 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
515
518
|
}
|
|
516
519
|
}
|
|
517
520
|
|
|
521
|
+
const purposeOf = (who: LeaseOptions): { readonly purpose?: string } =>
|
|
522
|
+
who.purpose === undefined ? {} : { purpose: who.purpose }
|
|
523
|
+
|
|
518
524
|
const say = (message: string): Effect.Effect<void> =>
|
|
519
525
|
events.publish(Info.make({ message: `roster: ${message}` }))
|
|
520
526
|
|
|
@@ -598,13 +604,15 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
598
604
|
const leaseOf = (
|
|
599
605
|
spec: ExecutorSpec,
|
|
600
606
|
role: Role,
|
|
601
|
-
|
|
607
|
+
who: LeaseOptions,
|
|
602
608
|
because?: "nobody" | "held" | "out"
|
|
603
609
|
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
604
610
|
Effect.gen(function* () {
|
|
605
|
-
const labelled = label === undefined ? {} : { label }
|
|
611
|
+
const labelled = who.label === undefined ? {} : { label: who.label }
|
|
606
612
|
const own = because === undefined ? {} : { because }
|
|
607
|
-
yield* events.publish(
|
|
613
|
+
yield* events.publish(
|
|
614
|
+
ExecutorLeased.make({ executor: spec.id, role, ...labelled, ...purposeOf(who), ...own })
|
|
615
|
+
)
|
|
608
616
|
const done = yield* Ref.make(false)
|
|
609
617
|
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
610
618
|
was
|
|
@@ -622,13 +630,20 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
622
630
|
const borrowedLease = (
|
|
623
631
|
spec: ExecutorSpec,
|
|
624
632
|
role: Role,
|
|
625
|
-
|
|
633
|
+
who: LeaseOptions,
|
|
626
634
|
because: "nobody" | "held" | "out"
|
|
627
635
|
): Effect.Effect<Lease, never, Scope.Scope> =>
|
|
628
636
|
Effect.gen(function* () {
|
|
629
|
-
const labelled = label === undefined ? {} : { label }
|
|
637
|
+
const labelled = who.label === undefined ? {} : { label: who.label }
|
|
630
638
|
yield* events.publish(
|
|
631
|
-
ExecutorLeased.make({
|
|
639
|
+
ExecutorLeased.make({
|
|
640
|
+
executor: spec.id,
|
|
641
|
+
role,
|
|
642
|
+
...labelled,
|
|
643
|
+
...purposeOf(who),
|
|
644
|
+
borrowed: true,
|
|
645
|
+
because
|
|
646
|
+
})
|
|
632
647
|
)
|
|
633
648
|
const done = yield* Ref.make(false)
|
|
634
649
|
const free = Effect.flatMap(Ref.getAndSet(done, true), (was) =>
|
|
@@ -841,11 +856,11 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
841
856
|
})
|
|
842
857
|
yield* waited
|
|
843
858
|
return own !== undefined && own.id === borrow.id
|
|
844
|
-
? yield* leaseOf(own, role, leaseOptions
|
|
859
|
+
? yield* leaseOf(own, role, leaseOptions, because)
|
|
845
860
|
: own !== undefined
|
|
846
861
|
? // Someone came back between the check and the pick: independent after all.
|
|
847
|
-
yield* leaseOf(own, role, leaseOptions
|
|
848
|
-
: yield* borrowedLease(borrow, role, leaseOptions
|
|
862
|
+
yield* leaseOf(own, role, leaseOptions)
|
|
863
|
+
: yield* borrowedLease(borrow, role, leaseOptions, because)
|
|
849
864
|
})
|
|
850
865
|
let announced = false
|
|
851
866
|
const asked = yield* Clock.currentTimeMillis
|
|
@@ -866,7 +881,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
866
881
|
const spec = yield* attempt(role, leaseOptions)
|
|
867
882
|
if (spec !== undefined) {
|
|
868
883
|
yield* waited
|
|
869
|
-
return yield* leaseOf(spec, role, leaseOptions
|
|
884
|
+
return yield* leaseOf(spec, role, leaseOptions)
|
|
870
885
|
}
|
|
871
886
|
if (!(yield* canEverServe(role, avoid))) {
|
|
872
887
|
const own = yield* takeOwn("nobody")
|
|
@@ -897,15 +912,22 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
897
912
|
}
|
|
898
913
|
if (!announced) {
|
|
899
914
|
announced = true
|
|
915
|
+
yield* events.publish(
|
|
916
|
+
Began.make({
|
|
917
|
+
kind: "wait",
|
|
918
|
+
label: `roster ${role}`,
|
|
919
|
+
...(leaseOptions.label === undefined ? {} : { lane: leaseOptions.label })
|
|
920
|
+
})
|
|
921
|
+
)
|
|
900
922
|
yield* say(
|
|
901
|
-
`waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}: ${yield* waitingReport(role, avoid)}`
|
|
923
|
+
`waiting for an executor to take ${role}${leaseOptions.label === undefined ? "" : ` for ${leaseOptions.label}`}${leaseOptions.purpose === undefined ? "" : ` (${leaseOptions.purpose})`}: ${yield* waitingReport(role, avoid)}`
|
|
902
924
|
)
|
|
903
925
|
}
|
|
904
926
|
yield* probeDue
|
|
905
927
|
const recovered = yield* attempt(role, leaseOptions)
|
|
906
928
|
if (recovered !== undefined) {
|
|
907
929
|
yield* waited
|
|
908
|
-
return yield* leaseOf(recovered, role, leaseOptions
|
|
930
|
+
return yield* leaseOf(recovered, role, leaseOptions)
|
|
909
931
|
}
|
|
910
932
|
yield* waitOrProbe(role)
|
|
911
933
|
}
|
|
@@ -916,7 +938,7 @@ export const makeRoster = Effect.fn("@llm4ts/flow/Roster.make")(function* (
|
|
|
916
938
|
leaseOptions: LeaseOptions = {}
|
|
917
939
|
): Effect.Effect<Lease | undefined, never, Scope.Scope> =>
|
|
918
940
|
Effect.flatMap(attempt(role, leaseOptions), (spec) =>
|
|
919
|
-
spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions
|
|
941
|
+
spec === undefined ? Effect.succeed(undefined) : leaseOf(spec, role, leaseOptions)
|
|
920
942
|
)
|
|
921
943
|
|
|
922
944
|
const exclude = (exclusion: Exclusion): Effect.Effect<void> =>
|
package/src/RosterSeats.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
|
13
13
|
import { Message, type ConnectorCapabilities, type LlmChunk } from "@llm4ts/core/Models"
|
|
14
14
|
import { describeFlowError, type FlowError } from "./FlowError.ts"
|
|
15
15
|
import { ExecutorHandedOver, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
|
+
import { callPurpose } from "./Timing.ts"
|
|
16
17
|
import {
|
|
17
18
|
describeExclusion,
|
|
18
19
|
hasRole,
|
|
@@ -141,10 +142,12 @@ export const rosterSeat = (
|
|
|
141
142
|
// The roster decides who serves: an independent executor, or, when
|
|
142
143
|
// none can ever serve or every one is held by a coder, the context's own.
|
|
143
144
|
const borrow = options.borrow === undefined ? undefined : yield* options.borrow
|
|
145
|
+
const purpose = yield* callPurpose
|
|
144
146
|
const lease = yield* roster
|
|
145
147
|
.lease(role, {
|
|
146
148
|
avoid,
|
|
147
149
|
...(options.label === undefined ? {} : { label: options.label }),
|
|
150
|
+
...(purpose === undefined ? {} : { purpose }),
|
|
148
151
|
...(borrow === undefined ? {} : { borrow })
|
|
149
152
|
})
|
|
150
153
|
.pipe(Effect.mapError(asLlmError))
|
package/src/Stories.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type FlowError
|
|
43
43
|
} from "./FlowError.ts"
|
|
44
44
|
import {
|
|
45
|
+
Began,
|
|
45
46
|
EvidenceChecked,
|
|
46
47
|
Info,
|
|
47
48
|
JudgedDimension,
|
|
@@ -781,6 +782,55 @@ export const setupRecoveryPrompt = (command: string): string =>
|
|
|
781
782
|
const failed = (story: Story, reason: string): StoryFailed =>
|
|
782
783
|
StoryFailed.make({ story: story.id, reason })
|
|
783
784
|
|
|
785
|
+
/** What the story scheduler can say about stories it is not starting. */
|
|
786
|
+
export interface LaunchPicture {
|
|
787
|
+
readonly epicId: string
|
|
788
|
+
readonly running: number
|
|
789
|
+
readonly concurrency: number
|
|
790
|
+
/** The roster's coder capacity; absent without a roster. */
|
|
791
|
+
readonly capacity?: number
|
|
792
|
+
/** Ready stories left for a coder slot to free. */
|
|
793
|
+
readonly readyWaiting: number
|
|
794
|
+
/** Stories whose dependencies are not merged yet, with the ones they wait for. */
|
|
795
|
+
readonly blocked: ReadonlyArray<{ readonly id: string; readonly on: ReadonlyArray<string> }>
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const shownBlocked = 3
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* One line on why fewer stories run than the cap allows, or `undefined`
|
|
802
|
+
* when nothing waits: dependencies first (the common case), then capacity.
|
|
803
|
+
*/
|
|
804
|
+
export const launchSummary = (picture: LaunchPicture): string | undefined => {
|
|
805
|
+
if (picture.blocked.length === 0 && picture.readyWaiting === 0) {
|
|
806
|
+
return undefined
|
|
807
|
+
}
|
|
808
|
+
const cap =
|
|
809
|
+
picture.capacity === undefined
|
|
810
|
+
? `concurrency ${picture.concurrency}`
|
|
811
|
+
: `concurrency ${picture.concurrency}, ${picture.capacity} coder slot(s)`
|
|
812
|
+
const blocked =
|
|
813
|
+
picture.blocked.length === 0
|
|
814
|
+
? []
|
|
815
|
+
: [
|
|
816
|
+
`${picture.blocked.length} waiting on dependencies: ${picture.blocked
|
|
817
|
+
.slice(0, shownBlocked)
|
|
818
|
+
.map((story) => `${story.id} ← ${story.on.join(", ")}`)
|
|
819
|
+
.join("; ")}${
|
|
820
|
+
picture.blocked.length > shownBlocked
|
|
821
|
+
? ` (+${picture.blocked.length - shownBlocked} more)`
|
|
822
|
+
: ""
|
|
823
|
+
}`
|
|
824
|
+
]
|
|
825
|
+
const starved =
|
|
826
|
+
picture.readyWaiting === 0 ? [] : [`${picture.readyWaiting} ready, waiting for a coder slot`]
|
|
827
|
+
return [
|
|
828
|
+
`epic ${picture.epicId}: ${picture.running} running (${cap})`,
|
|
829
|
+
...blocked,
|
|
830
|
+
...starved
|
|
831
|
+
].join(" · ")
|
|
832
|
+
}
|
|
833
|
+
|
|
784
834
|
// ---- The executor -------------------------------------------------------------
|
|
785
835
|
|
|
786
836
|
export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(function* (
|
|
@@ -934,52 +984,56 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
934
984
|
/** Merge the story branch into the epic branch and re-gate the epic head, one story at a time. */
|
|
935
985
|
const integrate = (story: Story, branch: string): Effect.Effect<void, FlowError> =>
|
|
936
986
|
Effect.flatMap(Clock.currentTimeMillis, (requested) =>
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
const checkpoint = yield* context.git.checkpoint
|
|
947
|
-
yield* timeEffect(
|
|
948
|
-
lane,
|
|
949
|
-
context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
|
|
950
|
-
(ms, failed) =>
|
|
951
|
-
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) })
|
|
952
|
-
)
|
|
953
|
-
const gate = yield* options.gates(context.workDir, lane)
|
|
954
|
-
// Charged only with what the merge added: the head before it had
|
|
955
|
-
// a baseline (run start or the previous merge).
|
|
956
|
-
const before = yield* ensureBaseline(
|
|
957
|
-
checkpoint,
|
|
958
|
-
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
959
|
-
context.workDir
|
|
960
|
-
)
|
|
961
|
-
const verdict = triageGates(gate, before, rootsOf(context.workDir))
|
|
962
|
-
if (!verdict.blocking.isClean) {
|
|
963
|
-
// Never leave a red epic head for the next story to inherit.
|
|
964
|
-
yield* context.git.rollback(checkpoint)
|
|
965
|
-
return yield* failed(
|
|
966
|
-
story,
|
|
967
|
-
`epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
|
|
987
|
+
Effect.andThen(
|
|
988
|
+
laneOf(story).publish(Began.make({ kind: "wait", label: "merge lock" })),
|
|
989
|
+
mergeLock.withPermit(
|
|
990
|
+
Effect.gen(function* () {
|
|
991
|
+
const lane = laneOf(story)
|
|
992
|
+
// Merges are one at a time: how long this story queued for its turn.
|
|
993
|
+
const turn = yield* Clock.currentTimeMillis
|
|
994
|
+
yield* lane.publish(
|
|
995
|
+
Timed.make({ kind: "wait", label: "merge lock", ms: turn - requested })
|
|
968
996
|
)
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
997
|
+
yield* epicCheckoutClean(story)
|
|
998
|
+
const checkpoint = yield* context.git.checkpoint
|
|
999
|
+
yield* timeEffect(
|
|
1000
|
+
lane,
|
|
1001
|
+
context.git.merge(branch, `${plan.epicId}: merge story ${story.id}`),
|
|
1002
|
+
(ms, failed) =>
|
|
1003
|
+
Timed.make({ kind: "merge", label: "merge", ms, ...(failed ? { failed } : {}) }),
|
|
1004
|
+
() => Began.make({ kind: "merge", label: "merge" })
|
|
977
1005
|
)
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1006
|
+
const gate = yield* options.gates(context.workDir, lane)
|
|
1007
|
+
// Charged only with what the merge added: the head before it had
|
|
1008
|
+
// a baseline (run start or the previous merge).
|
|
1009
|
+
const before = yield* ensureBaseline(
|
|
1010
|
+
checkpoint,
|
|
1011
|
+
Effect.succeed(ReviewResult.make({ issues: [] })),
|
|
1012
|
+
context.workDir
|
|
1013
|
+
)
|
|
1014
|
+
const verdict = triageGates(gate, before, rootsOf(context.workDir))
|
|
1015
|
+
if (!verdict.blocking.isClean) {
|
|
1016
|
+
// Never leave a red epic head for the next story to inherit.
|
|
1017
|
+
yield* context.git.rollback(checkpoint)
|
|
1018
|
+
return yield* failed(
|
|
1019
|
+
story,
|
|
1020
|
+
`epic gates failed after merging; merge undone:\n${issueLines(verdict.blocking)}`
|
|
1021
|
+
)
|
|
1022
|
+
}
|
|
1023
|
+
if (gateCommands !== undefined) {
|
|
1024
|
+
const mergedHead = yield* context.git.checkpoint
|
|
1025
|
+
yield* writeBaseline(
|
|
1026
|
+
files,
|
|
1027
|
+
options.stateDir,
|
|
1028
|
+
keyFor(mergedHead),
|
|
1029
|
+
yield* baselineOf(mergedHead, gate, context.workDir)
|
|
1030
|
+
)
|
|
1031
|
+
}
|
|
1032
|
+
yield* laneOf(story).publish(
|
|
1033
|
+
Info.make({ message: `story ${story.id}: merged into ${epicBranch}` })
|
|
1034
|
+
)
|
|
1035
|
+
})
|
|
1036
|
+
)
|
|
983
1037
|
)
|
|
984
1038
|
)
|
|
985
1039
|
|
|
@@ -1278,7 +1332,13 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1278
1332
|
: { rerunTest: options.testGate(state.worktree, laneOf(story)) })
|
|
1279
1333
|
}
|
|
1280
1334
|
if (gateCommands !== undefined) {
|
|
1281
|
-
|
|
1335
|
+
// Recorded here, before the first task, so the lane says what the time goes to.
|
|
1336
|
+
const baseline = yield* stage(
|
|
1337
|
+
laneOf(story),
|
|
1338
|
+
`story ${story.id}: baseline gates`,
|
|
1339
|
+
storyBaseline
|
|
1340
|
+
)
|
|
1341
|
+
const inherited = baseline?.failingLines ?? []
|
|
1282
1342
|
if (inherited.length > 0) {
|
|
1283
1343
|
inheritedByStory.set(story.id, inherited)
|
|
1284
1344
|
yield* appendFindings(
|
|
@@ -1424,7 +1484,12 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1424
1484
|
plan: planWithinPerimeter(
|
|
1425
1485
|
plan,
|
|
1426
1486
|
story,
|
|
1427
|
-
(text) =>
|
|
1487
|
+
(text) =>
|
|
1488
|
+
stage(
|
|
1489
|
+
laneOf(story),
|
|
1490
|
+
`story ${story.id}: plan tasks`,
|
|
1491
|
+
planTasks(watchedSeats, story, text)
|
|
1492
|
+
),
|
|
1428
1493
|
prompt,
|
|
1429
1494
|
laneOf(story)
|
|
1430
1495
|
),
|
|
@@ -1816,6 +1881,8 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1816
1881
|
|
|
1817
1882
|
/** Why no further story may start in this run; stories already running finish. */
|
|
1818
1883
|
let halted: string | undefined
|
|
1884
|
+
/** The last launch summary said, so a picture is told once, when it changes. */
|
|
1885
|
+
let toldLaunch: string | undefined
|
|
1819
1886
|
/** Stories retried once after the serving engine recovered. */
|
|
1820
1887
|
const recovered = new Set<string>()
|
|
1821
1888
|
|
|
@@ -1856,6 +1923,34 @@ export const implementStoriesFlow = Effect.fn("@llm4ts/flow/Stories.implement")(
|
|
|
1856
1923
|
) {
|
|
1857
1924
|
starvedAt = capacity
|
|
1858
1925
|
}
|
|
1926
|
+
// Say why fewer stories run than the cap allows: a plan that
|
|
1927
|
+
// chains its stories looks exactly like a scheduler that stalled.
|
|
1928
|
+
const started = new Set([...current.running, ...launching.map((story) => story.id)])
|
|
1929
|
+
const said = launchSummary({
|
|
1930
|
+
epicId: plan.epicId,
|
|
1931
|
+
running: started.size,
|
|
1932
|
+
concurrency,
|
|
1933
|
+
...(context.roster === undefined ? {} : { capacity }),
|
|
1934
|
+
readyWaiting: ready.length - launching.length,
|
|
1935
|
+
blocked: plan.stories.flatMap((story) =>
|
|
1936
|
+
current.done.has(story.id) ||
|
|
1937
|
+
current.failed.has(story.id) ||
|
|
1938
|
+
current.waiting.has(story.id) ||
|
|
1939
|
+
started.has(story.id) ||
|
|
1940
|
+
ready.includes(story)
|
|
1941
|
+
? []
|
|
1942
|
+
: [
|
|
1943
|
+
{
|
|
1944
|
+
id: story.id,
|
|
1945
|
+
on: story.dependsOn.filter((dependency) => !current.done.has(dependency))
|
|
1946
|
+
}
|
|
1947
|
+
]
|
|
1948
|
+
)
|
|
1949
|
+
})
|
|
1950
|
+
if (said !== undefined && said !== toldLaunch) {
|
|
1951
|
+
yield* events.publish(Info.make({ message: said }))
|
|
1952
|
+
}
|
|
1953
|
+
toldLaunch = said
|
|
1859
1954
|
}
|
|
1860
1955
|
if ((yield* Ref.get(running)).size === 0) {
|
|
1861
1956
|
break
|
package/src/Timing.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { Message, type LlmChunk, type TokenUsage } from "@llm4ts/core/Models"
|
|
|
13
13
|
import { redactText } from "@llm4ts/core/observability/Redaction"
|
|
14
14
|
import { toolCategory } from "./Activity.ts"
|
|
15
15
|
import { estimateUsage, estimatedModelLabel, type EstimatedUsageOptions } from "./EstimatedUsage.ts"
|
|
16
|
-
import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
16
|
+
import { Began, Timed, type FlowEventsShape } from "./FlowEvents.ts"
|
|
17
17
|
import {
|
|
18
18
|
attr,
|
|
19
19
|
currentKindSpan,
|
|
@@ -178,6 +178,22 @@ const TimedRole = Context.Reference<string | undefined>("@llm4ts/flow/Timing/Tim
|
|
|
178
178
|
defaultValue: () => undefined
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* What a call is for, beyond its role: a review lens, a vote. A roster
|
|
183
|
+
* lease names it (`topolino takes reviewer for home · adversarial lens`),
|
|
184
|
+
* so parallel calls of one role read as the different questions they are.
|
|
185
|
+
*/
|
|
186
|
+
const CallPurpose = Context.Reference<string | undefined>("@llm4ts/flow/Timing/CallPurpose", {
|
|
187
|
+
defaultValue: () => undefined
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
/** The purpose the current call is made for, when its caller named one. */
|
|
191
|
+
export const callPurpose: Effect.Effect<string | undefined> = CallPurpose
|
|
192
|
+
|
|
193
|
+
/** Runs `effect`'s calls under `purpose`, for the roster's lease events. */
|
|
194
|
+
export const withCallPurpose = <A, E, R>(purpose: string, effect: Effect.Effect<A, E, R>) =>
|
|
195
|
+
Effect.provideService(effect, CallPurpose, purpose)
|
|
196
|
+
|
|
181
197
|
/** The role the current call is made for: the caller's, or else `label`. */
|
|
182
198
|
export const roleOr = (label: string): Effect.Effect<string> =>
|
|
183
199
|
Effect.map(TimedRole, (role) => role ?? label)
|
|
@@ -192,16 +208,24 @@ const reportedMs = (chunk: LlmChunk, key: string): number | undefined => {
|
|
|
192
208
|
return Number.isFinite(value) ? value : undefined
|
|
193
209
|
}
|
|
194
210
|
|
|
195
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Times `effect`, publishing what `timed` makes of its duration whether it
|
|
213
|
+
* succeeds or fails; with `began`, also what it makes of the start, when the
|
|
214
|
+
* effect starts, so a screen shows the work while it runs.
|
|
215
|
+
*/
|
|
196
216
|
export const timeEffect = <A, E, R>(
|
|
197
217
|
events: FlowEventsShape,
|
|
198
218
|
effect: Effect.Effect<A, E, R>,
|
|
199
|
-
timed: (ms: number, failed: boolean, role: string | undefined) => Timed
|
|
219
|
+
timed: (ms: number, failed: boolean, role: string | undefined) => Timed,
|
|
220
|
+
began?: (role: string | undefined) => Began
|
|
200
221
|
): Effect.Effect<A, E, R> =>
|
|
201
222
|
Effect.flatMap(Effect.all([Clock.currentTimeMillis, TimedRole]), ([start, role]) =>
|
|
202
|
-
Effect.
|
|
203
|
-
Effect.
|
|
204
|
-
|
|
223
|
+
Effect.andThen(
|
|
224
|
+
began === undefined ? Effect.void : events.publish(began(role)),
|
|
225
|
+
Effect.onExit(effect, (exit) =>
|
|
226
|
+
Effect.flatMap(Clock.currentTimeMillis, (end) =>
|
|
227
|
+
events.publish(timed(end - start, Exit.isFailure(exit), role))
|
|
228
|
+
)
|
|
205
229
|
)
|
|
206
230
|
)
|
|
207
231
|
)
|
|
@@ -239,7 +263,7 @@ const tracedEffect = <A, E, R>(
|
|
|
239
263
|
...promptContent(content, [Message.make({ role: "User", content: prompt })])
|
|
240
264
|
}
|
|
241
265
|
},
|
|
242
|
-
timeEffect(events, effect, timed).pipe(
|
|
266
|
+
timeEffect(events, effect, timed, () => Began.make({ kind: "model", label: role })).pipe(
|
|
243
267
|
Effect.onExit((exit) => {
|
|
244
268
|
const known = Exit.isSuccess(exit)
|
|
245
269
|
? usageOf(exit.value)
|
|
@@ -282,6 +306,7 @@ const timedStream = <E>(
|
|
|
282
306
|
// The call's LLM span (ADR 0026), parented to the nearest kind span and
|
|
283
307
|
// ended when the stream does; each harness tool call is a TOOL child,
|
|
284
308
|
// opened on `tool_use` and closed on `tool_result`.
|
|
309
|
+
yield* events.publish(Began.make({ kind: "model", label: role }))
|
|
285
310
|
const parent = yield* currentKindSpan
|
|
286
311
|
const span = yield* Effect.makeSpan(role, {
|
|
287
312
|
kind: "client",
|
|
@@ -488,12 +513,16 @@ export const timedSeat = (
|
|
|
488
513
|
export const timedJudgment = (judgment: JudgmentShape, events: FlowEventsShape): JudgmentShape => ({
|
|
489
514
|
...judgment,
|
|
490
515
|
judge: (input) =>
|
|
491
|
-
timeEffect(
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
516
|
+
timeEffect(
|
|
517
|
+
events,
|
|
518
|
+
judgment.judge(input),
|
|
519
|
+
(ms, failed, role) =>
|
|
520
|
+
Timed.make({
|
|
521
|
+
kind: "model",
|
|
522
|
+
label: role ?? "judgment",
|
|
523
|
+
ms,
|
|
524
|
+
...(failed ? { failed: true } : {})
|
|
525
|
+
}),
|
|
526
|
+
(role) => Began.make({ kind: "model", label: role ?? "judgment" })
|
|
498
527
|
)
|
|
499
528
|
})
|