@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -576,6 +576,8 @@ export interface CacheLayer {
576
576
  * `hasRemote`.
577
577
  */
578
578
  probeBeforeUpload?(hashes: Promise<readonly string[]>): void
579
+ /** `taskId`'s key is `hash`: a scoped run picks each remote key by its task (`BranchScope`). */
580
+ noteKey?(taskId: string, hash: string): void
579
581
  /**
580
582
  * Resolve once every background write-through upload settles. The
581
583
  * orchestrator awaits it before `close()` — an upload reading layer state
@@ -698,6 +700,8 @@ export interface CacheLayer {
698
700
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
699
701
  projectDir: string
700
702
  outputFiles: string[]
703
+ /** The empty directories under the outputs (absolute), kept so a hit makes them. */
704
+ outputDirs?: string[]
701
705
  /**
702
706
  * Resolved `outputs.workspaceFiles` (absolute paths) + the root
703
707
  * they're relative to. Packed under `workspace-outputs/<rel>`.
@@ -714,12 +718,6 @@ export interface CacheLayer {
714
718
  * Only computed on the miss/save path — never on a hit.
715
719
  */
716
720
  inputComponents?: readonly TaskInputRow[]
717
- /**
718
- * For a sandboxed task: whether it could read a canonical path. A
719
- * symlinked output is packed as its target's bytes, outside the
720
- * sandbox, so one whose target the task could not read is refused.
721
- */
722
- readable?: ((real: string) => boolean) | undefined
723
721
  }): Promise<void>
724
722
  /**
725
723
  * Adopt an artifact produced elsewhere — the remote-hit path. Streams
@@ -36,6 +36,55 @@ import type {
36
36
  } from './cache.js'
37
37
  import type { OutputDirRow } from './cache.js'
38
38
  import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
39
+ import type { BranchScope } from './scoped-cache.js'
40
+ import type { PluginSecrets } from '../util/index.js'
41
+
42
+ const STOPPED: unique symbol = Symbol('stopped')
43
+
44
+ /**
45
+ * A response body that errors once the run stops, so the ingest reading it
46
+ * fails at once, removes its temp, and releases the connection; the cache
47
+ * then closes under no write.
48
+ */
49
+ function endsOnStop(body: Blob | Response, stopped: Promise<typeof STOPPED>): Blob | Response {
50
+ if (body instanceof Blob || body.body === null) return body
51
+ const reader = body.body.getReader()
52
+ let stop = false
53
+ // Once, not raced per chunk: a cancel ends the pending read as `done`,
54
+ // which the flag turns into an error rather than a short body.
55
+ void stopped.then(() => {
56
+ stop = true
57
+ void reader.cancel().catch(() => undefined)
58
+ })
59
+ const stream = new ReadableStream<Uint8Array>({
60
+ async pull(controller) {
61
+ const next = await reader.read()
62
+ if (stop) controller.error(new Error('the run was stopped'))
63
+ else if (next.done) controller.close()
64
+ else controller.enqueue(next.value)
65
+ },
66
+ cancel: (reason) => reader.cancel(reason),
67
+ })
68
+ return new Response(stream, { headers: body.headers })
69
+ }
70
+
71
+ /** What a remote's `open` is handed: the run's CI verdict and its plugin's keychain. */
72
+ export interface RemoteOpenContext extends PluginSecrets {
73
+ /** The run is on CI: nobody may be asked. */
74
+ readonly ci: boolean
75
+ /** One line naming the plugin, for a fact that leaves the remote on. */
76
+ warn(message: string): void
77
+ }
78
+
79
+ /**
80
+ * Where a remote call's key lives: `scope` names the branch scope a scoped
81
+ * key was derived in (`scopedKey`), absent for a task's own key. The key
82
+ * alone is enough; a backend that keeps one store per scope may use it to
83
+ * go straight to that store. turboCache(), nxCache() and vx-reapi ignore it.
84
+ */
85
+ export interface RemoteKey {
86
+ readonly scope?: string
87
+ }
39
88
 
40
89
  /**
41
90
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -62,17 +111,28 @@ export interface RemoteCacheLayer {
62
111
  * dropped before it is printed.
63
112
  */
64
113
  readonly endpoint?: string
