@vzn/vx 0.0.626 → 0.0.628
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/package.json +5 -5
- package/src/cache/key-fold.ts +3 -1
- package/src/cache/layer.ts +4 -1
- package/src/exec/index.ts +2 -0
- package/src/exec/runner.ts +40 -23
- package/src/orchestrator/execute-task.ts +26 -14
- package/src/orchestrator/hit-restore.ts +9 -3
- package/src/orchestrator/output-log.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.628",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.628",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.628",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.628",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.628"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -103,7 +103,9 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
103
103
|
// bytes on disk, the filter since removed, holds the CRLF build (A-60).
|
|
104
104
|
// v39: an entry saved while a gitlink without `.git` held files the key
|
|
105
105
|
// never listed sits under the key of that directory empty (A-61).
|
|
106
|
-
|
|
106
|
+
// v42: the stored output is both streams in order (orchestrator/output-log.ts);
|
|
107
|
+
// a v41 entry holds stdout alone and replays no stderr.
|
|
108
|
+
export const CACHE_VERSION = 'vx-cache-v42'
|
|
107
109
|
|
|
108
110
|
/**
|
|
109
111
|
* The digest a key folds for an input gone between its enumeration and its
|
package/src/cache/layer.ts
CHANGED
|
@@ -147,7 +147,10 @@ export interface CacheEntry {
|
|
|
147
147
|
* per hit (0.12 ms each across a warm 1000-project run before this).
|
|
148
148
|
*/
|
|
149
149
|
outputDirRows?: OutputDirRow[]
|
|
150
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* The task's output log, always present (may be empty): both streams in
|
|
152
|
+
* the order the run printed them (`orchestrator/output-log.ts`).
|
|
153
|
+
*/
|
|
151
154
|
stdout: string
|
|
152
155
|
storedAt: string
|
|
153
156
|
/**
|
package/src/exec/index.ts
CHANGED
package/src/exec/runner.ts
CHANGED
|
@@ -1125,56 +1125,73 @@ const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
|
|
|
1125
1125
|
|
|
1126
1126
|
const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
|
|
1127
1127
|
|
|
1128
|
+
/** One piece of a task's output and the stream it came on. */
|
|
1129
|
+
export interface CapturedChunk {
|
|
1130
|
+
text: string
|
|
1131
|
+
/** True when the task wrote it to stderr. */
|
|
1132
|
+
err: boolean
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1128
1135
|
/**
|
|
1129
1136
|
* A head-and-tail accumulator: the head fills once, the tail is a ring
|
|
1130
1137
|
* of chunks trimmed from the front, so memory is bounded by the two
|
|
1131
|
-
* limits plus one chunk whatever the task prints.
|
|
1138
|
+
* limits plus one chunk whatever the task prints. Each chunk keeps its
|
|
1139
|
+
* stream, so one capture can hold both in the order they arrived.
|
|
1132
1140
|
*/
|
|
1133
|
-
class BoundedCapture {
|
|
1134
|
-
private head =
|
|
1135
|
-
private
|
|
1141
|
+
export class BoundedCapture {
|
|
1142
|
+
private readonly head: CapturedChunk[] = []
|
|
1143
|
+
private headLen = 0
|
|
1144
|
+
private readonly tail: CapturedChunk[] = []
|
|
1136
1145
|
private tailLen = 0
|
|
1137
1146
|
private dropped = 0
|
|
1138
1147
|
|
|
1139
|
-
push(
|
|
1148
|
+
push(text: string, err = false): void {
|
|
1140
1149
|
// Once the tail has begun the head is closed, even a unit short of its
|
|
1141
1150
|
// bound (the surrogate case below): a later short chunk fit there and
|
|
1142
1151
|
// was retained ahead of the chunk before it.
|
|
1143
|
-
if (this.tailLen === 0 && this.
|
|
1144
|
-
let room = CAPTURE_HEAD_CHARS - this.
|
|
1145
|
-
if (
|
|
1146
|
-
this.head
|
|
1152
|
+
if (this.tailLen === 0 && this.headLen < CAPTURE_HEAD_CHARS) {
|
|
1153
|
+
let room = CAPTURE_HEAD_CHARS - this.headLen
|
|
1154
|
+
if (text.length <= room) {
|
|
1155
|
+
this.head.push({ text, err })
|
|
1156
|
+
this.headLen += text.length
|
|
1147
1157
|
return
|
|
1148
1158
|
}
|
|
1149
1159
|
// Never between a surrogate pair's halves: the bounds count UTF-16
|
|
1150
1160
|
// units, and a halved character read U+FFFD in the replay.
|
|
1151
|
-
if (isHighSurrogate(
|
|
1152
|
-
this.head
|
|
1153
|
-
|
|
1161
|
+
if (isHighSurrogate(text.charCodeAt(room - 1))) room--
|
|
1162
|
+
this.head.push({ text: text.slice(0, room), err })
|
|
1163
|
+
this.headLen += room
|
|
1164
|
+
text = text.slice(room)
|
|
1154
1165
|
}
|
|
1155
|
-
this.tail.push(
|
|
1156
|
-
this.tailLen +=
|
|
1166
|
+
this.tail.push({ text, err })
|
|
1167
|
+
this.tailLen += text.length
|
|
1157
1168
|
while (this.tailLen > CAPTURE_TAIL_CHARS) {
|
|
1158
1169
|
const first = this.tail[0]!
|
|
1159
1170
|
const excess = this.tailLen - CAPTURE_TAIL_CHARS
|
|
1160
|
-
if (first.length <= excess) {
|
|
1171
|
+
if (first.text.length <= excess) {
|
|
1161
1172
|
this.tail.shift()
|
|
1162
|
-
this.tailLen -= first.length
|
|
1163
|
-
this.dropped += first.length
|
|
1173
|
+
this.tailLen -= first.text.length
|
|
1174
|
+
this.dropped += first.text.length
|
|
1164
1175
|
} else {
|
|
1165
|
-
const cut = isHighSurrogate(first.charCodeAt(excess - 1)) ? excess + 1 : excess
|
|
1166
|
-
this.tail[0] = first.slice(cut)
|
|
1176
|
+
const cut = isHighSurrogate(first.text.charCodeAt(excess - 1)) ? excess + 1 : excess
|
|
1177
|
+
this.tail[0] = { text: first.text.slice(cut), err: first.err }
|
|
1167
1178
|
this.tailLen -= cut
|
|
1168
1179
|
this.dropped += cut
|
|
1169
1180
|
}
|
|
1170
1181
|
}
|
|
1171
1182
|
}
|
|
1172
1183
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1184
|
+
/** The kept chunks in arrival order, the dropped middle named on stdout. */
|
|
1185
|
+
chunks(): CapturedChunk[] {
|
|
1175
1186
|
return this.dropped === 0
|
|
1176
|
-
? this.head
|
|
1177
|
-
: this.head
|
|
1187
|
+
? [...this.head, ...this.tail]
|
|
1188
|
+
: [...this.head, { text: droppedOutputLine(this.dropped), err: false }, ...this.tail]
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
text(): string {
|
|
1192
|
+
return this.chunks()
|
|
1193
|
+
.map((c) => c.text)
|
|
1194
|
+
.join('')
|
|
1178
1195
|
}
|
|
1179
1196
|
}
|
|
1180
1197
|
|
|
@@ -39,7 +39,9 @@ import {
|
|
|
39
39
|
PersistentReadyError,
|
|
40
40
|
sandboxReads,
|
|
41
41
|
maskCaptured,
|
|
42
|
+
BoundedCapture,
|
|
42
43
|
} from '../exec/index.js'
|
|
44
|
+
import { encodeOutputLog } from './output-log.js'
|
|
43
45
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
44
46
|
import {
|
|
45
47
|
killGraceMs,
|
|
@@ -644,17 +646,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
644
646
|
let unkeyed = unkeyedUpstream !== undefined
|
|
645
647
|
const willSave = willWrite && args.taintedUpstream !== true && !unkeyed
|
|
646
648
|
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
// stored stderr — see the v17 artifact format). Both streams are still
|
|
652
|
-
// drained and still stream chunk-by-chunk to the logger; only the retained
|
|
653
|
-
// copy is dropped, which for a chatty task is its full byte size in heap.
|
|
649
|
+
// The executor retains nothing: the entry's output is the attempt's
|
|
650
|
+
// `output` log, both streams in the order they reached the logger, kept
|
|
651
|
+
// only when this task will WRITE an entry. Both streams are still drained
|
|
652
|
+
// and still stream chunk-by-chunk to the logger.
|
|
654
653
|
// Deferral is decided at plan time and only ever set for a remote-placed,
|
|
655
654
|
// eligibility-cleared task; it suppresses this machine's clean AND save.
|
|
656
655
|
const deferralRequested = args.download === 'deferred'
|
|
657
|
-
const capture: CaptureConfig = { stdout:
|
|
656
|
+
const capture: CaptureConfig = { stdout: false, stderr: false }
|
|
658
657
|
|
|
659
658
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
660
659
|
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
@@ -1199,6 +1198,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1199
1198
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
1200
1199
|
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
1201
1200
|
let flushMasked = (): void => {}
|
|
1201
|
+
// The attempt's output as the logger got it (masked), for the entry.
|
|
1202
|
+
let output: BoundedCapture | undefined
|
|
1203
|
+
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
|
|
1202
1204
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1203
1205
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1204
1206
|
let untrack: (() => void) | undefined
|
|
@@ -1288,8 +1290,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1288
1290
|
}
|
|
1289
1291
|
|
|
1290
1292
|
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
+
const kept = willSave ? new BoundedCapture() : undefined
|
|
1294
|
+
output = kept
|
|
1295
|
+
const toOut = (t: string): void => {
|
|
1296
|
+
kept?.push(t)
|
|
1297
|
+
log.taskStdout(node, t)
|
|
1298
|
+
}
|
|
1299
|
+
const toErr = (t: string): void => {
|
|
1300
|
+
kept?.push(t, true)
|
|
1301
|
+
log.taskStderr(node, t)
|
|
1302
|
+
}
|
|
1303
|
+
const out = secrets && maskedEmitter(secrets, toOut)
|
|
1304
|
+
const err = secrets && maskedEmitter(secrets, toErr)
|
|
1293
1305
|
flushMasked = () => {
|
|
1294
1306
|
out?.end()
|
|
1295
1307
|
err?.end()
|
|
@@ -1303,8 +1315,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1303
1315
|
env,
|
|
1304
1316
|
envDefine: step.env?.define ?? {},
|
|
1305
1317
|
capture,
|
|
1306
|
-
onStdout: out ? (chunk) => out.push(chunk) :
|
|
1307
|
-
onStderr: err ? (chunk) => err.push(chunk) :
|
|
1318
|
+
onStdout: out ? (chunk) => out.push(chunk) : toOut,
|
|
1319
|
+
onStderr: err ? (chunk) => err.push(chunk) : toErr,
|
|
1308
1320
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
1309
1321
|
...(args.track !== undefined
|
|
1310
1322
|
? {
|
|
@@ -1364,7 +1376,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1364
1376
|
taskId: node.id,
|
|
1365
1377
|
command: storedCommand,
|
|
1366
1378
|
durationMs: result.durationMs,
|
|
1367
|
-
stdout:
|
|
1379
|
+
stdout: storedOutput(),
|
|
1368
1380
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1369
1381
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
1370
1382
|
},
|
|
@@ -1390,7 +1402,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1390
1402
|
captured,
|
|
1391
1403
|
command: storedCommand,
|
|
1392
1404
|
durationMs: result.durationMs,
|
|
1393
|
-
stdout:
|
|
1405
|
+
stdout: storedOutput(),
|
|
1394
1406
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1395
1407
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
1396
1408
|
outputDirSnapshots: args.outputDirSnapshots,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '../util/index.js'
|
|
31
31
|
import type { ExecuteArgs } from './execute-task.js'
|
|
32
32
|
import { entryHolds } from './miss-save.js'
|
|
33
|
+
import { decodeOutputLog } from './output-log.js'
|
|
33
34
|
|
|
34
35
|
export interface RestoreHitArgs {
|
|
35
36
|
args: ExecuteArgs
|
|
@@ -45,7 +46,7 @@ export interface RestoreHitArgs {
|
|
|
45
46
|
/**
|
|
46
47
|
* Materialize a confirmed cache hit: decide skip-restore, clean +
|
|
47
48
|
* restore declared outputs, mark the exact changed paths so downstream
|
|
48
|
-
* same-project tasks needn't re-spawn git, replay stored
|
|
49
|
+
* same-project tasks needn't re-spawn git, replay stored output, and
|
|
49
50
|
* build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
|
|
50
51
|
* so the local short-circuit can restore a stable-key hit ahead of the
|
|
51
52
|
* schedule using the IDENTICAL logic — there is one restore path, not
|
|
@@ -97,7 +98,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
97
98
|
// path pays one SELECT here. Either beats reading the manifest from
|
|
98
99
|
// the tar (decompress + parse) at the same point.
|
|
99
100
|
// A task with no declared outputs has nothing to clean or restore: its
|
|
100
|
-
//
|
|
101
|
+
// output replays from the row (`hit.stdout`), and its artifact holds
|
|
101
102
|
// only logs. Extracting it anyway cost an `exists` + a tar read per hit
|
|
102
103
|
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
103
104
|
// tasks declare `outputs: []` (2026-09-10, VX_TIMING).
|
|
@@ -296,7 +297,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
296
297
|
[process.env, node.config.exec?.env?.define],
|
|
297
298
|
node.config.exec?.env?.secret,
|
|
298
299
|
)
|
|
299
|
-
log
|
|
300
|
+
// The stored log holds both streams in the order the run printed them.
|
|
301
|
+
for (const c of decodeOutputLog(hit.stdout)) {
|
|
302
|
+
const text = secrets === null ? c.text : secrets.mask(c.text)
|
|
303
|
+
if (c.err) log.taskStderr(node, text)
|
|
304
|
+
else log.taskStdout(node, text)
|
|
305
|
+
}
|
|
300
306
|
}
|
|
301
307
|
const status =
|
|
302
308
|
hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A task's output as a cache entry stores it: both streams in the order
|
|
2
|
+
// vx read them, so a hit replays what the run showed. One string, so every
|
|
3
|
+
// cache layer carries it as it carried stdout: the text as written, with
|
|
4
|
+
// RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
|
|
5
|
+
// a literal RS doubled. Output that never touched stderr is its own text.
|
|
6
|
+
|
|
7
|
+
import type { CapturedChunk } from '../exec/index.js'
|
|
8
|
+
|
|
9
|
+
const RS = '\x1e'
|
|
10
|
+
|
|
11
|
+
/** `chunks` as one stored string; it starts on stdout. */
|
|
12
|
+
export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
|
|
13
|
+
let out = ''
|
|
14
|
+
let err = false
|
|
15
|
+
for (const c of chunks) {
|
|
16
|
+
if (c.text.length === 0) continue
|
|
17
|
+
if (c.err !== err) {
|
|
18
|
+
out += RS + (c.err ? 'e' : 'o')
|
|
19
|
+
err = c.err
|
|
20
|
+
}
|
|
21
|
+
out += c.text.replaceAll(RS, RS + RS)
|
|
22
|
+
}
|
|
23
|
+
return out
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
|
|
27
|
+
export function decodeOutputLog(log: string): CapturedChunk[] {
|
|
28
|
+
if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
|
|
29
|
+
const chunks: CapturedChunk[] = []
|
|
30
|
+
let err = false
|
|
31
|
+
let text = ''
|
|
32
|
+
const flush = (): void => {
|
|
33
|
+
if (text.length > 0) chunks.push({ text, err })
|
|
34
|
+
text = ''
|
|
35
|
+
}
|
|
36
|
+
let from = 0
|
|
37
|
+
for (let at = log.indexOf(RS); at !== -1; at = log.indexOf(RS, from)) {
|
|
38
|
+
text += log.slice(from, at)
|
|
39
|
+
const next = log[at + 1]
|
|
40
|
+
from = at + 2
|
|
41
|
+
if (next === 'e' || next === 'o') {
|
|
42
|
+
flush()
|
|
43
|
+
err = next === 'e'
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
text += RS
|
|
47
|
+
// A lone RS (at the end, or before any other character) is the text's own.
|
|
48
|
+
if (next !== RS) from = at + 1
|
|
49
|
+
}
|
|
50
|
+
text += log.slice(from)
|
|
51
|
+
flush()
|
|
52
|
+
return chunks
|
|
53
|
+
}
|