@vzn/vx-dte 0.0.652 → 0.0.653

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/src/local.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // loopback. For tests, demos
4
4
  // and trying a distributed run before a CI starts the workers.
5
5
 
6
- import { mkdtemp, writeFile } from 'node:fs/promises'
6
+ import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
9
  import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
@@ -24,7 +24,8 @@ export function local(options: LocalOptions = {}): DteAdapter {
24
24
  const procs = new Set<Bun.Subprocess>()
25
25
  /** A worktree add or a prepare: what close stops at once. */
26
26
  const helpers = new Set<Bun.Subprocess>()
27
- const trees: Array<{ root: string; dir: string }> = []
27
+ /** The repositories this run adds worktrees to, whose records close prunes. */
28
+ const roots = new Set<string>()
28
29
  const pending = new Set<Promise<unknown>>()
29
30
 
30
31
  // Async throughout: a run starts its workers while it shows its first rows.
@@ -83,7 +84,14 @@ export function local(options: LocalOptions = {}): DteAdapter {
83
84
 
84
85
  async spawn(a) {
85
86
  const work = (async () => {
86
- base ??= mkdtemp(path.join(os.tmpdir(), 'vx-dte-'))
87
+ base ??= (async () => {
88
+ const d = await mkdtemp(path.join(os.tmpdir(), BASE_PREFIX))
89
+ // Whose they are, for a later run to reclaim once this vx is gone.
90
+ await writeFile(path.join(d, OWNER), String(process.pid))
91
+ void reclaim(a.workspaceRoot)
92
+ return d
93
+ })()
94
+ roots.add(a.workspaceRoot)
87
95
  const dir = path.join(await base, String(a.index))
88
96
  if (closed) return
89
97
  // The run's own snapshot: one taken here, after a hit restored a
@@ -95,7 +103,6 @@ export function local(options: LocalOptions = {}): DteAdapter {
95
103
  { ...process.env, GIT_INDEX_FILE: undefined },
96
104
  )
97
105
  if (add.code !== 0) throw new Error(`git worktree add: ${errorLine(add.err)}`)
98
- trees.push({ root: a.workspaceRoot, dir })
99
106
  // The workspace's own place in the checkout: from the worktree's
100
107
  // root, a workspace in a subdirectory of its repository had no
101
108
  // `.vx/workspace` above, and every worker exited (2026-10-25).
@@ -137,38 +144,115 @@ export function local(options: LocalOptions = {}): DteAdapter {
137
144
  relay?.stop()
138
145
  relay = undefined
139
146
  for (const p of helpers) group(p, 'SIGTERM')
140
- const grace = setTimeout(() => {
141
- for (const p of procs) {
142
- if (helpers.has(p)) group(p, 'SIGKILL')
143
- else p.kill('SIGKILL')
144
- }
145
- }, 5_000)
146
- await Promise.allSettled([...pending])
147
- await Promise.all([...procs].map((p) => p.exited))
148
- clearTimeout(grace)
149
- // A detached shell removes the worktrees after vx exits: removing ones
150
- // an install filled took seconds after a Ctrl-C (2026-10-21), and a
151
- // 300k-file tree 2.7 s. `prune` then drops git's record of each.
152
- const roots = [...new Set(trees.splice(0).map((t) => t.root))]
147
+ // A detached shell waits for what this run started, kills what is
148
+ // left after 5 s, then removes the worktrees, after vx exits:
149
+ // removing ones an install filled took seconds after a Ctrl-C
150
+ // (2026-10-21), and a 300k-file tree 2.7 s. Spawned first: core stops
151
+ // waiting on a teardown after 3 s, and a worker slow to leave kept
152
+ // close from spawning it before vx exited, so every worktree stayed
153
+ // (2026-11-08). `prune` then drops git's record of each.
153
154
  if (base !== undefined) {
154
155
  const dir = await base
155
156
  base = undefined
156
- Bun.spawn(
157
- [
158
- 'sh',
159
- '-c',
160
- 'rm -rf "$1"; shift; for r; do git -C "$r" worktree prune; done',
161
- 'sh',
162
- dir,
163
- ...roots,
164
- ],
165
- { stdio: ['ignore', 'ignore', 'ignore'], detached: true },
166
- ).unref()
157
+ const helping = [...helpers].map((p) => p.pid).join(' ')
158
+ const working = [...procs]
159
+ .filter((p) => !helpers.has(p))
160
+ .map((p) => p.pid)
161
+ .join(' ')
162
+ Bun.spawn(['sh', '-c', CLEANUP, 'sh', dir, helping, working, ...roots], {
163
+ stdio: ['ignore', 'ignore', 'ignore'],
164
+ detached: true,
165
+ }).unref()
167
166
  }
167
+ roots.clear()
168
+ // Bounded under core's deadline: what still runs after it is the shell's.
169
+ await Promise.race([
170
+ Promise.allSettled([...pending, ...[...procs].map((p) => p.exited)]),
171
+ Bun.sleep(2_000),
172
+ ])
168
173
  },
169
174
  }
170
175
  }
171
176
 
177
+ /**
178
+ * `sh -c CLEANUP sh <base> <helper pids> <worker pids> <roots…>`: waits up
179
+ * to 5 s for the pids, kills each left (a helper's whole group), removes
180
+ * the base and prunes each root's worktree records.
181
+ */
182
+ const CLEANUP = `d=$1 h=$2 w=$3; shift 3
183
+ i=0
184
+ while [ $i -lt 50 ]; do
185
+ up=; for p in $h $w; do kill -0 $p 2>/dev/null && up=1; done
186
+ [ -z "$up" ] && break
187
+ sleep 0.1; i=$((i+1))
188
+ done
189
+ for p in $h; do kill -9 -$p 2>/dev/null; done
190
+ for p in $w; do kill -9 $p 2>/dev/null; done
191
+ rm -rf "$d"; for r; do git -C "$r" worktree prune; done`
192
+
193
+ const BASE_PREFIX = 'vx-dte-'
194
+ const OWNER = 'owner'
195
+ /** `mkdtemp`'s name: the prefix and six characters. */
196
+ const OWN_BASE = new RegExp(`^${BASE_PREFIX}[A-Za-z0-9]{6}$`)
197
+
198
+ /**
199
+ * The worktrees a run whose vx was killed (SIGKILL, a lost machine) left in
200
+ * `root`'s repository, removed: each under a base whose owner process is
201
+ * gone. A killed run's close never ran, and they stayed for good
202
+ * (2026-11-06). One `git worktree list`, off the run's path.
203
+ */
204
+ async function reclaim(root: string): Promise<void> {
205
+ try {
206
+ const env: Record<string, string | undefined> = { ...process.env }
207
+ for (const name of GIT_REPO_ENV) delete env[name]
208
+ const list = Bun.spawn(['git', 'worktree', 'list', '--porcelain'], {
209
+ cwd: root,
210
+ env,
211
+ stdin: 'ignore',
212
+ stdout: 'pipe',
213
+ stderr: 'ignore',
214
+ })
215
+ const bases = new Set<string>()
216
+ for (const line of (await new Response(list.stdout).text()).split('\n')) {
217
+ if (!line.startsWith('worktree ')) continue
218
+ const dir = line.slice('worktree '.length)
219
+ const base = path.dirname(dir)
220
+ if (/^\d+$/.test(path.basename(dir)) && OWN_BASE.test(path.basename(base))) bases.add(base)
221
+ }
222
+ const gone: string[] = []
223
+ for (const base of bases) {
224
+ const pid = Number(
225
+ await readFile(path.join(base, OWNER), 'utf8').catch(
226
+ // quiet: a base with no owner is not known to be gone
227
+ () => '',
228
+ ),
229
+ )
230
+ if (Number.isInteger(pid) && pid > 0 && !alive(pid)) gone.push(base)
231
+ }
232
+ if (gone.length === 0) return
233
+ Bun.spawn(
234
+ ['sh', '-c', 'r=$1; shift; rm -rf "$@"; git -C "$r" worktree prune', 'sh', root, ...gone],
235
+ {
236
+ env,
237
+ stdio: ['ignore', 'ignore', 'ignore'],
238
+ detached: true,
239
+ },
240
+ ).unref()
241
+ } catch {
242
+ // quiet: what is left is reclaimed by the next run
243
+ }
244
+ }
245
+
246
+ function alive(pid: number): boolean {
247
+ try {
248
+ process.kill(pid, 0)
249
+ return true
250
+ } catch (err) {
251
+ // Another user's process is alive too.
252
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
253
+ }
254
+ }
255
+
172
256
  /** Signal `p`'s process group: a helper leads its own (`detached`). */
173
257
  function group(p: Bun.Subprocess, signal: NodeJS.Signals): void {
174
258
  try {
package/src/messages.ts CHANGED
@@ -54,6 +54,8 @@ export interface RunOrder {
54
54
  cwd: string
55
55
  project: string
56
56
  env: Record<string, string>
57
+ /** `HOST_ENV` names its key folds as unset: the worker leaves them unset, not its own. */
58
+ unset?: string[]
57
59
  /** Its `exec.env.secret`: the names in `env` whose values its saved output masks. */
58
60
  secret?: string[]
59
61
  timeoutMs?: number
@@ -65,9 +67,10 @@ export interface RunOrder {
65
67
  * Its `exec.sandbox`, and for a cached task the projects its key answers
66
68
  * for (workspace-relative): the worker builds the request on its own
67
69
  * paths and OS (`sandboxRequestFor`), as its orchestrator may run another;
68
- * `reads`, the dependencies' outputs it may read (workspace-relative).
70
+ * `reads`, the dependencies' outputs it may read, and `reported`, the
71
+ * heads a refused read under is reported (workspace-relative).
69
72
  */
70
- sandbox?: { config: SandboxConfig; keyed?: string[]; reads?: string[] }
73
+ sandbox?: { config: SandboxConfig; keyed?: string[]; reads?: string[]; reported?: string[] }
71
74
  /** Its persistent dependencies (`ServerSpec`), `cwd` workspace-relative: the worker starts each once. */
72
75
  servers?: ServerOrder[]
73
76
  /** The runtime probes its key includes, as the orchestrator answered them: the worker's must answer the same. */
@@ -119,6 +122,8 @@ export type ToWorker =
119
122
  /** Part of an order's `deps`, ahead of the batch whose order was too long for one frame. */
120
123
  | { type: 'deps'; task: string; deps: WiredDep[] }
121
124
  | { type: 'cancel'; task: string }
125
+ /** No task left in the run uses persistent task `task`: it stops here. */
126
+ | { type: 'unserve'; task: string }
122
127
  /**
123
128
  * It was taken into the run, under the name the run's output gives it,
124
129
  * and reads the cache as the run does: its refs, and the access the
@@ -152,8 +157,12 @@ export interface Load {
152
157
  * `cpu` (one that sent 3, each as a task started, read 0.6 of 2.0 used).
153
158
  */
154
159
  readonly avg?: number
160
+ /** Cores the worker's own process used on average since it started: what the run's bookkeeping costs it. */
161
+ readonly self?: number
155
162
  /** When it was read, by the worker's clock: its task times are read against it. */
156
163
  readonly at?: number
164
+ /** Each server it runs, its base as fitted (`server-fit.ts`): cores it uses with no dependant's work. */
165
+ readonly servers?: Record<string, number>
157
166
  }
158
167
 
159
168
  /** A task's times on its worker, epoch ms by the worker's clock: begun, command ended. */
@@ -219,7 +228,9 @@ export type WiredResult = Omit<ExecuteResult, 'violations' | 'outputs' | 'stdout
219
228
 
220
229
  /**
221
230
  * Names whose value is the worker's own: the orchestrator sends one only
222
- * when a task's `env.define` sets it, a value its key includes.
231
+ * when a task's `env.define` sets it or its `inputs.env` keys it, a value
232
+ * its key includes; a keyed one unset there is unset on the worker too
233
+ * (`RunOrder.unset`).
223
234
  */
224
235
  export const HOST_ENV = [
225
236
  'HOME',