spexcode 0.6.1 → 0.6.2
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/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +171 -28
- package/spec-cli/src/client.ts +112 -13
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +477 -101
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +1 -1
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +344 -66
- package/spec-cli/src/help.ts +22 -10
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +101 -13
- package/spec-cli/src/layout.ts +116 -33
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +10 -5
- package/spec-cli/src/plugin-harness.ts +10 -10
- package/spec-cli/src/reviews.ts +57 -28
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +584 -104
- package/spec-cli/src/uninstall.ts +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-eval/src/cli.ts +6 -1
- package/spec-eval/src/evaltab.ts +7 -5
- package/spec-eval/src/freshness.ts +383 -104
- package/spec-eval/src/scenariofresh.ts +2 -2
- package/spec-eval/src/sessioneval.ts +126 -132
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
|
@@ -161,7 +161,7 @@ function oidAt(root: string, rev: string, path: string): string {
|
|
|
161
161
|
|
|
162
162
|
// @@@ the memo holds SETTLED oids, so concurrent askers need a flight to join - primes run concurrently
|
|
163
163
|
// across the whole scope, and every one of them missed the memo while the first child was still running:
|
|
164
|
-
// measured on
|
|
164
|
+
// measured on adopter-a's 415-node scope, 2695 `rev-parse` children resolved 1212 distinct (rev, path) pairs.
|
|
165
165
|
// `rev:path` under a full sha names an immutable object, so joining answers the same question; the LRU
|
|
166
166
|
// write still happens once, on settle, exactly as the sync path does it.
|
|
167
167
|
const oidFlight = new Map<string, Promise<string>>()
|
|
@@ -189,7 +189,7 @@ async function oidAtAsync(root: string, rev: string, path: string): Promise<stri
|
|
|
189
189
|
// will ask about and TWO children answer them all: `cat-file --batch-check` for the object ids, then
|
|
190
190
|
// `cat-file --batch` for the blobs. The singular form below is one demand's worth of the same thing, kept
|
|
191
191
|
// for callers that genuinely learn their demand one at a time. Priming one at a time is what this replaces:
|
|
192
|
-
// on
|
|
192
|
+
// on adopter-a's 415-node scope it forked 1212 `rev-parse` children plus a `cat-file` each, and the batch
|
|
193
193
|
// answers the identical set — same oids, same blocks, same verdicts, two children.
|
|
194
194
|
export async function primeScenarioBlocks(root: string, demands: readonly { rev: string; path: string }[]): Promise<void> {
|
|
195
195
|
const wanted = new Map<string, { rev: string; path: string }>()
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
driftIndex,
|
|
14
14
|
historyIndex,
|
|
15
15
|
treeTextFiles,
|
|
16
|
+
withEventLedgerBuild,
|
|
17
|
+
withEventLedgerDemand,
|
|
16
18
|
type DriftIndex,
|
|
17
19
|
type DriftPathEvent,
|
|
18
20
|
type ReviewDiffFile,
|
|
@@ -121,7 +123,7 @@ type LoadedSpec = Awaited<ReturnType<typeof loadSpecs>>[number]
|
|
|
121
123
|
async function impactGit(root: string, args: string[], operation: string): Promise<string> {
|
|
122
124
|
const result = await gitTry(['-C', root, ...args])
|
|
123
125
|
if (!result.ok) {
|
|
124
|
-
const detail = result.stderr.trim()
|
|
126
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
|
|
125
127
|
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
126
128
|
}
|
|
127
129
|
return result.stdout
|
|
@@ -130,7 +132,7 @@ async function impactGit(root: string, args: string[], operation: string): Promi
|
|
|
130
132
|
async function impactIndexGit(root: string, indexFile: string, args: string[], operation: string): Promise<string> {
|
|
131
133
|
const result = await gitTry(['-C', root, ...args], { indexFile })
|
|
132
134
|
if (!result.ok) {
|
|
133
|
-
const detail = result.stderr.trim()
|
|
135
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure'
|
|
134
136
|
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
135
137
|
}
|
|
136
138
|
return result.stdout
|
|
@@ -798,14 +800,23 @@ export type ExportModel = {
|
|
|
798
800
|
|
|
799
801
|
// null when no session has that id (route → 404).
|
|
800
802
|
export async function buildExportModel(id: string): Promise<ExportModel | null> {
|
|
801
|
-
const
|
|
802
|
-
if (!
|
|
803
|
+
const identity = reviewIdentity(id)
|
|
804
|
+
if (!identity) return null
|
|
803
805
|
// root EVERYTHING at the SESSION's worktree — readings, freshness, AND the spec tree itself. The
|
|
804
806
|
// worktree's .spec is the branch's pending proposal ([[source-of-truth]]): a node the branch ADDED
|
|
805
807
|
// exists only there, so a trunk-rooted loadSpecs would silently drop it from the model (the 0fca
|
|
806
808
|
// family's node-existence layer). No worktree → the backend checkout, unchanged.
|
|
807
|
-
const wtPath = worktreePathForBranch(
|
|
809
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
808
810
|
const ctxRoot = wtPath ?? repoRoot()
|
|
811
|
+
return withEventLedgerDemand(ctxRoot, async () => {
|
|
812
|
+
const payload = await reviewPayload(id)
|
|
813
|
+
return payload
|
|
814
|
+
? withEventLedgerBuild(ctxRoot, () => buildExportModelInLedger(id, payload, wtPath, ctxRoot))
|
|
815
|
+
: null
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function buildExportModelInLedger(id: string, payload: ReviewPayloadValue, wtPath: string | null, ctxRoot: string): Promise<ExportModel> {
|
|
809
820
|
const specs = await loadSpecs(ctxRoot)
|
|
810
821
|
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
811
822
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
@@ -1425,11 +1436,6 @@ export type SessionEvals = {
|
|
|
1425
1436
|
// rather than publishing a fold over the window. `evalRevision.content` is the stronger identity anyway:
|
|
1426
1437
|
// equal content revision IS the same evaluation cut, so a consumer fencing on it needs no counts.
|
|
1427
1438
|
summary?: SessionEvalSummary
|
|
1428
|
-
// the LIST page's chrome, and only its. A focused build renders no gates strip and no branch counters, so
|
|
1429
|
-
// it does not buy them — see `order` below for what marks such a model partial.
|
|
1430
|
-
gates?: ExportGate[]
|
|
1431
|
-
ahead?: number
|
|
1432
|
-
dirtyNonRuntime?: number
|
|
1433
1439
|
evalRevision: SessionEvalRevision
|
|
1434
1440
|
// present ONLY on a focused build: the whole population's identity+sequence facts, because `nodes` then
|
|
1435
1441
|
// holds just the few the response will render. Its presence is exactly what marks a model as PARTIAL —
|
|
@@ -1618,13 +1624,23 @@ type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
|
|
|
1618
1624
|
|
|
1619
1625
|
async function buildSessionEvalModel(
|
|
1620
1626
|
id: string,
|
|
1621
|
-
|
|
1627
|
+
identity: ReviewIdentity,
|
|
1622
1628
|
wtPath: string | null,
|
|
1623
1629
|
pick?: SessionEvalFocus,
|
|
1624
1630
|
): Promise<SessionEvalModel> {
|
|
1625
1631
|
// spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
|
|
1626
1632
|
// in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
|
|
1627
1633
|
const ctxRoot = wtPath ?? repoRoot()
|
|
1634
|
+
return withEventLedgerBuild(ctxRoot, () => buildSessionEvalModelInLedger(id, identity, wtPath, pick, ctxRoot))
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function buildSessionEvalModelInLedger(
|
|
1638
|
+
id: string,
|
|
1639
|
+
identity: ReviewIdentity,
|
|
1640
|
+
wtPath: string | null,
|
|
1641
|
+
pick: SessionEvalFocus | undefined,
|
|
1642
|
+
ctxRoot: string,
|
|
1643
|
+
): Promise<SessionEvalModel> {
|
|
1628
1644
|
const specs = await loadSpecs(ctxRoot)
|
|
1629
1645
|
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
1630
1646
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
@@ -1647,14 +1663,12 @@ async function buildSessionEvalModel(
|
|
|
1647
1663
|
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
|
|
1648
1664
|
|| (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
|
|
1649
1665
|
|
|
1650
|
-
const primary =
|
|
1651
|
-
const full = 'gates' in payload ? payload : null
|
|
1666
|
+
const primary = identity.node && specById.has(identity.node) ? specById.get(identity.node)!.title : null
|
|
1652
1667
|
return {
|
|
1653
1668
|
id,
|
|
1654
|
-
node:
|
|
1655
|
-
branch:
|
|
1656
|
-
title: primary ||
|
|
1657
|
-
...(full ? { ahead: full.ahead, dirtyNonRuntime: full.dirtyNonRuntime, gates: gateRows(full) } : {}),
|
|
1669
|
+
node: identity.node,
|
|
1670
|
+
branch: identity.branch,
|
|
1671
|
+
title: primary || identity.node || identity.branch || id.slice(0, 8),
|
|
1658
1672
|
nodes,
|
|
1659
1673
|
impact,
|
|
1660
1674
|
...(order ? { order } : {}),
|
|
@@ -1728,13 +1742,6 @@ type SummaryBuildResult =
|
|
|
1728
1742
|
|
|
1729
1743
|
export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
|
|
1730
1744
|
|
|
1731
|
-
type DemandJob<T> = {
|
|
1732
|
-
id: string
|
|
1733
|
-
run: () => Promise<T>
|
|
1734
|
-
resolve: (value: T) => void
|
|
1735
|
-
reject: (error: unknown) => void
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
1745
|
type ProjectionEntry = {
|
|
1739
1746
|
id: string
|
|
1740
1747
|
path: string
|
|
@@ -1744,6 +1751,7 @@ type ProjectionEntry = {
|
|
|
1744
1751
|
current?: { generation: number; revision: string; value: SessionEvalSummary }
|
|
1745
1752
|
scheduled: number | null
|
|
1746
1753
|
running: number | null
|
|
1754
|
+
flight: Promise<void> | null
|
|
1747
1755
|
demandCancelledGeneration: number | null
|
|
1748
1756
|
observerHolds: Set<string>
|
|
1749
1757
|
}
|
|
@@ -1758,27 +1766,16 @@ type ProjectionCohortRow = Pick<StagedProjection, 'entry' | 'generation'>
|
|
|
1758
1766
|
|
|
1759
1767
|
type ProjectionTarget = 'all' | { id?: string; path?: string }
|
|
1760
1768
|
|
|
1761
|
-
//
|
|
1762
|
-
//
|
|
1763
|
-
// projection queue has one bounded capacity for every project. A deployment can tune the capacity, but the
|
|
1764
|
-
// default is deliberately serial: board assembly remains responsive and memory has a natural settle point.
|
|
1765
|
-
const configuredProjectionConcurrency = Number(process.env.SPEXCODE_SESSION_EVAL_CONCURRENCY || 1)
|
|
1766
|
-
const PROJECTION_CONCURRENCY = Number.isFinite(configuredProjectionConcurrency)
|
|
1767
|
-
? Math.max(1, Math.floor(configuredProjectionConcurrency))
|
|
1768
|
-
: 1
|
|
1769
|
-
|
|
1770
|
-
// Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
|
|
1771
|
-
// entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
|
|
1772
|
-
// `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
|
|
1769
|
+
// Pure generation coordinator around an injected stable builder. Sessions have independent evaluation cuts,
|
|
1770
|
+
// so an entry launches and publishes independently; generation/revision fences remain per entry.
|
|
1773
1771
|
export class SessionEvalProjectionCache {
|
|
1774
1772
|
readonly epoch: string
|
|
1775
1773
|
private readonly entries = new Map<string, ProjectionEntry>()
|
|
1776
1774
|
private readonly observerHolds = new Map<string, ProjectionTarget>()
|
|
1777
1775
|
private readonly observerWaiters = new Set<() => void>()
|
|
1778
|
-
private
|
|
1776
|
+
private readonly flights = new Set<Promise<void>>()
|
|
1779
1777
|
private notify: () => void
|
|
1780
1778
|
private precompute: boolean
|
|
1781
|
-
private readonly demandQueue: DemandJob<any>[] = []
|
|
1782
1779
|
private readonly demands = new Map<string, Promise<unknown>>()
|
|
1783
1780
|
|
|
1784
1781
|
constructor(
|
|
@@ -1806,7 +1803,7 @@ export class SessionEvalProjectionCache {
|
|
|
1806
1803
|
}
|
|
1807
1804
|
this.authorize(entry)
|
|
1808
1805
|
}
|
|
1809
|
-
if (enabled) queueMicrotask(() => this.
|
|
1806
|
+
if (enabled) queueMicrotask(() => this.startScheduled())
|
|
1810
1807
|
}
|
|
1811
1808
|
|
|
1812
1809
|
private authorize(entry: ProjectionEntry): void {
|
|
@@ -1831,6 +1828,7 @@ export class SessionEvalProjectionCache {
|
|
|
1831
1828
|
phase: 'loading',
|
|
1832
1829
|
scheduled: null,
|
|
1833
1830
|
running: null,
|
|
1831
|
+
flight: null,
|
|
1834
1832
|
demandCancelledGeneration: null,
|
|
1835
1833
|
observerHolds: new Set(),
|
|
1836
1834
|
}
|
|
@@ -1854,6 +1852,7 @@ export class SessionEvalProjectionCache {
|
|
|
1854
1852
|
phase: 'loading',
|
|
1855
1853
|
scheduled: null,
|
|
1856
1854
|
running: null,
|
|
1855
|
+
flight: null,
|
|
1857
1856
|
demandCancelledGeneration: null,
|
|
1858
1857
|
observerHolds: new Set(),
|
|
1859
1858
|
}
|
|
@@ -1873,7 +1872,7 @@ export class SessionEvalProjectionCache {
|
|
|
1873
1872
|
this.authorize(entry)
|
|
1874
1873
|
out.set(session.id, this.project(entry))
|
|
1875
1874
|
}
|
|
1876
|
-
queueMicrotask(() => this.
|
|
1875
|
+
queueMicrotask(() => this.startScheduled())
|
|
1877
1876
|
return out
|
|
1878
1877
|
}
|
|
1879
1878
|
|
|
@@ -1951,28 +1950,31 @@ export class SessionEvalProjectionCache {
|
|
|
1951
1950
|
const existing = this.demands.get(id)
|
|
1952
1951
|
if (existing) return existing as Promise<T>
|
|
1953
1952
|
const entry = this.ensureEntry(id, path)
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
1956
|
-
// A running summary is left alone; the priority job waits for it to settle before taking the slot.
|
|
1953
|
+
// An unstarted summary is superseded by the full demand build. A running summary stays the one flight for
|
|
1954
|
+
// this entry; demand waits for it only, never for unrelated session work.
|
|
1957
1955
|
if (entry.running == null && entry.scheduled === entry.generation) {
|
|
1958
1956
|
entry.scheduled = null
|
|
1959
1957
|
entry.demandCancelledGeneration = entry.generation
|
|
1960
1958
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1959
|
+
const priorFlight = entry.flight
|
|
1960
|
+
const promise = (async () => {
|
|
1961
|
+
if (priorFlight) await priorFlight
|
|
1962
|
+
return run()
|
|
1963
|
+
})()
|
|
1964
1964
|
this.demands.set(id, promise)
|
|
1965
|
-
this.demandQueue.push({ id, run, resolve, reject })
|
|
1966
1965
|
promise.finally(() => {
|
|
1967
1966
|
if (this.demands.get(id) === promise) this.demands.delete(id)
|
|
1968
1967
|
}).catch(() => {})
|
|
1969
|
-
queueMicrotask(() => this.startBatch())
|
|
1970
1968
|
return promise
|
|
1971
1969
|
}
|
|
1972
1970
|
|
|
1973
1971
|
async idle(): Promise<void> {
|
|
1974
|
-
|
|
1975
|
-
|
|
1972
|
+
for (;;) {
|
|
1973
|
+
await Promise.resolve()
|
|
1974
|
+
const pending = [...this.flights, ...this.demands.values()]
|
|
1975
|
+
if (!pending.length) return
|
|
1976
|
+
await Promise.allSettled(pending)
|
|
1977
|
+
}
|
|
1976
1978
|
}
|
|
1977
1979
|
|
|
1978
1980
|
accept(id: string, generation: number, revision: string, value: SessionEvalSummary): boolean {
|
|
@@ -2002,64 +2004,57 @@ export class SessionEvalProjectionCache {
|
|
|
2002
2004
|
return target === 'all' || target.id === entry.id || target.path === entry.path
|
|
2003
2005
|
}
|
|
2004
2006
|
|
|
2005
|
-
private
|
|
2006
|
-
return this.demandQueue.length > 0
|
|
2007
|
-
|| [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
private publishCohort(staged: StagedProjection[]): boolean {
|
|
2007
|
+
private publish(staged: StagedProjection): boolean {
|
|
2011
2008
|
let changed = false
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
entry.phase = 'error'
|
|
2026
|
-
entry.scheduled = null
|
|
2027
|
-
continue
|
|
2028
|
-
}
|
|
2029
|
-
changed = changed || entry.phase !== 'ready' || entry.current?.revision !== result.revision
|
|
2030
|
-
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
2031
|
-
entry.phase = 'ready'
|
|
2009
|
+
const { entry, generation, result } = staged
|
|
2010
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) return false
|
|
2011
|
+
if (result.kind === 'unstable') {
|
|
2012
|
+
entry.generation++
|
|
2013
|
+
entry.phase = 'updating'
|
|
2014
|
+
entry.scheduled = null
|
|
2015
|
+
entry.demandCancelledGeneration = null
|
|
2016
|
+
this.authorize(entry)
|
|
2017
|
+
return true
|
|
2018
|
+
}
|
|
2019
|
+
if (result.kind === 'missing') {
|
|
2020
|
+
changed = entry.phase !== 'error'
|
|
2021
|
+
entry.phase = 'error'
|
|
2032
2022
|
entry.scheduled = null
|
|
2023
|
+
return changed
|
|
2033
2024
|
}
|
|
2025
|
+
changed = entry.phase !== 'ready' || entry.current?.revision !== result.revision
|
|
2026
|
+
entry.current = { generation, revision: result.revision, value: result.summary }
|
|
2027
|
+
entry.phase = 'ready'
|
|
2028
|
+
entry.scheduled = null
|
|
2034
2029
|
return changed
|
|
2035
2030
|
}
|
|
2036
2031
|
|
|
2037
|
-
private
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
// Freeze this finite cohort. Inputs that arrive later remain scheduled for the next batch, so a
|
|
2048
|
-
// busy stream cannot keep a completed cohort unpublished forever.
|
|
2049
|
-
const cohort = [...this.entries.values()]
|
|
2050
|
-
.filter((entry) => entry.scheduled != null && entry.running == null)
|
|
2051
|
-
.map((entry) => ({ entry, generation: entry.scheduled! }))
|
|
2052
|
-
const staged: StagedProjection[] = []
|
|
2053
|
-
for (let offset = 0; offset < cohort.length && !this.demandQueue.length; offset += PROJECTION_CONCURRENCY) {
|
|
2054
|
-
const chunk = cohort.slice(offset, offset + PROJECTION_CONCURRENCY)
|
|
2055
|
-
const results = await Promise.all(chunk.map((row) => this.runEntry(row)))
|
|
2056
|
-
for (const result of results) if (result) staged.push(result)
|
|
2057
|
-
}
|
|
2058
|
-
if (this.publishCohort(staged)) this.notify()
|
|
2059
|
-
})().finally(() => {
|
|
2060
|
-
this.batch = null
|
|
2061
|
-
if (this.hasPending()) this.startBatch()
|
|
2032
|
+
private startScheduled(): void {
|
|
2033
|
+
for (const entry of this.entries.values()) {
|
|
2034
|
+
if (entry.scheduled == null || entry.running != null) continue
|
|
2035
|
+
this.startEntry({ entry, generation: entry.scheduled })
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
private startEntry(row: ProjectionCohortRow): void {
|
|
2040
|
+
const flight = this.runEntry(row).then((staged) => {
|
|
2041
|
+
if (staged && this.publish(staged)) this.notify()
|
|
2062
2042
|
})
|
|
2043
|
+
row.entry.flight = flight
|
|
2044
|
+
this.flights.add(flight)
|
|
2045
|
+
void flight.then(
|
|
2046
|
+
() => {
|
|
2047
|
+
this.flights.delete(flight)
|
|
2048
|
+
if (row.entry.flight === flight) row.entry.flight = null
|
|
2049
|
+
queueMicrotask(() => this.startScheduled())
|
|
2050
|
+
},
|
|
2051
|
+
(error) => {
|
|
2052
|
+
this.flights.delete(flight)
|
|
2053
|
+
if (row.entry.flight === flight) row.entry.flight = null
|
|
2054
|
+
console.warn(`spec-eval: session summary runner failed for ${row.entry.id}: ${error instanceof Error ? error.message : String(error)}`)
|
|
2055
|
+
queueMicrotask(() => this.startScheduled())
|
|
2056
|
+
},
|
|
2057
|
+
)
|
|
2063
2058
|
}
|
|
2064
2059
|
|
|
2065
2060
|
private async runEntry({ entry, generation }: ProjectionCohortRow): Promise<StagedProjection | null> {
|
|
@@ -2080,27 +2075,29 @@ export class SessionEvalProjectionCache {
|
|
|
2080
2075
|
}
|
|
2081
2076
|
|
|
2082
2077
|
async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBuildResult> {
|
|
2083
|
-
const
|
|
2084
|
-
if (!
|
|
2085
|
-
const wtPath = worktreePathForBranch(
|
|
2078
|
+
const identity = reviewIdentity(id)
|
|
2079
|
+
if (!identity) return { kind: 'missing' }
|
|
2080
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
2086
2081
|
const ctxPath = wtPath ?? repoRoot()
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2082
|
+
return withEventLedgerDemand(ctxPath, async () => {
|
|
2083
|
+
const before = await sessionEvalContentRevision(ctxPath)
|
|
2084
|
+
const cacheKey = `${id}\0${before}`
|
|
2085
|
+
const cached = summaryByContent.get(cacheKey)
|
|
2086
|
+
if (cached) {
|
|
2087
|
+
const after = await sessionEvalContentRevision(ctxPath)
|
|
2088
|
+
return before === after
|
|
2089
|
+
? { kind: 'stable', revision: after, summary: cached.summary }
|
|
2090
|
+
: { kind: 'unstable' }
|
|
2091
|
+
}
|
|
2092
|
+
const model = await buildSessionEvalModel(id, identity, wtPath)
|
|
2091
2093
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const summary = sessionEvalSummary(model.nodes)
|
|
2100
|
-
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
2101
|
-
// This fold IS the demand's fold, so it deposits the model too and a later open replays it.
|
|
2102
|
-
depositStableCut(id, after, { summary, model })
|
|
2103
|
-
return { kind: 'stable', revision: after, summary }
|
|
2094
|
+
if (before !== after) return { kind: 'unstable' }
|
|
2095
|
+
const summary = sessionEvalSummary(model.nodes)
|
|
2096
|
+
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
2097
|
+
// This fold IS the demand's fold, so it deposits the model too and a later open replays it.
|
|
2098
|
+
depositStableCut(id, after, { summary, model })
|
|
2099
|
+
return { kind: 'stable', revision: after, summary }
|
|
2100
|
+
})
|
|
2104
2101
|
}
|
|
2105
2102
|
|
|
2106
2103
|
// ONE content-addressed cut per session, keyed by id + content revision, carrying the summary the graph
|
|
@@ -2163,16 +2160,14 @@ export async function awaitSessionEvalProjectionIdle(): Promise<void> { await pr
|
|
|
2163
2160
|
// it never deposits — but it still prefers a cached FULL model when one exists, because a complete answer
|
|
2164
2161
|
// already paid for beats a cheap incomplete one.
|
|
2165
2162
|
export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Promise<SessionEvals | null> {
|
|
2166
|
-
// A full model is demand-only
|
|
2167
|
-
//
|
|
2163
|
+
// A full model is demand-only. It joins its session's current summary flight, but unrelated summaries never
|
|
2164
|
+
// gate this demand; a cached stable cut then replays without re-deriving Git state.
|
|
2165
|
+
const identity = reviewIdentity(id)
|
|
2166
|
+
if (!identity) return null
|
|
2167
|
+
const wtPath = worktreePathForBranch(identity.branch)
|
|
2168
|
+
const ctxPath = wtPath ?? repoRoot()
|
|
2168
2169
|
for (;;) {
|
|
2169
|
-
const attempt = await projectionCache.demand(id, '', async () => {
|
|
2170
|
-
// a focused open renders no gates strip, so it reads the session's IDENTITY (a free store read)
|
|
2171
|
-
// instead of its review payload (ahead count + dirty scan + merge-tree conflict probe).
|
|
2172
|
-
const payload = pick ? reviewIdentity(id) : await reviewPayload(id)
|
|
2173
|
-
if (!payload) return { kind: 'missing' as const }
|
|
2174
|
-
const wtPath = worktreePathForBranch(payload.branch)
|
|
2175
|
-
const ctxPath = wtPath ?? repoRoot()
|
|
2170
|
+
const attempt = await projectionCache.demand(id, '', () => withEventLedgerDemand(ctxPath, async () => {
|
|
2176
2171
|
const known = projectionCache.get(id)
|
|
2177
2172
|
const generation = known?.generation ?? 0
|
|
2178
2173
|
await awaitObservableInputs(id, ctxPath)
|
|
@@ -2194,7 +2189,7 @@ export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Pr
|
|
|
2194
2189
|
if (before !== settled) projectionCache.invalidate({ id })
|
|
2195
2190
|
return { kind: 'retry' as const }
|
|
2196
2191
|
}
|
|
2197
|
-
const model = await buildSessionEvalModel(id,
|
|
2192
|
+
const model = await buildSessionEvalModel(id, identity, wtPath, pick)
|
|
2198
2193
|
const after = await sessionEvalContentRevision(ctxPath)
|
|
2199
2194
|
const current = projectionCache.get(id)
|
|
2200
2195
|
if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
|
|
@@ -2213,8 +2208,7 @@ export async function buildSessionEvals(id: string, pick?: SessionEvalFocus): Pr
|
|
|
2213
2208
|
depositStableCut(id, after, { summary, model })
|
|
2214
2209
|
if (current) projectionCache.accept(id, generation, after, summary)
|
|
2215
2210
|
return { kind: 'ready' as const, model, summary, generation, revision: after }
|
|
2216
|
-
})
|
|
2217
|
-
if (attempt.kind === 'missing') return null
|
|
2211
|
+
}))
|
|
2218
2212
|
if (attempt.kind === 'retry') continue
|
|
2219
2213
|
return {
|
|
2220
2214
|
...attempt.model,
|