114
+ /**
115
+ * Optional: make the layer ready (read a credential, ask for one).
116
+ * Called once per run, in declared order, before the status line and the
117
+ * first task, and only when the run's policy lets this remote read or
118
+ * write: a `--cache-policy=remote:` run, `--no-cache` and a plan
119
+ * (`--dry`, `vx show`) never call it, so the other methods may be asked
120
+ * without it. A throw is a warning naming the plugin and turns the
121
+ * remote off for the run; the run goes on. `ctx.secret` reads the
122
+ * plugin's keychain and may ask on the terminal.
123
+ */
124
+ open?(ctx: RemoteOpenContext): Promise<void>
65
125
  /** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
66
- has(hash: string): Promise<boolean>
126
+ has(hash: string, at?: RemoteKey): Promise<boolean>
67
127
  /**
68
128
  * Optional batch existence probe: given N hashes, return the subset stored
69
129
  * remotely in ONE round-trip. Its presence says the wire can batch: a run
70
130
  * asks it once for its keys, `--dry` before its per-key answers, and
71
131
  * `--force` in the background to skip uploads of held keys. Without it a
72
132
  * run GETs each key directly (no `has`). Never throws for control flow:
73
- * `null` means "no batch info; use per-hash".
133
+ * `null` means "no batch info; use per-hash". `at[i]` is `hashes[i]`'s.
74
134
  */
75
- hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
135
+ hasMany?(hashes: readonly string[], at?: readonly RemoteKey[]): Promise<Set<string> | null>
76
136
  /**
77
137
  * Fetch an artifact; `null` = miss. `body` is read once, by core,
78
138
  * straight into the local cache — an HTTP wire returns its `fetch`
@@ -82,7 +142,10 @@ export interface RemoteCacheLayer {
82
142
  * nothing and the read is a miss, so a layer can check bytes as they pass
83
143
  * (a digest, a signature) and refuse at the end.
84
144
  */
85
- get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
145
+ get(
146
+ hash: string,
147
+ at?: RemoteKey,
148
+ ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
86
149
  /**
87
150
  * Store an artifact (fire-and-forget from LayeredCache's perspective).
88
151
  * `body` is file-backed (`Bun.file`) when the local store holds the
@@ -91,7 +154,7 @@ export interface RemoteCacheLayer {
91
154
  * key the remote holds keeps the stored bytes. Core never asks `has`
92
155
  * first; a wire that would overwrite checks inside.
93
156
  */
94
- put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
157
+ put(hash: string, body: Blob, meta: { durationMs: number } & RemoteKey): Promise<void>
95
158
  }
96
159
 
97
160
  export class LayeredCache implements CacheLayer {
@@ -99,7 +162,6 @@ export class LayeredCache implements CacheLayer {
99
162
  readonly hasRemote = true
100
163
 
101
164
  private readonly lanes: readonly RemoteLane[]
102
- private readonly readable: readonly RemoteLane[]
103
165
 
104
166
  /**
105
167
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
@@ -128,9 +190,21 @@ export class LayeredCache implements CacheLayer {
128
190
  constructor(
129
191
  readonly local: Cache,
130
192
  remotes: readonly ResolvedRemote[],
193
+ /** The run's branch scope: it picks each remote key; the local store never sees it. */
194
+ private readonly branch?: BranchScope,
131
195
  ) {
132
- this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic))
133
- this.readable = this.lanes.filter((l) => l.read)
196
+ this.lanes = remotes.map(
197
+ (r) => new RemoteLane(r, local.traffic, branch?.remote(r.remote) ?? r.remote),
198
+ )
199
+ }
200
+
201
+ noteKey(taskId: string, hash: string): void {
202
+ this.branch?.noteKey(taskId, hash)
203
+ }
204
+
205
+ // Asked, not kept: a remote whose `open` failed leaves the run after this is built.
206
+ private get readable(): RemoteLane[] {
207
+ return this.lanes.filter((l) => l.read)
134
208
  }
135
209
 
