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.
Files changed (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. 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 z-code's 415-node scope, 2695 `rev-parse` children resolved 1212 distinct (rev, path) pairs.
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 z-code's 415-node scope it forked 1212 `rev-parse` children plus a `cat-file` each, and the batch
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() || `${result.failure ?? 'git'} failure`
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() || `${result.failure ?? 'git'} failure`
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 payload = await reviewPayload(id)
802
- if (!payload) return null
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(payload.branch)
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
- payload: ReviewPayloadValue | ReviewIdentity,
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 = payload.node && specById.has(payload.node) ? specById.get(payload.node)!.title : null
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: payload.node,
1655
- branch: payload.branch,
1656
- title: primary || payload.node || payload.branch || id.slice(0, 8),
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
- // Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
1762
- // multiplies those git children and their parsed indexes by the retained session count, so the shared
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 batch: Promise<void> | null = null
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.startBatch())
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.startBatch())
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
- // A queued summary for this same generation is superseded by the full demand build. If that demand
1955
- // rejects, snapshots must not recreate the cancelled eager work until a later invalidation advances g.
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
- let resolve!: (value: T) => void
1962
- let reject!: (error: unknown) => void
1963
- const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
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
- await Promise.resolve()
1975
- while (this.batch) await this.batch
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 hasPending(): boolean {
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
- for (const { entry, generation, result } of staged) {
2013
- if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size) continue
2014
- if (result.kind === 'unstable') {
2015
- entry.generation++
2016
- entry.phase = 'updating'
2017
- entry.scheduled = null
2018
- entry.demandCancelledGeneration = null
2019
- this.authorize(entry)
2020
- changed = true
2021
- continue
2022
- }
2023
- if (result.kind === 'missing') {
2024
- changed = changed || entry.phase !== 'error'
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 startBatch(): void {
2038
- if (this.batch) return
2039
- if (!this.hasPending()) return
2040
- this.batch = (async () => {
2041
- const demand = this.demandQueue.shift()
2042
- if (demand) {
2043
- try { demand.resolve(await demand.run()) }
2044
- catch (error) { demand.reject(error) }
2045
- return
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 payload = await reviewPayload(id)
2084
- if (!payload) return { kind: 'missing' }
2085
- const wtPath = worktreePathForBranch(payload.branch)
2078
+ const identity = reviewIdentity(id)
2079
+ if (!identity) return { kind: 'missing' }
2080
+ const wtPath = worktreePathForBranch(identity.branch)
2086
2081
  const ctxPath = wtPath ?? repoRoot()
2087
- const before = await sessionEvalContentRevision(ctxPath)
2088
- const cacheKey = `${id}\0${before}`
2089
- const cached = summaryByContent.get(cacheKey)
2090
- if (cached) {
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
- return before === after
2093
- ? { kind: 'stable', revision: after, summary: cached.summary }
2094
- : { kind: 'unstable' }
2095
- }
2096
- const model = await buildSessionEvalModel(id, payload, wtPath)
2097
- const after = await sessionEvalContentRevision(ctxPath)
2098
- if (before !== after) return { kind: 'unstable' }
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, but it still runs through the projection queue. This gives a selected session
2167
- // priority over unrelated queued summaries without opening a second git/build lane.
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, payload, wtPath, pick)
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,