@vzn/vx-dte 0.0.650 → 0.0.652

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/plugin.ts CHANGED
@@ -20,7 +20,7 @@ import type { Need } from './dispatch.js'
20
20
  import { booked, DEFAULT_STRATEGY, STRATEGIES, type Strategy } from './strategy.js'
21
21
  import type { Orchestrator } from './orchestrator.js'
22
22
  import type { Proof } from './seal.js'
23
- import type { Size } from './planner.js'
23
+ import type { Forecast, Size } from './planner.js'
24
24
 
25
25
  /** One kind of worker the adapter can start. */
26
26
  export interface WorkerSize {
@@ -76,6 +76,8 @@ export interface DteAdapter {
76
76
  * on joining, stuck or queued, so stopped whatever its job holds.
77
77
  */
78
78
  cancel?(ids: readonly string[], late?: boolean): void | Promise<readonly string[]>
79
+ /** Worker `id`, given up as late, joined after all and the run took it: a stop not yet sent is dropped. */
80
+ kept?(id: string): void
79
81
  /** The CI files that start its workers, which `vx dte init` writes; none for an adapter that starts them on this machine. */
80
82
  init?(setup: DteSetup): Promise<DteInit>
81
83
  /** Where it says a fault it works around (a status poll or a cancel that failed); called as the run starts. */
@@ -241,7 +243,13 @@ export function dte(options: DteOptions = {}) {
241
243
  // With no scheme the run failed in the executor as a bare "Invalid URL".
242
244
  if (options.relay !== undefined && !/^https?:\/\//.test(options.relay)) {
243
245
  throw new UserError(
244
- `dte() option "relay" must be an http(s) URL, got ${JSON.stringify(options.relay)}`,
246
+ `dte() option "relay" must be an http(s) URL, got ${JSON.stringify(shown(options.relay))}`,
247
+ )
248
+ }
249
+ const unrouted = options.relay === undefined ? undefined : unroutable(options.relay)
250
+ if (unrouted !== undefined) {
251
+ throw new UserError(
252
+ `dte() option "relay" ${unrouted}, got ${JSON.stringify(shown(options.relay!))}`,
245
253
  )
246
254
  }
247
255
  // On GitHub Actions with none given, one for the relay's URL.
@@ -385,7 +393,10 @@ export function dte(options: DteOptions = {}) {
385
393
  throw new UserError(`vx dte worker: needs ${missing.join(', and ')}`)
386
394
  }
387
395
  if (!/^https?:\/\//.test(relay))
388
- throw new UserError(`vx dte worker: VX_DTE_RELAY must be http(s), got ${relay}`)
396
+ throw new UserError(`vx dte worker: VX_DTE_RELAY must be http(s), got ${shown(relay)}`)
397
+ const unroutedEnv = unroutable(relay)
398
+ if (unroutedEnv !== undefined)
399
+ throw new UserError(`vx dte worker: VX_DTE_RELAY ${unroutedEnv}, got ${shown(relay)}`)
389
400
  const secret = givenSecret()
390
401
  if (secret === undefined && !onGitHub()) {
391
402
  throw new UserError(
@@ -485,13 +496,13 @@ export function dte(options: DteOptions = {}) {
485
496
  throw new UserError('dte(): no relay: set relay, or use an adapter that has one')
486
497
  const [
487
498
  { Orchestrator },
488
- { join },
499
+ { joinOffLoop },
489
500
  { decide, knownMisses, slowdown, tailsMs, WARM_UP_MS },
490
501
  { snapshot },
491
502
  seal,
492
503
  ] = await Promise.all([
493
504
  import('./orchestrator.js'),
494
- import('./wire.js'),
505
+ import('./wire-thread.js'),
495
506
  import('./planner.js'),
496
507
  import('./tree.js'),
497
508
  import('./seal.js'),
@@ -521,7 +532,9 @@ export function dte(options: DteOptions = {}) {
521
532
  let onChange = (_now?: boolean): void => {}
522
533
  const o = new Orchestrator({
523
534
  me: seal.sealOrchestrator(
524
- await join(relay, run, 'orchestrator', null, () => token(relay), headers),
535
+ // Off this loop: the run's own sync work held its acks, and every
536
+ // worker then running ended (CI, 2026-10-29).
537
+ await joinOffLoop(relay, run, null, () => token(relay), headers),
525
538
  run,
526
539
  proof,
527
540
  ctx.warn,
@@ -550,6 +563,7 @@ export function dte(options: DteOptions = {}) {
550
563
  joined: () => onChange(),
551
564
  // A worker given up on joining: its job, still queued or stuck, is stopped.
552
565
  overdue: (id) => void adapter?.cancel?.([id], true),
566
+ kept: (id) => adapter?.kept?.(id),
553
567
  // Before the run counts what is left to start: a worker that let a
554
568
  // task's lease end failed main's macOS run with another to start
555
569
  // (2026-10-23).
@@ -620,6 +634,8 @@ export function dte(options: DteOptions = {}) {
620
634
  // The run's deadline (`Plan.boundMs`), and the work its plans knew.
621
635
  let deadline: number | undefined
622
636
  const known = new Set<string>()
637
+ // The last plan, which stands while what it saw holds (`decide`).
638
+ const last: { forecast?: Forecast } = {}
623
639
  // A plan that needs a worker later plans again then.
624
640
  let again: ReturnType<typeof setTimeout> | undefined
625
641
  // The fewest workers that end the run's known work within a margin of
@@ -662,6 +678,8 @@ export function dte(options: DteOptions = {}) {
662
678
  how,
663
679
  home,
664
680
  queued: o.queued(warmUp),
681
+ last,
682
+ now: performance.now(),
665
683
  })
666
684
  deadline = performance.now() + p.boundMs
667
685
  for (const t of p.tasks) known.add(t.id)
@@ -782,6 +800,8 @@ export function dte(options: DteOptions = {}) {
782
800
  }
783
801
  if (needsIn === undefined) scheduled = forecast
784
802
  else forecast()
803
+ // Core asks the footer and the summary after teardown: the last run's graph.
804
+ const depsOf = (id: string): readonly string[] => (nodes ?? ran)?.get(id)?.deps ?? []
785
805
  return {
786
806
  name: 'dte',
787
807
  remote: true,
@@ -813,9 +833,14 @@ export function dte(options: DteOptions = {}) {
813
833
  replan()
814
834
  return o.execute(req)
815
835
  },
816
- footer: () => o.footer(() => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes)),
836
+ footer: () =>
837
+ o.footer(
838
+ () => o.idealMs(depsOf, sizes),
839
+ () => o.advice(depsOf, sizes),
840
+ ),
817
841
  machines: () => o.machines(),
818
- idealMs: () => o.idealMs((id) => (nodes ?? ran)?.get(id)?.deps ?? [], sizes),
842
+ idealMs: () => o.idealMs(depsOf, sizes),
843
+ advice: () => o.advice(depsOf, sizes),
819
844
  profile: () => o.profile(),
820
845
  running: (id) => o.where(id),
821
846
  demand(remaining, misses) {
@@ -1016,3 +1041,18 @@ export function homeOf(sizes: readonly Size[], host: string): string | undefined
1016
1041
  const of = new Set(sizes.filter((s) => s.max > 0).map((s) => s.platform))
1017
1042
  return of.has(host) && of.size > 1 ? host : undefined
1018
1043
  }
1044
+
1045
+ /**
1046
+ * Why a relay url no route can be appended to is refused: past a `?` or
1047
+ * `#` every socket asked the relay's root, and one naming /v1 asked /v1/v1/….
1048
+ */
1049
+ function unroutable(url: string): string | undefined {
1050
+ if (/[?#]/.test(url)) return 'takes no ?query or #fragment: each route is appended to it'
1051
+ if (/\/v1\/*$/.test(url)) return "is the relay's base, without /v1"
1052
+ return undefined
1053
+ }
1054
+
1055
+ /** A relay URL as a refusal may print it: a `user:pass@` would reach the CI log. */
1056
+ function shown(url: string): string {
1057
+ return url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^/?#]*@/i, '$1***@')
1058
+ }
package/src/seal.ts CHANGED
@@ -40,17 +40,22 @@ const GITHUB = 'https://token.actions.githubusercontent.com'
40
40
  /** GitHub's keys are read again for a key not among them at most this often. */
41
41
  const KEYS_REREAD_MS = 60_000
42
42
 
43
+ /** One try at GitHub's token endpoint, which answers in well under a second. */
44
+ const TOKEN_TRY_MS = 3_000
45
+
43
46
  /**
44
- * An OIDC token for `audience` from GitHub's token endpoint. A 5xx or a
45
- * dropped connection is tried again, three tries in 10 s at most: one 503
46
- * ended a worker before it joined (PR #4146, 2026-10-23).
47
+ * An OIDC token for `audience` from GitHub's token endpoint. A 5xx, a
48
+ * dropped connection or a try that hangs past 3 s is tried again, three
49
+ * tries in 10 s at most: one 503 ended a worker before it joined (PR #4146,
50
+ * 2026-10-23), and one held request spent the whole budget (PR #4887,
51
+ * 2026-10-31).
47
52
  */
48
53
  export async function oidcToken(url: string, bearer: string, audience: string): Promise<string> {
49
54
  const until = performance.now() + 10_000
50
55
  for (let attempt = 1; ; attempt++) {
51
56
  const res = await fetch(`${url}&audience=${encodeURIComponent(audience)}`, {
52
57
  headers: { authorization: `bearer ${bearer}` },
53
- signal: AbortSignal.timeout(Math.max(1, until - performance.now())),
58
+ signal: AbortSignal.timeout(Math.max(1, Math.min(TOKEN_TRY_MS, until - performance.now()))),
54
59
  }).catch((err: unknown) => (err instanceof Error ? err.message : String(err)))
55
60
  if (typeof res !== 'string' && res.ok) {
56
61
  // quiet: a body that is not JSON holds no token, said below
@@ -371,6 +376,7 @@ export function sealOrchestrator(
371
376
  return me.online
372
377
  },
373
378
  resumed: (f) => me.resumed(f),
379
+ quiet: (f) => me.quiet(f),
374
380
  async send(to, body) {
375
381
  const s = sessions.get(to)
376
382
  // Lost or never admitted: the relay's own answer for a peer it does
@@ -468,6 +474,7 @@ export async function sealWorker(
468
474
  return me.online
469
475
  },
470
476
  resumed: (f) => me.resumed(f),
477
+ quiet: (f) => me.quiet(f),
471
478
  async send(to, body) {
472
479
  if (session === undefined) throw new Error('no session with the orchestrator yet')
473
480
  await session.send(body, (sealed) => me.send(to, sealed))
@@ -0,0 +1,98 @@
1
+ // The thread `joinOffLoop` (wire-thread.ts) starts: the orchestrator's
2
+ // participant, its socket, acks, beats and redials, on a loop the run's
3
+ // work never holds.
4
+
5
+ import { join, type Participant } from './wire.js'
6
+
7
+ declare const self: Worker
8
+
9
+ /**
10
+ * The run's loop held this many heartbeats (10 s on the default) is taken
11
+ * as hung: the thread leaves, so the relay closes the run and its workers
12
+ * end, as they would have for an orchestrator quiet that long (owner,
13
+ * 2026-10-23: "if it does not reply for 10s its dead").
14
+ */
15
+ const HELD_BEATS = 5
16
+
17
+ let me: Participant | undefined
18
+ let n = 0
19
+ const tokens = new Map<number, { resolve(v: string | undefined): void; reject(e: Error): void }>()
20
+ const post = (m: unknown): void => self.postMessage(m)
21
+ const failure = (err: unknown): { status?: number; message: string } => ({
22
+ ...(typeof (err as { status?: unknown }).status === 'number'
23
+ ? { status: (err as { status: number }).status }
24
+ : {}),
25
+ message: err instanceof Error ? err.message : String(err),
26
+ })
27
+
28
+ self.onmessage = async (e: MessageEvent<Record<string, unknown>>) => {
29
+ const m = e.data
30
+ if (m['type'] === 'token') {
31
+ const t = tokens.get(m['n'] as number)
32
+ tokens.delete(m['n'] as number)
33
+ if (typeof m['error'] === 'string') t?.reject(new Error(m['error']))
34
+ else t?.resolve(m['value'] as string | undefined)
35
+ return
36
+ }
37
+ const reply = (value?: unknown): void => post({ type: 'done', n: m['n'], value })
38
+ const fail = (err: unknown): void => post({ type: 'done', n: m['n'], error: failure(err) })
39
+ try {
40
+ if (m['type'] === 'join') {
41
+ const shared = new Float64Array(m['shared'] as SharedArrayBuffer)
42
+ const token = m['asks']
43
+ ? () =>
44
+ new Promise<string | undefined>((resolve, reject) => {
45
+ const k = ++n
46
+ tokens.set(k, { resolve, reject })
47
+ post({ type: 'token', n: k })
48
+ })
49
+ : (m['token'] as string | undefined)
50
+ const p = await join(
51
+ m['relay'] as string,
52
+ m['run'] as string,
53
+ 'orchestrator',
54
+ m['info'],
55
+ token,
56
+ m['extra'] as Record<string, string>,
57
+ )
58
+ me = p
59
+ p.resumed((peer) => post({ type: 'resumed', ...(peer === undefined ? {} : { peer }) }))
60
+ reply({ id: p.id, heartbeatMs: p.heartbeatMs })
61
+ const watch = setInterval(
62
+ () => {
63
+ shared[1] = p.online ? 1 : 0
64
+ const held = Date.now() - shared[0]!
65
+ if (held < HELD_BEATS * p.heartbeatMs) return
66
+ clearInterval(watch)
67
+ post({
68
+ type: 'ended',
69
+ status: 410,
70
+ message: `dte: this machine's run was held ${Math.round(held / 1000)} s, past ${HELD_BEATS} heartbeats: the run is over`,
71
+ })
72
+ // quiet: `ended` above already said the run is over; a failed
73
+ // leave changes nothing the relay does with it.
74
+ void p.leave().catch(() => undefined)
75
+ },
76
+ Math.max(10, p.heartbeatMs / 10),
77
+ )
78
+ for (;;) {
79
+ let got
80
+ try {
81
+ got = await p.poll()
82
+ } catch (err) {
83
+ clearInterval(watch)
84
+ const f = failure(err)
85
+ post({ type: 'ended', status: f.status ?? 500, message: f.message })
86
+ return
87
+ }
88
+ if (got.length > 0) post({ type: 'messages', messages: got })
89
+ }
90
+ }
91
+ if (me === undefined) throw new Error('dte: not joined')
92
+ if (m['type'] === 'send') reply(await me.send(m['to'] as string, m['body']))
93
+ else if (m['type'] === 'remove') reply(await me.remove(m['worker'] as string))
94
+ else if (m['type'] === 'leave') reply(await me.leave())
95
+ } catch (err) {
96
+ fail(err)
97
+ }
98
+ }
@@ -0,0 +1,168 @@
1
+ // The orchestrator's relay participant on a thread of its own.
2
+ //
3
+ // Liveness is in the protocol: a quiet worker reports every 1.5
4
+ // heartbeats, its orchestrator acks, and a peer quiet 3 heartbeats is lost. On the orchestrator's loop, its own
5
+ // sync work held its beats: in CI run 40886829588 (2026-10-29) it said
6
+ // nothing for 12 s and every worker then running ended, stopping 13
7
+ // tasks. Here the socket, its acks, beats and redials run on a thread the
8
+ // run's work never holds; the run talks to it by messages.
9
+
10
+ import { ui } from '@vzn/vx'
11
+ import { RelayError, type Participant, type RelayMessage } from './wire.js'
12
+
13
+ /** From the thread: what `wire-host.ts` posts. */
14
+ type FromHost =
15
+ | { type: 'token'; n: number }
16
+ | { type: 'messages'; messages: RelayMessage[] }
17
+ | { type: 'resumed'; peer?: string }
18
+ | { type: 'ended'; status: number; message: string }
19
+ | { type: 'done'; n: number; value?: unknown; error?: { status?: number; message: string } }
20
+
21
+ /** How often this loop says it is alive to the thread (`HELD_BEATS` in wire-host.ts reads it). */
22
+ const TICK_MS = 100
23
+ /**
24
+ * A tick this late says so: the thread kept the run alive, but what held the
25
+ * loop is not known (run 40886829588's profile had no slice in its 12 s gap),
26
+ * and the line's time in the log is where to look. Half the 2 s heartbeat.
27
+ */
28
+ const HELD_SAY_MS = 1000
29
+
30
+ export async function joinOffLoop(
31
+ relay: string,
32
+ run: string,
33
+ info: unknown,
34
+ token: string | undefined | (() => Promise<string | undefined>),
35
+ extra: Readonly<Record<string, string>> = {},
36
+ ): Promise<Participant> {
37
+ // [0]: when this loop last ran (Date.now, the clock both threads share); [1]: online.
38
+ const shared = new Float64Array(new SharedArrayBuffer(16))
39
+ const tick = (): void => {
40
+ const now = Date.now()
41
+ const held = now - shared[0]! - TICK_MS
42
+ if (shared[0] && held >= HELD_SAY_MS)
43
+ ui.say('warn', `[vx] dte: this machine's loop was held ${held} ms`)
44
+ shared[0] = now
45
+ }
46
+ tick()
47
+ const ticking = setInterval(tick, TICK_MS)
48
+ ticking.unref()
49
+ const thread = new Worker(new URL('./wire-host.ts', import.meta.url).href)
50
+ let n = 0
51
+ const calls = new Map<number, { resolve(v: unknown): void; reject(e: unknown): void }>()
52
+ const inbox: RelayMessage[] = []
53
+ let ended: RelayError | undefined
54
+ let onResumed: ((peer?: string) => void) | undefined
55
+ /** The token function's own error, said as itself rather than as the thread's copy. */
56
+ let tokenError: unknown
57
+ const waiters = new Set<() => void>()
58
+ const wake = (): void => {
59
+ for (const w of [...waiters]) w()
60
+ }
61
+ const call = (msg: Record<string, unknown>): Promise<unknown> =>
62
+ new Promise((resolve, reject) => {
63
+ const k = ++n
64
+ calls.set(k, { resolve, reject })
65
+ thread.postMessage({ ...msg, n: k })
66
+ })
67
+ thread.onmessage = (e: MessageEvent<FromHost>) => {
68
+ const m = e.data
69
+ if (m.type === 'token') {
70
+ void (async () => {
71
+ try {
72
+ const value = typeof token === 'function' ? await token() : token
73
+ thread.postMessage({ type: 'token', n: m.n, value })
74
+ } catch (err) {
75
+ tokenError = err
76
+ thread.postMessage({
77
+ type: 'token',
78
+ n: m.n,
79
+ error: err instanceof Error ? err.message : String(err),
80
+ })
81
+ }
82
+ })()
83
+ } else if (m.type === 'messages') {
84
+ inbox.push(...m.messages)
85
+ wake()
86
+ } else if (m.type === 'resumed') onResumed?.(m.peer)
87
+ else if (m.type === 'ended') {
88
+ ended ??= new RelayError(m.status, m.message)
89
+ wake()
90
+ } else {
91
+ const c = calls.get(m.n)
92
+ calls.delete(m.n)
93
+ if (m.error === undefined) c?.resolve(m.value)
94
+ else if (tokenError instanceof Error && tokenError.message === m.error.message)
95
+ c?.reject(tokenError)
96
+ else
97
+ c?.reject(
98
+ m.error.status === undefined
99
+ ? new Error(m.error.message)
100
+ : new RelayError(m.error.status, m.error.message),
101
+ )
102
+ }
103
+ }
104
+ const stop = (): void => {
105
+ clearInterval(ticking)
106
+ thread.terminate()
107
+ }
108
+ let joined: { id: string; heartbeatMs: number }
109
+ try {
110
+ joined = (await call({
111
+ type: 'join',
112
+ relay,
113
+ run,
114
+ info,
115
+ extra,
116
+ shared: shared.buffer,
117
+ token: typeof token === 'function' ? undefined : token,
118
+ asks: typeof token === 'function',
119
+ })) as { id: string; heartbeatMs: number }
120
+ } catch (err) {
121
+ stop()
122
+ throw err
123
+ }
124
+ const idle = (ms: number, signal?: AbortSignal): Promise<void> =>
125
+ new Promise((resolve) => {
126
+ const done = (): void => {
127
+ clearTimeout(timer)
128
+ signal?.removeEventListener('abort', done)
129
+ waiters.delete(done)
130
+ resolve()
131
+ }
132
+ const timer = setTimeout(done, ms)
133
+ signal?.addEventListener('abort', done)
134
+ waiters.add(done)
135
+ })
136
+ return {
137
+ id: joined.id,
138
+ heartbeatMs: joined.heartbeatMs,
139
+ get online() {
140
+ return shared[1] === 1
141
+ },
142
+ resumed(f) {
143
+ onResumed = f
144
+ },
145
+ // The thread's own wire beats for the run: its loop may be held.
146
+ quiet() {},
147
+ async send(to, body) {
148
+ await call({ type: 'send', to, body })
149
+ },
150
+ async poll(signal) {
151
+ if (inbox.length === 0 && ended === undefined && signal?.aborted !== true)
152
+ await idle(joined.heartbeatMs, signal)
153
+ if (inbox.length > 0) return inbox.splice(0)
154
+ if (ended !== undefined) throw ended
155
+ return []
156
+ },
157
+ async remove(worker) {
158
+ await call({ type: 'remove', worker })
159
+ },
160
+ async leave() {
161
+ try {
162
+ await call({ type: 'leave' })
163
+ } finally {
164
+ stop()
165
+ }
166
+ },
167
+ }
168
+ }