@vzn/vx 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/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/graph/folds.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
parseDependencySpec,
|
|
11
11
|
type DependencySpec,
|
|
12
12
|
} from '../util/index.js'
|
|
13
|
+
import { standInsOf, type StandIns } from './stand-ins.js'
|
|
13
14
|
import type { TaskNode } from './task-graph.js'
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -36,9 +37,13 @@ export function keyedDeps(node: TaskNode): readonly string[] {
|
|
|
36
37
|
* A filter selects among the task's own dependencies, never through a
|
|
37
38
|
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
38
39
|
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
39
|
-
* matches
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* matches a member (`build.*`) the filter folds neither directly nor
|
|
41
|
+
* through a selected group therefore left it out, and the key stopped
|
|
42
|
+
* moving with that member's inputs: a stale hit under a green run. That
|
|
43
|
+
* held when the pattern also matched another dependency directly
|
|
44
|
+
* (`dependsOn: ['build', 'build.a']`), so such a spec is refused, once,
|
|
45
|
+
* over the graph every key site reads, unless it matches the group
|
|
46
|
+
* itself (a negation of the group is a decision). One that matches
|
|
42
47
|
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
43
48
|
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
44
49
|
* names it (`assertFilterNamesDeclaredDeps`).
|
|
@@ -49,20 +54,37 @@ export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>)
|
|
|
49
54
|
if (filter === undefined || filter.length === 0) continue
|
|
50
55
|
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
51
56
|
const groups = deps.filter(isGroupTask)
|
|
57
|
+
const folded = new Set<TaskNode>()
|
|
58
|
+
if (groups.length > 0) {
|
|
59
|
+
const units = deps.map((n) => ({ node: n, unit: n.id }))
|
|
60
|
+
const picked = selectFoldedDeps(units, filter, node.projectName, node.id, standInsOf(node))
|
|
61
|
+
for (const { node: n } of picked) {
|
|
62
|
+
folded.add(n)
|
|
63
|
+
if (isGroupTask(n)) for (const m of groupMembers(n, nodes)) folded.add(m)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
52
66
|
for (const raw of filter) {
|
|
53
67
|
const spec = parseSpec(raw, node.id)
|
|
54
68
|
if (spec.negated) continue
|
|
55
69
|
const matches = specMatcher(spec)
|
|
56
70
|
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
57
|
-
|
|
71
|
+
const direct = deps.some(hit)
|
|
58
72
|
for (const group of groups) {
|
|
59
|
-
|
|
73
|
+
if (hit(group)) continue
|
|
74
|
+
const member = groupMembers(group, nodes).find(
|
|
75
|
+
(m) => hit(m) && !folded.has(m) && !deps.includes(m),
|
|
76
|
+
)
|
|
60
77
|
if (member === undefined) continue
|
|
78
|
+
const outcome = direct ? 'so the key leaves it out' : 'so it selects nothing'
|
|
61
79
|
throw new UserError(
|
|
62
|
-
`${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id},
|
|
80
|
+
`${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, ${outcome}; name '${group.taskName}' or depend on ${member.id} directly`,
|
|
63
81
|
)
|
|
64
82
|
}
|
|
65
|
-
if (
|
|
83
|
+
if (direct) continue
|
|
84
|
+
if (
|
|
85
|
+
namesNoDependency(spec, node.config.dependsOn ?? []) ||
|
|
86
|
+
namesTransitiveDependency(spec, node, nodes, hit)
|
|
87
|
+
) {
|
|
66
88
|
const named = deps.map((d) => d.id).join(', ') || 'none'
|
|
67
89
|
throw new UserError(
|
|
68
90
|
`${node.id}: inputs.tasks: '${raw}' names none of its dependencies (${named}), so it selects nothing and the key would not move with them; fix the name, or use [] to decouple on purpose`,
|
|
@@ -100,6 +122,34 @@ function namesNoDependency(spec: DependencySpec, dependsOn: readonly string[]):
|
|
|
100
122
|
})
|
|
101
123
|
}
|
|
102
124
|
|
|
125
|
+
/**
|
|
126
|
+
* An exact `pkg#task` filter that matched no dependency but names a task the
|
|
127
|
+
* key reaches only through one. `namesNoDependency` lets it through when a
|
|
128
|
+
* `^` entry names the task (the preset whose project lacks that dependency),
|
|
129
|
+
* but `a → b → c` with `dependsOn: ['^build']` and `['c#build']` selected
|
|
130
|
+
* none of a's direct dependencies, and an edit to c replayed a's hit.
|
|
131
|
+
*/
|
|
132
|
+
function namesTransitiveDependency(
|
|
133
|
+
spec: DependencySpec,
|
|
134
|
+
node: TaskNode,
|
|
135
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
136
|
+
hit: (n: TaskNode) => boolean,
|
|
137
|
+
): boolean {
|
|
138
|
+
if (spec.kind !== 'cross' || isTaskPattern(spec.project) || isTaskPattern(spec.task)) return false
|
|
139
|
+
const seen = new Set<string>([node.id])
|
|
140
|
+
const stack = [...keyedDeps(node)]
|
|
141
|
+
while (stack.length > 0) {
|
|
142
|
+
const id = stack.pop()!
|
|
143
|
+
if (seen.has(id)) continue
|
|
144
|
+
seen.add(id)
|
|
145
|
+
const n = nodes.get(id)
|
|
146
|
+
if (n === undefined) continue
|
|
147
|
+
if (hit(n)) return true
|
|
148
|
+
stack.push(...keyedDeps(n))
|
|
149
|
+
}
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
103
153
|
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
104
154
|
try {
|
|
105
155
|
return parseDependencySpec(raw, 'inputs.tasks')
|
|
@@ -162,16 +212,26 @@ export interface FoldCandidate {
|
|
|
162
212
|
* - `filter === undefined` → every candidate, as given.
|
|
163
213
|
* - `filter === []` → none (fully decoupled).
|
|
164
214
|
*
|
|
165
|
-
* Otherwise deduped by `unit`, first candidate kept. The unit is
|
|
166
|
-
* fold's own identity, not the task's: two groups over the same members
|
|
167
|
-
* share one hash
|
|
168
|
-
*
|
|
215
|
+
* Otherwise deduped by `unit`, first selected candidate kept. The unit is
|
|
216
|
+
* the fold's own identity, not the task's: two groups over the same members
|
|
217
|
+
* share one hash. A negation drops only the candidates it names, and the
|
|
218
|
+
* dedup runs after every entry, so a unit still selected through another
|
|
219
|
+
* candidate stays folded: deleting by unit let `['^*', '!c#build']` drop
|
|
220
|
+
* b#build's group with c#build's, and a change beneath both replayed the
|
|
221
|
+
* old hit.
|
|
222
|
+
*
|
|
223
|
+
* `standIns` is the task's `standInsOf`: an edge added past a
|
|
224
|
+
* default build on a package cycle is selected whenever an edge it went
|
|
225
|
+
* past is. That build folds nothing of its cycle, so `['build']` on
|
|
226
|
+
* `a ↔ b` selected a#build alone, dropped b#build, and an edit to b
|
|
227
|
+
* replayed the old hit.
|
|
169
228
|
*/
|
|
170
229
|
export function selectFoldedDeps(
|
|
171
230
|
deps: readonly FoldCandidate[],
|
|
172
231
|
filter: readonly string[] | undefined,
|
|
173
232
|
selfProjectName: string,
|
|
174
233
|
selfTaskId: string,
|
|
234
|
+
standIns?: StandIns,
|
|
175
235
|
): FoldCandidate[] {
|
|
176
236
|
if (filter === undefined) return [...deps]
|
|
177
237
|
|
|
@@ -180,18 +240,25 @@ export function selectFoldedDeps(
|
|
|
180
240
|
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
181
241
|
const matchers = specs.map((spec) => specMatcher(spec))
|
|
182
242
|
|
|
183
|
-
const selected = new
|
|
243
|
+
const selected = new Set<FoldCandidate>()
|
|
184
244
|
for (let i = 0; i < specs.length; i++) {
|
|
185
245
|
const spec = specs[i]!
|
|
186
246
|
const matches = matchers[i]!
|
|
187
247
|
for (const d of deps) {
|
|
188
248
|
const isSelf = d.node.projectName === selfProjectName
|
|
189
249
|
if (!matches(d.node, isSelf)) continue
|
|
190
|
-
if (spec.negated) selected.delete(d
|
|
191
|
-
else
|
|
250
|
+
if (spec.negated) selected.delete(d)
|
|
251
|
+
else selected.add(d)
|
|
192
252
|
}
|
|
193
253
|
}
|
|
194
|
-
|
|
254
|
+
if (standIns !== undefined) {
|
|
255
|
+
const ids = new Set<string>()
|
|
256
|
+
for (const d of selected) ids.add(d.node.id)
|
|
257
|
+
for (const d of deps) if (standIns[d.node.id]?.some((p) => ids.has(p))) selected.add(d)
|
|
258
|
+
}
|
|
259
|
+
const byUnit = new Map<string, FoldCandidate>()
|
|
260
|
+
for (const d of selected) if (!byUnit.has(d.unit)) byUnit.set(d.unit, d)
|
|
261
|
+
return [...byUnit.values()]
|
|
195
262
|
}
|
|
196
263
|
|
|
197
264
|
/** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
|
|
@@ -250,7 +317,13 @@ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): Fo
|
|
|
250
317
|
candidates.push({ node: dep, unit: foldUnit(dep) })
|
|
251
318
|
}
|
|
252
319
|
if (isGroupTask(node) && node.config.inputs === undefined) return candidates
|
|
253
|
-
return selectFoldedDeps(
|
|
320
|
+
return selectFoldedDeps(
|
|
321
|
+
candidates,
|
|
322
|
+
node.config.inputs?.tasks,
|
|
323
|
+
node.projectName,
|
|
324
|
+
node.id,
|
|
325
|
+
standInsOf(node),
|
|
326
|
+
)
|
|
254
327
|
}
|
|
255
328
|
|
|
256
329
|
/**
|
package/src/graph/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
} from './folds.js'
|
|
19
19
|
export { DEFAULT_BUILD, isDefaultBuild } from './default-build.js'
|
|
20
20
|
export { computeReverseDepCount } from './priorities.js'
|
|
21
|
+
export { setStandIns, type StandIns, standInsOf } from './stand-ins.js'
|
|
21
22
|
export { criticalPath } from './critical-path.js'
|
|
22
23
|
export { taskDefinition, taskDefinitions } from './definition.js'
|
|
23
24
|
export {
|
|
@@ -26,6 +27,13 @@ export {
|
|
|
26
27
|
idleServers,
|
|
27
28
|
type InputChanges,
|
|
28
29
|
RestoreDemoted,
|
|
30
|
+
isKeyFailed,
|
|
31
|
+
KeyFailed,
|
|
32
|
+
markKeyFailed,
|
|
33
|
+
markOutputsFailed,
|
|
34
|
+
OutputsFailed,
|
|
35
|
+
outputsFailedOf,
|
|
36
|
+
RestoreFailed,
|
|
29
37
|
runGraph,
|
|
30
38
|
type TaskOutcome,
|
|
31
39
|
} from './scheduler.js'
|
package/src/graph/priorities.ts
CHANGED
|
@@ -347,10 +347,14 @@ export function strategyPriorities(
|
|
|
347
347
|
return out
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/**
|
|
351
|
+
* A task's merged weight, read as asked: a merged copy held an entry per
|
|
352
|
+
* task of the run beside the two maps it was made of.
|
|
353
|
+
*/
|
|
350
354
|
export function mergePriorities(
|
|
351
355
|
baseline: ReadonlyMap<string, number>,
|
|
352
356
|
overrides: ReadonlyMap<string, number>,
|
|
353
|
-
):
|
|
357
|
+
): (id: string) => number {
|
|
354
358
|
// The override caller scored "expected critical-path duration" in ms.
|
|
355
359
|
// Baseline reverse-deps counts are bounded by N; scale the override so
|
|
356
360
|
// it sorts above the baseline for any node it covers, and add the
|
|
@@ -361,10 +365,9 @@ export function mergePriorities(
|
|
|
361
365
|
const SCALE = 1 << 20
|
|
362
366
|
let lo = 0
|
|
363
367
|
for (const w of overrides.values()) if (w < lo) lo = w
|
|
364
|
-
|
|
365
|
-
for (const [id, w] of overrides) {
|
|
368
|
+
return (id) => {
|
|
366
369
|
const b = baseline.get(id) ?? 0
|
|
367
|
-
|
|
370
|
+
const w = overrides.get(id)
|
|
371
|
+
return w === undefined ? b : (w - lo + 1) * SCALE + b
|
|
368
372
|
}
|
|
369
|
-
return out
|
|
370
373
|
}
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -33,6 +33,81 @@ export class RestoreDemoted extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Thrown by `execute` when restoring a cache hit threw (a full disk, a
|
|
38
|
+
* path in the way, a link out of the project). The run reports `error`
|
|
39
|
+
* as any throw, and the failed outcome says `restoreFailed`: a failed
|
|
40
|
+
* task, counted as neither a miss nor a hit.
|
|
41
|
+
*/
|
|
42
|
+
export class RestoreFailed extends Error {
|
|
43
|
+
constructor(readonly error: unknown) {
|
|
44
|
+
super(thrownMessage(error))
|
|
45
|
+
this.name = 'RestoreFailed'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Thrown by `execute` when deriving the task's key threw (a runtime probe
|
|
51
|
+
* that exited non-zero, a refused `inputs.env` value). The command never
|
|
52
|
+
* ran: the failed outcome is marked (`isKeyFailed`), and the line says so.
|
|
53
|
+
*/
|
|
54
|
+
export class KeyFailed extends Error {
|
|
55
|
+
constructor(readonly error: unknown) {
|
|
56
|
+
super(thrownMessage(error))
|
|
57
|
+
this.name = 'KeyFailed'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A mark, not a field: `TaskOutcome` and `RunPlan` are public types. A symbol
|
|
62
|
+
// rides a spread copy and never reaches JSON.
|
|
63
|
+
const KEY_FAILED = Symbol('keyFailed')
|
|
64
|
+
|
|
65
|
+
/** Mark a failed outcome (or a plan holding one) as having no derivable key. */
|
|
66
|
+
export function markKeyFailed<T extends object>(o: T): T {
|
|
67
|
+
;(o as Record<symbol, unknown>)[KEY_FAILED] = true
|
|
68
|
+
return o
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether `markKeyFailed` marked it: no key, so no command ran and no cache was asked. */
|
|
72
|
+
export function isKeyFailed(o: object): boolean {
|
|
73
|
+
return (o as Record<symbol, unknown>)[KEY_FAILED] === true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Thrown by `execute` when vx's own work on a miss's outputs threw: the
|
|
78
|
+
* clean before an attempt, or the save after an exit 0 (a name that is
|
|
79
|
+
* not UTF-8). The outcome keeps `exitCode` and `durationMs` (what the
|
|
80
|
+
* command returned and took so far) and is marked (`outputsFailedOf`),
|
|
81
|
+
* so no label names an exit the command never returned.
|
|
82
|
+
*/
|
|
83
|
+
export class OutputsFailed extends Error {
|
|
84
|
+
constructor(
|
|
85
|
+
readonly error: unknown,
|
|
86
|
+
readonly step: OutputsStep,
|
|
87
|
+
readonly exitCode: number,
|
|
88
|
+
readonly durationMs: number,
|
|
89
|
+
) {
|
|
90
|
+
super(thrownMessage(error))
|
|
91
|
+
this.name = 'OutputsFailed'
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type OutputsStep = 'clean' | 'save'
|
|
96
|
+
|
|
97
|
+
// A mark, as KEY_FAILED: `TaskOutcome` is a public type.
|
|
98
|
+
const OUTPUTS_FAILED = Symbol('outputsFailed')
|
|
99
|
+
|
|
100
|
+
/** Mark a failed outcome (or its view) with the output step that failed it. */
|
|
101
|
+
export function markOutputsFailed<T extends object>(o: T, step: OutputsStep): T {
|
|
102
|
+
;(o as Record<symbol, unknown>)[OUTPUTS_FAILED] = step
|
|
103
|
+
return o
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The output step `markOutputsFailed` marked, if any. */
|
|
107
|
+
export function outputsFailedOf(o: object): OutputsStep | undefined {
|
|
108
|
+
return (o as Record<symbol, OutputsStep | undefined>)[OUTPUTS_FAILED]
|
|
109
|
+
}
|
|
110
|
+
|
|
36
111
|
/** What moved in a missed task's key since the last entry saved for it. */
|
|
37
112
|
export interface InputChanges {
|
|
38
113
|
/** How many components changed, appeared or went. 0: the same key, its entry gone. */
|
|
@@ -128,6 +203,13 @@ export interface TaskOutcome {
|
|
|
128
203
|
* so a dependant that folds `hash` saves nothing either (A-12).
|
|
129
204
|
*/
|
|
130
205
|
unkeyed?: true
|
|
206
|
+
/**
|
|
207
|
+
* The input this task's own re-check found moved while it ran (absolute),
|
|
208
|
+
* or null when the key re-derived before the command already differed.
|
|
209
|
+
* The command read a state of it no `vx watch` judgement saw, so the
|
|
210
|
+
* loop counts its next settled state as a change.
|
|
211
|
+
*/
|
|
212
|
+
driftedInput?: string | null
|
|
131
213
|
/**
|
|
132
214
|
* The task with no `inputs` this task's key folds (this task, or one
|
|
133
215
|
* upstream): no run can derive that key again, so it neither read nor
|
|
@@ -148,6 +230,14 @@ export interface TaskOutcome {
|
|
|
148
230
|
* read this one field (item 267).
|
|
149
231
|
*/
|
|
150
232
|
blockedBy?: string
|
|
233
|
+
/**
|
|
234
|
+
* On an outcome that ran behind a failed dependency (`continueMode:
|
|
235
|
+
* 'always'`), one `--exclude-dependencies` dropped, or a server that died
|
|
236
|
+
* while it ran: the task at the root of it. Its result is not its key's, so no flaky verdict reads it and
|
|
237
|
+
* its run row names the root as a skip's `blockedBy` does. Set by `run()`
|
|
238
|
+
* before any observer (a plugin's `onTaskComplete`) hears the outcome.
|
|
239
|
+
*/
|
|
240
|
+
taintedBy?: string
|
|
151
241
|
/**
|
|
152
242
|
* On a `skipped` outcome: its `exec.platforms` leave out this machine.
|
|
153
243
|
* Its dependants are skipped with `blockedBy` naming it, and neither
|
|
@@ -206,6 +296,12 @@ export interface TaskOutcome {
|
|
|
206
296
|
outputs?: 'deferred'
|
|
207
297
|
/** The task ran here and its result went to the cache. The footer's `↑`. */
|
|
208
298
|
saved?: true
|
|
299
|
+
/**
|
|
300
|
+
* On a `failed` outcome: a cache hit whose restore threw, so no command
|
|
301
|
+
* ran. It counts as failed only, in no cache column: not a miss, not
|
|
302
|
+
* its layer's hit.
|
|
303
|
+
*/
|
|
304
|
+
restoreFailed?: true
|
|
209
305
|
/**
|
|
210
306
|
* v11 analytics: hrtime span relative to the parent run's t=0.
|
|
211
307
|
* Lets downstream analytics reconstruct the actual parallel timeline
|
|
@@ -336,6 +432,8 @@ export interface ScheduleOptions {
|
|
|
336
432
|
* dispatch, the slot and the promise around it were ~10 µs of each of a
|
|
337
433
|
* warm run's 1,090 groups (X-192); a hit whose tree is current does no
|
|
338
434
|
* more. A restore-tier task's `upstream` may hold undefined entries.
|
|
435
|
+
* The thunk's outcome is the scheduler's: its queue time is set on it,
|
|
436
|
+
* not on a copy (a copy per hit, 2026-11-07).
|
|
339
437
|
*/
|
|
340
438
|
settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
|
|
341
439
|
/**
|
|
@@ -431,23 +529,23 @@ export interface ScheduleOptions {
|
|
|
431
529
|
class ReadyHeap {
|
|
432
530
|
private readonly ids: string[] = []
|
|
433
531
|
private readonly seq: number[] = []
|
|
434
|
-
// Each slot's priority, read once at push: a
|
|
532
|
+
// Each slot's priority, read once at push: a lookup per comparison
|
|
435
533
|
// was two per heap level on every push and pop.
|
|
436
534
|
private readonly prio: number[] = []
|
|
437
535
|
private next = 0
|
|
438
536
|
/** Queued ids `counts` holds true for. */
|
|
439
537
|
counted = 0
|
|
440
538
|
constructor(
|
|
441
|
-
private priority:
|
|
539
|
+
private priority: (id: string) => number,
|
|
442
540
|
private readonly counts?: (id: string) => boolean,
|
|
443
541
|
) {}
|
|
444
542
|
get size(): number {
|
|
445
543
|
return this.ids.length
|
|
446
544
|
}
|
|
447
545
|
/** Re-rank every queued id under `priority`, and every later push. */
|
|
448
|
-
reprioritize(priority:
|
|
546
|
+
reprioritize(priority: (id: string) => number): void {
|
|
449
547
|
this.priority = priority
|
|
450
|
-
for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority
|
|
548
|
+
for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority(this.ids[i]!)
|
|
451
549
|
for (let i = (this.ids.length >> 1) - 1; i >= 0; i--) this.down(i)
|
|
452
550
|
}
|
|
453
551
|
private down(i: number): void {
|
|
@@ -498,7 +596,7 @@ class ReadyHeap {
|
|
|
498
596
|
if (this.counts?.(id) === true) this.counted++
|
|
499
597
|
this.ids.push(id)
|
|
500
598
|
this.seq.push(seq)
|
|
501
|
-
this.prio.push(this.priority
|
|
599
|
+
this.prio.push(this.priority(id))
|
|
502
600
|
let i = this.ids.length - 1
|
|
503
601
|
while (i > 0) {
|
|
504
602
|
const parent = (i - 1) >> 1
|
|
@@ -530,11 +628,13 @@ class ReadyHeap {
|
|
|
530
628
|
}
|
|
531
629
|
|
|
532
630
|
/**
|
|
533
|
-
* The dead server a dependency stands for: itself, or one a group
|
|
534
|
-
* A group is a name for its deps, and it finished the
|
|
535
|
-
* was ready, so a task behind it ran against the dead
|
|
536
|
-
*
|
|
537
|
-
*
|
|
631
|
+
* The dead server a dependency stands for: itself, or one a group or a
|
|
632
|
+
* server reaches. A group is a name for its deps, and it finished the
|
|
633
|
+
* moment the server was ready, so a task behind it ran against the dead
|
|
634
|
+
* one; a server stays up on the servers it depends on, so a task behind
|
|
635
|
+
* `web` runs against the `api` under it too. Walked with a stack and a
|
|
636
|
+
* seen set: recursion threw `RangeError` on a deep chain of groups, and
|
|
637
|
+
* re-walked a group each path reached it by.
|
|
538
638
|
*/
|
|
539
639
|
export function deadServerBehind(
|
|
540
640
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
@@ -549,7 +649,7 @@ export function deadServerBehind(
|
|
|
549
649
|
seen.add(at)
|
|
550
650
|
if (serverDied(at)) return at
|
|
551
651
|
const n = nodes.get(at)
|
|
552
|
-
if (n === undefined || !isGroupTask(n)) continue
|
|
652
|
+
if (n === undefined || (!isGroupTask(n) && n.config.exec?.persistent === undefined)) continue
|
|
553
653
|
// Reversed, so the first dep is walked first, as the order it names them.
|
|
554
654
|
for (let i = n.deps.length - 1; i >= 0; i--) stack.push(n.deps[i]!)
|
|
555
655
|
}
|
|
@@ -606,9 +706,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
606
706
|
: options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
607
707
|
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
608
708
|
: (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
|
|
609
|
-
const priority:
|
|
709
|
+
const priority: (id: string) => number = options.priorities
|
|
610
710
|
? mergePriorities(baseline, options.priorities)
|
|
611
|
-
: baseline
|
|
711
|
+
: (id) => baseline.get(id) ?? 0
|
|
612
712
|
|
|
613
713
|
// Two ready queues — exec-tier (cache misses + unstable tasks) and
|
|
614
714
|
// restore-tier (confirmed stable local hits). Each is a max-heap keyed by
|
|
@@ -627,7 +727,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
627
727
|
// (`dormant`) until a dependant is demoted, which starts it, or until
|
|
628
728
|
// every dependant has finished, which settles it unspawned. Settled
|
|
629
729
|
// without an outcome: it is not part of the run, as a pruned task is not.
|
|
630
|
-
// A group between it and its hits is idle with it.
|
|
730
|
+
// A group or a server between it and its hits is idle with it.
|
|
631
731
|
const idleGroups = new Set<string>()
|
|
632
732
|
const idle = idleServers(nodes, dependents, restoreTier, idleGroups)
|
|
633
733
|
for (const g of idleGroups) idle.add(g)
|
|
@@ -684,7 +784,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
684
784
|
// complete.
|
|
685
785
|
// When each queued id became ready; dispatch takes it out.
|
|
686
786
|
const readyAt = new Map<string, number>()
|
|
687
|
-
|
|
787
|
+
// Elapsed-time stamps (queue and hold waits), immune to wall clock steps.
|
|
788
|
+
const startedAt = performance.now()
|
|
688
789
|
for (const node of nodes.values()) {
|
|
689
790
|
if (restoreTier?.has(node.id)) restoreReady.push(node.id)
|
|
690
791
|
else if (node.deps.length === 0) execReady.push(node.id)
|
|
@@ -896,7 +997,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
896
997
|
if (heldRelease.delete(d)) enter(d)
|
|
897
998
|
else if (!inRestoreTier(d)) {
|
|
898
999
|
execReady.push(d)
|
|
899
|
-
readyAt.set(d,
|
|
1000
|
+
readyAt.set(d, performance.now())
|
|
900
1001
|
}
|
|
901
1002
|
}
|
|
902
1003
|
}
|
|
@@ -1027,7 +1128,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1027
1128
|
if (hasRoom(id)) {
|
|
1028
1129
|
if (!admitActive || admits(id)) return id
|
|
1029
1130
|
// A free worker, refused by the policy: the hold starts now.
|
|
1030
|
-
if (!heldSince.has(id)) heldSince.set(id,
|
|
1131
|
+
if (!heldSince.has(id)) heldSince.set(id, performance.now())
|
|
1031
1132
|
}
|
|
1032
1133
|
parked.push([id, seq])
|
|
1033
1134
|
}
|
|
@@ -1107,7 +1208,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1107
1208
|
continue
|
|
1108
1209
|
}
|
|
1109
1210
|
|
|
1110
|
-
const
|
|
1211
|
+
const readySince = readyAt.get(id)
|
|
1212
|
+
const queuedMs = readySince === undefined ? 0 : Math.round(performance.now() - readySince)
|
|
1111
1213
|
readyAt.delete(id)
|
|
1112
1214
|
const settle = settleInPlace(node, upstream)
|
|
1113
1215
|
let now: TaskOutcome | undefined
|
|
@@ -1120,7 +1222,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1120
1222
|
}
|
|
1121
1223
|
}
|
|
1122
1224
|
if (now !== undefined) {
|
|
1123
|
-
|
|
1225
|
+
if (queuedMs !== 0) now.queuedMs = queuedMs
|
|
1226
|
+
finishOne(id, now)
|
|
1124
1227
|
continue
|
|
1125
1228
|
}
|
|
1126
1229
|
const leave = admit(id)
|
|
@@ -1128,7 +1231,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1128
1231
|
// tick sees it; the completion callbacks unlist it.
|
|
1129
1232
|
const untrack = track(id)
|
|
1130
1233
|
const since = admitActive ? heldSince.get(id) : undefined
|
|
1131
|
-
const heldMs = since === undefined ? 0 : Math.max(1,
|
|
1234
|
+
const heldMs = since === undefined ? 0 : Math.max(1, Math.round(performance.now() - since))
|
|
1132
1235
|
if (since !== undefined) heldSince.delete(id)
|
|
1133
1236
|
const withHold = (o: TaskOutcome): TaskOutcome =>
|
|
1134
1237
|
heldMs === 0 && queuedMs === 0
|
|
@@ -1177,8 +1280,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1177
1280
|
}
|
|
1178
1281
|
void settled.then(done, () => done())
|
|
1179
1282
|
},
|
|
1180
|
-
(
|
|
1181
|
-
if (
|
|
1283
|
+
(thrown: unknown) => {
|
|
1284
|
+
if (thrown instanceof RestoreDemoted) {
|
|
1182
1285
|
leave()
|
|
1183
1286
|
untrack()
|
|
1184
1287
|
demoted.add(id)
|
|
@@ -1186,16 +1289,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1186
1289
|
idle.delete(p)
|
|
1187
1290
|
if (dormant.delete(p)) {
|
|
1188
1291
|
execReady.push(p)
|
|
1189
|
-
readyAt.set(p,
|
|
1292
|
+
readyAt.set(p, performance.now())
|
|
1190
1293
|
}
|
|
1191
1294
|
})
|
|
1192
1295
|
if (pending.get(id) === 0) {
|
|
1193
1296
|
execReady.push(id)
|
|
1194
|
-
readyAt.set(id,
|
|
1297
|
+
readyAt.set(id, performance.now())
|
|
1195
1298
|
}
|
|
1196
1299
|
tick()
|
|
1197
1300
|
return
|
|
1198
1301
|
}
|
|
1302
|
+
const err =
|
|
1303
|
+
thrown instanceof RestoreFailed ||
|
|
1304
|
+
thrown instanceof KeyFailed ||
|
|
1305
|
+
thrown instanceof OutputsFailed
|
|
1306
|
+
? thrown.error
|
|
1307
|
+
: thrown
|
|
1199
1308
|
const message = thrownMessage(err)
|
|
1200
1309
|
// After the run's stop a rejection is the stop's doing (a probe
|
|
1201
1310
|
// it killed, C-65): aborted, as any task it kills, and unsaid.
|
|
@@ -1203,9 +1312,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1203
1312
|
const outcome: TaskOutcome = withHold({
|
|
1204
1313
|
node,
|
|
1205
1314
|
status: stopped ? 'aborted' : 'failed',
|
|
1206
|
-
exitCode: 1,
|
|
1207
|
-
durationMs: 0,
|
|
1315
|
+
exitCode: thrown instanceof OutputsFailed ? thrown.exitCode : 1,
|
|
1316
|
+
durationMs: thrown instanceof OutputsFailed ? thrown.durationMs : 0,
|
|
1317
|
+
...(thrown instanceof RestoreFailed && !stopped ? { restoreFailed: true } : {}),
|
|
1208
1318
|
})
|
|
1319
|
+
if (thrown instanceof KeyFailed && !stopped) markKeyFailed(outcome)
|
|
1320
|
+
if (thrown instanceof OutputsFailed && !stopped) markOutputsFailed(outcome, thrown.step)
|
|
1209
1321
|
leave()
|
|
1210
1322
|
untrack()
|
|
1211
1323
|
if (stopped) {
|
|
@@ -1220,9 +1332,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1220
1332
|
// Out of descriptors is the environment's limit too: a stack
|
|
1221
1333
|
// naming a file that was fine sent the user after the file.
|
|
1222
1334
|
if (isUserError(err) || isFsRefusal(err) || isOutOfFds(err)) {
|
|
1223
|
-
const
|
|
1335
|
+
const said = isUserError(err)
|
|
1224
1336
|
? message
|
|
1225
1337
|
: `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
|
|
1338
|
+
const text = thrown instanceof KeyFailed ? `cache key not derived: ${said}` : said
|
|
1226
1339
|
// In a task's own output block, in full: that block is read
|
|
1227
1340
|
// alone, and "as <task> above" named one printed elsewhere or
|
|
1228
1341
|
// not at all (2026-10-22). On bare stderr the first is above.
|
|
@@ -1290,9 +1403,25 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1290
1403
|
continue
|
|
1291
1404
|
promoted.add(d)
|
|
1292
1405
|
restoreReady.push(d)
|
|
1293
|
-
readyAt.set(d,
|
|
1406
|
+
readyAt.set(d, performance.now())
|
|
1407
|
+
}
|
|
1408
|
+
// Idle as `idleServers` finds it: through the groups and servers
|
|
1409
|
+
// nobody asked for above it. The servers beneath it are held on the
|
|
1410
|
+
// same keys, so the walk idles them with it; a server already
|
|
1411
|
+
// answered, or never held, keeps its course.
|
|
1412
|
+
if (found.length > 0) {
|
|
1413
|
+
const tier =
|
|
1414
|
+
demoted.size === 0 ? restoreTier : new Set([...restoreTier!].filter(inRestoreTier))
|
|
1415
|
+
const between = new Set<string>()
|
|
1416
|
+
const holdsHere = (s: string): boolean =>
|
|
1417
|
+
s === id || unanswered.has(s) || isGroupTask(nodes.get(s) as TaskNode)
|
|
1418
|
+
for (const s of idleServers(nodes, dependents, tier, between)) if (holdsHere(s)) idle.add(s)
|
|
1419
|
+
for (const g of between)
|
|
1420
|
+
if (holdsHere(g) && !idleGroups.has(g)) {
|
|
1421
|
+
idleGroups.add(g)
|
|
1422
|
+
idle.add(g)
|
|
1423
|
+
}
|
|
1294
1424
|
}
|
|
1295
|
-
if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
|
|
1296
1425
|
if (heldReady.delete(id)) execReady.push(id)
|
|
1297
1426
|
tick()
|
|
1298
1427
|
}
|
|
@@ -1320,10 +1449,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1320
1449
|
|
|
1321
1450
|
/**
|
|
1322
1451
|
* Persistent tasks nobody asked for whose every dependant is a restore-tier
|
|
1323
|
-
* hit, or a group nobody asked for that only such dependants
|
|
1324
|
-
* a group runs nothing, so `env: { dependsOn: ['dev'] }`
|
|
1325
|
-
* and its hits booted the server on every warm run
|
|
1326
|
-
* `groups
|
|
1452
|
+
* hit, or a group or server nobody asked for that only such dependants
|
|
1453
|
+
* stand behind: a group runs nothing, so `env: { dependsOn: ['dev'] }`
|
|
1454
|
+
* between the server and its hits booted the server on every warm run, and
|
|
1455
|
+
* `api` under an idle `web` serves nobody either. Those groups and servers
|
|
1456
|
+
* go in `groups`: the scheduler settles them as it settles a group.
|
|
1327
1457
|
*/
|
|
1328
1458
|
export function idleServers(
|
|
1329
1459
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
@@ -1334,10 +1464,10 @@ export function idleServers(
|
|
|
1334
1464
|
const out = new Set<string>()
|
|
1335
1465
|
if (restoreTier === undefined || restoreTier.size === 0) return out
|
|
1336
1466
|
const unasked = (n: TaskNode): boolean => !n.requested && n.surfaced !== true
|
|
1337
|
-
// Per group: whether only hits stand behind it. Post-order on a
|
|
1338
|
-
// groups nest as deep as the graph.
|
|
1467
|
+
// Per group or server: whether only hits stand behind it. Post-order on a
|
|
1468
|
+
// stack, as groups and servers nest as deep as the graph.
|
|
1339
1469
|
const spare = new Map<string, boolean>()
|
|
1340
|
-
const
|
|
1470
|
+
const spareNode = (root: string): boolean => {
|
|
1341
1471
|
const stack = [root]
|
|
1342
1472
|
while (stack.length > 0) {
|
|
1343
1473
|
const id = stack[stack.length - 1]!
|
|
@@ -1347,7 +1477,12 @@ export function idleServers(
|
|
|
1347
1477
|
}
|
|
1348
1478
|
const n = nodes.get(id)
|
|
1349
1479
|
const ds = dependents.get(id)
|
|
1350
|
-
if (
|
|
1480
|
+
if (
|
|
1481
|
+
n === undefined ||
|
|
1482
|
+
(!isGroupTask(n) && n.config.exec?.persistent === undefined) ||
|
|
1483
|
+
!unasked(n) ||
|
|
1484
|
+
ds === undefined
|
|
1485
|
+
) {
|
|
1351
1486
|
spare.set(id, false)
|
|
1352
1487
|
stack.pop()
|
|
1353
1488
|
continue
|
|
@@ -1368,7 +1503,7 @@ export function idleServers(
|
|
|
1368
1503
|
for (const n of nodes.values()) {
|
|
1369
1504
|
if (n.config.exec?.persistent === undefined || !unasked(n)) continue
|
|
1370
1505
|
const ds = dependents.get(n.id)
|
|
1371
|
-
if (ds === undefined || !ds.every((d) => restoreTier.has(d) ||
|
|
1506
|
+
if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareNode(d))) continue
|
|
1372
1507
|
out.add(n.id)
|
|
1373
1508
|
if (groups === undefined) continue
|
|
1374
1509
|
const stack = ds.filter((d) => !restoreTier.has(d))
|