136
210
  key(input: CacheKeyInput): Promise<string> {
@@ -280,7 +354,17 @@ export class LayeredCache implements CacheLayer {
280
354
 
281
355
  private async pullFrom(lane: RemoteLane, hash: string, ctx?: CacheGetContext): Promise<boolean> {
282
356
  const fetchStart = performance.now()
283
- const remoteBody = await lane.get(hash)
357
+ // A stopped run waits on no read, as on no upload: a lookup or download
358
+ // in flight held a Ctrl-C until its last byte (M, 2026-10-17). The
359
+ // plugin's request is left to the exit, and its body is cancelled.
360
+ const stopped = lane.whenStopped.then((): typeof STOPPED => STOPPED)
361
+ const pending = lane.get(hash)
362
+ const remoteBody = await Promise.race([pending, stopped])
363
+ if (remoteBody === STOPPED) {
364
+ // A late answer's body would hold its connection to the exit.
365
+ void pending.then((r) => (r?.body instanceof Response ? r.body.body?.cancel() : undefined))
366
+ return false
367
+ }
284
368
  if (remoteBody === null) return false
285
369
 
286
370
  // Ingest the remote body into local using the caller-supplied
@@ -301,7 +385,9 @@ export class LayeredCache implements CacheLayer {
301
385
  ...(ctx?.outputs !== undefined ? { outputs: ctx.outputs } : {}),
302
386
  }
303
387
  try {
304
- await this.local.ingest(hash, remoteBody.body, meta)
388
+ const ingest = this.local.ingest(hash, endsOnStop(remoteBody.body, stopped), meta)
389
+ ingest.catch(() => undefined)
390
+ if ((await Promise.race([ingest, stopped])) === STOPPED) return false
305
391
  } catch (err) {
306
392
  // The bytes came off the network — a corrupt/truncated remote
307
393
  // artifact must degrade to a cache miss (task re-executes), not
@@ -17,7 +17,8 @@ const FILE_PREFIX_MTIME = -2
17
17
 
18
18
  import type { Database } from 'bun:sqlite'
19
19
  import { inHashes, lazyStatement } from './schema.js'
20
- import { lstatSync, statSync } from 'node:fs'
20
+ import { modeMatches } from './umask.js'
21
+ import { lstatSync, readlinkSync, statSync } from 'node:fs'
21
22
  import { lstat, readdir } from 'node:fs/promises'
22
23
  import path from 'node:path'
23
24
  import {
@@ -30,6 +31,19 @@ import {
30
31
  WORKSPACE_OUTPUT_PREFIX,
31
32
  } from './layer.js'
32
33
 
34
+ /**
35
+ * The mode a symlink output's row carries (S_IFLNK): its size is the
36
+ * target's byte length and its mtime the target's CRC-32.
37
+ */
38
+ export const LINK_MODE = 0o120000
39
+
40
+ /** A link row is current while a link with that target stands there; never followed. */
41
+ function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
42
+ if (!lstatSync(abs).isSymbolicLink()) return false
43
+ const to = readlinkSync(abs, 'buffer')
44
+ return to.byteLength === e.size && Bun.hash.crc32(to) === e.mtimeMs
45
+ }
46
+
33
47
  export class OutputIndex {
34
48
  private readonly insertOutputFile: ReturnType<Database['prepare']>
35
49
  private readonly deleteOutputFiles: ReturnType<Database['prepare']>
@@ -206,10 +220,13 @@ export class OutputIndex {
206
220
  // graph stage 100 → 54 ms, the whole process 422 → 368 ms.
207
221
  const results = expected.map((e) => {
208
222
  try {
223
+ if (e.mode === LINK_MODE) return linkCurrent(path.join(projectDir, e.path), e)
209
224
  const s = statSync(path.join(projectDir, e.path))
225
+ // An empty output directory: current while a directory stands there.
226
+ if (e.path.endsWith('/')) return s.isDirectory()
210
227
  return (
211
228
  s.size === e.size &&
212
- (s.mode & 0o777) === (e.mode & 0o777) &&
229
+ modeMatches(s.mode, e.mode) &&
213
230
  // MILLISECOND comparison (sub-ms tolerance for the float
214
231
  // round-trip through utimes). Save rows carry stat-ms and
215
232
  // restoreOutputs re-syncs restored files to the row value,
@@ -324,6 +341,7 @@ export class OutputIndex {
324
341
  const rows = this.rowsOf(hash)
325
342
  const stamps: Array<[string, number, number]> = []
326
343
  for (const e of rows) {
344
+ if (e.path.endsWith('/') || e.mode === LINK_MODE) continue
327
345
  const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
328
346
  const abs = ws
329
347
  ? path.join(workspaceRoot, e.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
@@ -332,7 +350,7 @@ export class OutputIndex {
332
350
  const s = statSync(abs)
333
351
  if (
334
352
  s.size === e.size &&
335
- (s.mode & 0o777) === (e.mode & 0o777) &&
353
+ modeMatches(s.mode, e.mode) &&
336
354
  Math.abs(s.mtimeMs - e.mtimeMs) < 1
337
355
  ) {
338
356
  stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
@@ -415,10 +433,13 @@ export class OutputIndex {
415
433
  const results = rows.map((r) => {
416
434
  let st
417
435
  try {
418
- st = lstatSync(path.join(projectDir, r.path))
436
+ // No throw for a missing dir: every restore after a clean asks
437
+ // about one, and an exception per hit cost more than the stat.
438
+ st = lstatSync(path.join(projectDir, r.path), { throwIfNoEntry: false })
419
439
  } catch {
420
440
  return r.mtimeMs === ABSENT_DIR_MTIME
421
441
  }
442
+ if (st === undefined) return r.mtimeMs === ABSENT_DIR_MTIME
422
443
  if (r.mtimeMs === ABSENT_DIR_MTIME) return false
423
444
  if (r.mtimeMs === FILE_PREFIX_MTIME) return st.isFile()
424
445
  return st.isDirectory() && Math.abs(st.mtimeMs - r.mtimeMs) < 1
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Database } from 'bun:sqlite'
10
10
  import { xxh3hex } from '../util/index.js'
11
+ import type { RemoteKey } from './layered-cache.js'
11
12
  import type { CachePolicy } from './policy.js'
12
13
  import type { ResolvedRemote } from './remote-lane.js'
13
14
  import { scopedKey } from './scoped-cache.js'
@@ -85,13 +86,17 @@ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint
85
86
  }
86
87
 
87
88
  /**
88
- * `plugin`'s view of the cache. A scoped run reads the default key, then
89
- * the scope's, and writes only the scope's, as tasks do (`ScopedCache`).
89
+ * `plugin`'s view of the cache. The local store never knows a scope
90
+ * (owner, 2026-10-17): it keeps every entry under its name's key. A scoped
91
+ * run reads a remote under the default key, then the scope's, and writes
92
+ * a remote only under the scope's.
90
93
  */
91
94
  export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache {
92
95
  const { db, policy, scope } = host
93
96
  const readers = host.remotes.filter((r) => r.read)
94
97
  const writers = host.remotes.filter((r) => r.write)
98
+ const at: RemoteKey = scope === undefined ? {} : { scope }
99
+ const atOf = (key: string, name: string): RemoteKey => (key === entryKey(plugin, name) ? {} : at)
95
100
  const reads = (name: string): string[] => {
96
101
  const key = entryKey(plugin, name)
97
102
  return scope === undefined ? [key] : [key, scopedKey(scope, key)]
@@ -139,19 +144,18 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
139
144
 
140
145
  return {
141
146
  async get(name) {
147
+ const local = entryKey(plugin, name)
148
+ const hit = localGet(local)
149
+ if (hit !== null) return hit
142
150
  const keys = reads(name)
143
- for (const key of keys) {
144
- const hit = localGet(key)
145
- if (hit !== null) return hit
146
- }
147
151
  for (const { remote } of readers) {
148
152
  for (const key of keys) {
149
153
  try {
150
- const got = await remote.get(key)
154
+ const got = await remote.get(key, atOf(key, name))
151
155
  if (got === null) continue
152
156
  const bytes = await capped(got.body)
153
157
  if (bytes === null) continue
154
- if (policy.localWrite) localPut(key, bytes)
158
+ if (policy.localWrite) localPut(local, bytes)
155
159
  return bytes
156
160
  } catch {
157
161
  // a failing remote is a miss there
@@ -164,10 +168,10 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
164
168
  async put(name, bytes) {
165
169
  if (bytes.byteLength === 0 || bytes.byteLength > PLUGIN_ENTRY_MAX_BYTES) return
166
170
  const key = writeKey(name)
167
- if (policy.localWrite) localPut(key, bytes)
171
+ if (policy.localWrite) localPut(entryKey(plugin, name), bytes)
168
172
  for (const { remote } of writers) {
169
173
  try {
170
- await remote.put(key, new Blob([bytes]), { durationMs: 0 })
174
+ await remote.put(key, new Blob([bytes]), { durationMs: 0, ...at })
171
175
  } catch {
172
176
  // the other layers still keep it
173
177
  }
@@ -175,13 +179,13 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
175
179
  },
176
180
 
177
181
  async has(name) {
182
+ if (localHas([entryKey(plugin, name)]).size > 0) return true
178
183
  const keys = reads(name)
179
- if (localHas(keys).size > 0) return true
180
184
  let answered = policy.localRead
181
185
  for (const { remote } of readers) {
182
186
  for (const key of keys) {
183
187
  try {
184
- if (await remote.has(key)) return true
188
+ if (await remote.has(key, atOf(key, name))) return true
185
189
  answered = true
186
190
  } catch {
187
191
  // no answer from this remote
@@ -196,13 +200,16 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
196
200
  for (const name of names) for (const key of reads(name)) byKey.set(key, name)
197
201
  const keys = [...byKey.keys()]
198
202
  const found = new Set<string>()
199
- for (const key of localHas(keys)) found.add(byKey.get(key)!)
203
+ for (const key of localHas(names.map((n) => entryKey(plugin, n)))) found.add(byKey.get(key)!)
200
204
  for (const { remote } of readers) {
201
205
  const ask = keys.filter((k) => !found.has(byKey.get(k)!))
202
206
  if (ask.length === 0) break
203
207
  if (remote.hasMany === undefined) return null
204
208
  try {
205
- const got: unknown = await remote.hasMany(ask)
209
+ const got: unknown = await remote.hasMany(
210
+ ask,
211
+ ask.map((k) => atOf(k, byKey.get(k)!)),
212
+ )
206
213
  if (!(got instanceof Set)) return null
207
214
  for (const key of got) if (byKey.has(key as string)) found.add(byKey.get(key as string)!)
208
215
  } catch {
@@ -214,9 +221,9 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
214
221
 
215
222
  async getRun(name) {
216
223
  for (const { remote } of readers) {
217
- for (const key of runReadKeys(name, scope)) {
224
+ for (const [i, key] of runReadKeys(name, scope).entries()) {
218
225
  try {
219
- const got = await remote.get(key)
226
+ const got = await remote.get(key, i === 0 ? {} : at)
220
227
  if (got === null) continue
221
228
  const bytes = await capped(got.body, Infinity)
222
229
  if (bytes !== null) return bytes
@@ -10,8 +10,11 @@ import type { RemoteCacheLayer } from './layered-cache.js'
10
10
  /** One remote, resolved: its wire and what the run's policy and its `access` let it do. */
11
11
  export interface ResolvedRemote {
12
12
  readonly remote: RemoteCacheLayer
13
- readonly read: boolean
14
- readonly write: boolean
13
+ /** The plugin that supplied it, whose keychain its `open` reads; none for an injected layer. */
14
+ readonly plugin?: string
15
+ /** Cleared, with `write`, when the remote's `open` fails: off for the rest of the run. */
16
+ read: boolean
17
+ write: boolean
15
18
  /** Told of each failure this remote degrades to a miss; the default writes stderr. */
16
19
  readonly onError?: (err: Error) => void
17
20
  }
@@ -89,8 +92,7 @@ function batchOf(hashes: readonly string[]): { batch: string } {
89
92
  }
90
93
 
91
94
  export class RemoteLane {
92
- readonly read: boolean
93
- readonly write: boolean
95
+ private readonly resolved: ResolvedRemote
94
96
  private readonly remote: RemoteCacheLayer
95
97
  private readonly endpoint: string | undefined
96
98
  private readonly onError: ((err: Error) => void) | undefined
@@ -131,14 +133,23 @@ export class RemoteLane {
131
133
  constructor(
132
134
  resolved: ResolvedRemote,
133
135
  private readonly traffic: RemoteTraffic,
136
+ /** The wire as this run asks it: a branch scope's view of `resolved.remote`. */
137
+ remote: RemoteCacheLayer = resolved.remote,
134
138
  ) {
135
- this.remote = resolved.remote
136
- this.read = resolved.read
137
- this.write = resolved.write
139
+ this.resolved = resolved
140
+ this.remote = remote
138
141
  this.onError = resolved.onError
139
142
  this.endpoint = printableEndpoint(resolved.remote.endpoint)
140
143
  }
141
144
 
145
+ get read(): boolean {
146
+ return this.resolved.read
147
+ }
148
+
149
+ get write(): boolean {
150
+ return this.resolved.write
151
+ }
152
+
142
153
  /**
143
154
  * Existence probe; a failure is a miss. A key a `hasMany` this run
144
155
  * answered for is not asked again: `--dry` batches its keys first.
@@ -280,6 +291,11 @@ export class RemoteLane {
280
291
  this.pump()
281
292
  }
282
293
 
294
+ /** Settles when the run is stopped. */
295
+ get whenStopped(): Promise<void> {
296
+ return this.stopping
297
+ }
298
+
283
299
  /** Resolves once every queued and in-flight upload has settled, or the run was stopped. */
284
300
  async drain(): Promise<void> {
285
301
  // A probe no upload waited on still runs: settled before the run tears
@@ -292,7 +308,8 @@ export class RemoteLane {
292
308
  /**
293
309
  * The run was stopped (Ctrl-C, SIGTERM, an embedder's abort): queued
294
310
  * uploads never start and the drain stops waiting on the ones in flight,
295
- * which a plugin cannot be asked to cancel. A slow remote held a stopped
311
+ * which a plugin cannot be asked to cancel. Reads in flight are given up
312
+ * the same way (`LayeredCache.pullFrom`). A slow remote held a stopped
296
313
  * run past the signal's exit bound, which then exited with no footer.
297
314
  */
298
315
  stop(): void {
@@ -44,7 +44,10 @@ export async function putRunEntry(
44
44
  for (const { remote, write } of remotes) {
45
45
  if (!write) continue
46
46
  try {
47
- await remote.put(key, new Blob([bytes]), { durationMs: 0 })
47
+ await remote.put(key, new Blob([bytes]), {
48
+ durationMs: 0,
49
+ ...(scope === undefined ? {} : { scope }),
50
+ })
48
51
  kept = true
49
52
  } catch {
50
53
  // the other remotes still keep it
@@ -140,6 +140,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
140
140
  content_hash TEXT NOT NULL,
141
141
  seen_at INTEGER NOT NULL
142
142
  );
143
+ -- v42: each task's last key by its fold up to the files and a token of
144
+ -- the files' git records (fold-memo.ts). A memo: the KEY is unchanged.
145
+ CREATE TABLE IF NOT EXISTS main.fold_memo (
146
+ task_id TEXT PRIMARY KEY,
147
+ probe TEXT NOT NULL,
148
+ key TEXT NOT NULL,
149
+ seen_at INTEGER NOT NULL
150
+ );
143
151
  -- The size of each index blob the enumeration checked against the
144
152
  -- worktree size git recorded (A-60). Fixed for its OID, so a warm
145
153
  -- run asks git for none; swept with file_hashes by seen_at, the
@@ -317,8 +325,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
317
325
  * the artifact is the record and the entries only its inventory, so a reset
318
326
  * drops the rows and keeps these, as it keeps the files. `artifacts_meta`
319
327
  * holds the table's own layout version (`'layout'`), the one thing that may
320
- * drop it. `at` is the last use a row-less sweep judges, as a file's mtime;
321
- * it sits before `bytes` so reading it never walks a blob's overflow pages.
328
+ * drop it. `at` is the last use a row-less sweep judges, as a file's mtime.
329
+ * A rowid table, not WITHOUT ROWID: there the blob sits in the key's b-tree,
330
+ * so every probe of a hash reads overflow pages; 10 000 lookups took 312 ms
331
+ * against 8.5 (a2, 2026-10-17).
322
332
  */
323
333
  export function createArtifactTables(db: Database, store: 'main' | 'store'): void {
324
334
  db.exec(artifactTables(store))
@@ -331,7 +341,7 @@ function artifactTables(store: 'main' | 'store'): string {
331
341
  hash TEXT PRIMARY KEY,
332
342
  at INTEGER NOT NULL,
333
343
  bytes BLOB NOT NULL
334
- ) WITHOUT ROWID;
344
+ );
335
345
  CREATE TABLE IF NOT EXISTS ${store}.artifacts_meta (
336
346
  key TEXT PRIMARY KEY,
337
347
  value TEXT NOT NULL