@zenera/cli 1.1.23 → 1.1.24

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 (57) hide show
  1. package/README.md +35 -2
  2. package/dist/ask.d.ts +51 -0
  3. package/dist/ask.js +143 -0
  4. package/dist/batch.d.ts +77 -0
  5. package/dist/batch.js +338 -0
  6. package/dist/command.d.ts +6 -0
  7. package/dist/commands/inspect.d.ts +11 -0
  8. package/dist/commands/inspect.js +388 -89
  9. package/dist/commands/list.js +128 -19
  10. package/dist/commands/memory.js +342 -12
  11. package/dist/commands/models.js +14 -2
  12. package/dist/commands/run.js +139 -34
  13. package/dist/commands/sandbox.js +72 -1
  14. package/dist/engine.d.ts +19 -0
  15. package/dist/engine.js +60 -8
  16. package/dist/liveness.js +6 -3
  17. package/dist/main.js +5 -2
  18. package/dist/podman.d.ts +16 -0
  19. package/dist/podman.js +115 -2
  20. package/dist/progress.d.ts +33 -0
  21. package/dist/progress.js +402 -0
  22. package/dist/request.d.ts +45 -0
  23. package/dist/request.js +300 -0
  24. package/dist/sandbox.js +10 -0
  25. package/dist/session.d.ts +26 -1
  26. package/dist/session.js +42 -3
  27. package/dist/term.d.ts +3 -0
  28. package/dist/term.js +17 -1
  29. package/dist/trace.d.ts +84 -0
  30. package/dist/trace.js +686 -0
  31. package/dist/validate.js +25 -3
  32. package/package.json +2 -2
  33. package/templates/editor/.github/copilot-instructions.md +11 -6
  34. package/templates/editor/.github/prompts/finetune.prompt.md +251 -0
  35. package/templates/editor/.github/skills/zen-cli/SKILL.md +9 -3
  36. package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
  37. package/templates/editor/.github/skills/zen-cli/references/inspect.md +262 -21
  38. package/templates/editor/.github/skills/zen-cli/references/memory.md +89 -19
  39. package/templates/editor/.github/skills/zen-cli/references/models.md +1 -1
  40. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  41. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  42. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1000 -0
  43. package/templates/editor/.github/skills/zen-finetune/scripts/collect.sh +432 -0
  44. package/templates/editor/.github/skills/zen-finetune/scripts/sample.sh +199 -0
  45. package/templates/editor/.github/skills/zen-inspect/SKILL.md +396 -0
  46. package/templates/editor/.github/skills/zen-memory/SKILL.md +74 -29
  47. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
  48. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +19 -0
  49. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  50. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  51. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  52. package/templates/editor/.vscode/agents.schema.json +1 -1
  53. package/templates/editor/.vscode/settings.json +1 -0
  54. package/templates/project/agents/files-instructions.md +13 -0
  55. package/templates/project/agents/fork-instructions.md +27 -0
  56. package/templates/project/agents/memory-instructions.md +80 -7
  57. package/templates/project/gitignore +3 -0
@@ -178,6 +178,25 @@ agents:
178
178
  | `env` | string[] | `[]` | Host environment variables to pass by name. |
179
179
  | `keys` | boolean | `true` | Forward model credentials to the container. |
180
180
 
181
+ ### `memory` and `cpus` divide a machine they cannot enlarge
182
+
183
+ On macOS and Windows these are shares of the podman VM, not of the host. The VM
184
+ is sized **once**, by whichever project first ran `zen sandbox up` on this
185
+ machine, and it defaults to **2048 MiB and 2 CPUs** however large the host is.
186
+ Raising `sandbox.memory` afterwards never resizes it — the init flags are only
187
+ read when no machine exists yet.
188
+
189
+ So the scaffolded default, `memory: 4096`, promises each container twice the
190
+ whole default VM, and podman will admit as many of them as you ask for without
191
+ complaining. Nothing reports this: `zen check` does not, and `zen sandbox
192
+ status` prints no memory figure at all. It surfaces later as an exit 137, or as
193
+ an exit 124 that looks like a slow index.
194
+
195
+ One container is rarely the problem. Before running several at once — a
196
+ `zen run batch` with `--concurrency` above 1 — load **zen-sandbox-capacity** and
197
+ run its `preflight_sandbox.sh`, which measures the VM, the container cap and the
198
+ index and says whether the arithmetic fits.
199
+
181
200
  ---
182
201
 
183
202
  ## 6. Hardening Postures: `standard` vs `strict`
@@ -0,0 +1,334 @@
1
+ ---
2
+ name: zen-sandbox-capacity
3
+ description: Whether the machine can actually run the batch you are about to start. The podman VM is the ceiling and it is sized once, at 2 GiB by default, whatever the host has - so N concurrent containers over a multi-gigabyte index thrash and are OOM-killed while the host sits idle. Covers the three layers (host, VM, container) and which one can add memory, the exit-code table (137 is always capacity, 124 is usually 137 in disguise, and raising `timeout:` converts one into the other and hides the fault), the memory budget and how to measure its terms instead of assuming them, separating `local` probes from `embed` ones so a starved index is never mistaken for a throttled provider, recording the machine spec so two rounds' token counts are comparable at all, and `scripts/preflight_sandbox.sh` as the gate that answers it in under a minute. Load before any `zen run batch`, before raising `--concurrency` above 1, on exit 137, on a `killed` message from the shell, on an exit 124 from a `zen rag` call, when a batch is slow or timed out, and before comparing tokens or wall-clock between two rounds. Not for a single interactive `zen run` - one container is not a capacity problem.
4
+ ---
5
+
6
+ # Capacity
7
+
8
+ A container is not a machine. On macOS every container `zen` starts lives inside
9
+ one Linux virtual machine, that VM has a fixed amount of memory, and **the
10
+ number is not the host's**. A laptop with 36 GiB routinely runs a 2 GiB VM, and
11
+ the host being nearly empty is no comfort at all to the process being killed
12
+ inside it.
13
+
14
+ That gap is what this skill exists for. It reads as a provider problem, a
15
+ network problem, a slow-index problem and a flaky-model problem long before it
16
+ reads as what it is, because the symptom arrives as a timeout and timeouts are
17
+ the one error everybody already has a theory about.
18
+
19
+ The evidence is at the bottom. The short version: five tuning rounds, eighty
20
+ minutes of wall clock and six prose edits were spent on an exit 124 produced by
21
+ a VM smaller than the index it was asked to read.
22
+
23
+ ## Does this apply here
24
+
25
+ Three things, and if any is absent say so and stop rather than guessing:
26
+
27
+ | Needs | Without it |
28
+ | --------------------------------------- | --------------------------------------------------------- |
29
+ | `podman` on `PATH` | Nothing below is measurable. Report it and stop |
30
+ | A VM — i.e. macOS or Windows, not Linux | Linux podman is native: no VM, no ceiling, not applicable |
31
+ | An agent holding `sandbox:*` | No container is ever started; capacity is not the fault |
32
+
33
+ The check reports all three and exits `3` for any of them. Docker, Colima and
34
+ Lima are out of scope on purpose: they have their own VM with its own controls,
35
+ and a number read out of podman would be about a machine that is not running the
36
+ work.
37
+
38
+ ## The three layers
39
+
40
+ Routinely confused, and only one of them can add memory.
41
+
42
+ | Layer | Read with | Set with | Can add memory? |
43
+ | ------------- | ---------------------------------------------- | ---------------------------------- | -------------------------------------- |
44
+ | The host | `sysctl -n hw.memsize` | — | No. It bounds what the VM may be given |
45
+ | **The VM** | `podman info --format json` → `.host.memTotal` | `podman machine set --memory` | **Yes. This is the ceiling** |
46
+ | One container | `podman inspect … .HostConfig.Memory` | `sandbox.memory:` in `agents.yaml` | No. It only divides the VM up |
47
+
48
+ `sandbox.memory: 8192` does not give a container 8 GiB. It gives it permission
49
+ to use 8 GiB of whatever the VM has, and a 2 GiB VM cannot honour that promise to
50
+ one container, let alone to eight.
51
+
52
+ ## The machine is sized once, by whichever project created it
53
+
54
+ This is the fact that makes the whole failure mode invisible, and it is worth
55
+ stating on its own.
56
+
57
+ `zen` creates the podman machine the first time any project needs a container,
58
+ with `machine init --cpus <n> --memory <m>` taken from **that project's**
59
+ `sandbox:` block, falling back to **2 CPUs and 2048 MiB**. From then on the
60
+ machine exists, so the code path that sizes it is never reached again.
61
+
62
+ Three consequences:
63
+
64
+ 1. **Editing `sandbox.memory:` afterwards resizes nothing.** It changes the
65
+ per-container cap inside an unchanged VM. The `agents.yaml` diff looks like a
66
+ capacity fix and is not one.
67
+ 2. **The VM may be sized by a project you have forgotten about.** The first
68
+ project to start a container on this host wins, permanently.
69
+ 3. **The scaffolded default is already oversubscribed.** `zen init` writes
70
+ `memory: 4096` per container, and the default VM is 2048 MiB — so one
71
+ container is permitted twice the whole machine, and podman will admit eight of
72
+ them without a word of complaint. Nothing refuses an impossible sum; it is
73
+ only ever discovered by being killed.
74
+
75
+ Resizing is the operator's to do, never this skill's and never the check's:
76
+
77
+ ```sh
78
+ podman machine stop
79
+ podman machine set --memory 12288 --cpus 6
80
+ podman machine start
81
+ ```
82
+
83
+ `machine stop` kills every running container, so it does not happen in the
84
+ middle of somebody's batch. Print the command and hand it over.
85
+
86
+ ## Reading a failure: the exit codes
87
+
88
+ The single most useful table here, because the whole misdiagnosis turns on one
89
+ row of it.
90
+
91
+ | Exit | Means | Points at |
92
+ | ----------: | ---------------------------- | ------------------------------------------------------------- |
93
+ | `124` | `timeout` fired | The work was too slow — **often a symptom of 137 conditions** |
94
+ | `137` | SIGKILL — the **OOM killer** | Capacity. Always. There is no other reading |
95
+ | `143` | SIGTERM | Shutdown or `stop_command`, not capacity |
96
+ | `0`, slowly | Thrash | Capacity, near the edge and not yet over it |
97
+
98
+ **124 and 137 co-occur under memory pressure**, and that is the trap. A VM under
99
+ pressure swaps (or, with no swap, spends the time in reclaim), so everything runs
100
+ an order of magnitude slower, so commands hit their timeout — and the ones that
101
+ do not hit it get killed outright. A run shows both, and 124 is the more common
102
+ of the two, so the 124 gets investigated.
103
+
104
+ Then the obvious repair makes it worse:
105
+
106
+ ```yaml
107
+ sandbox:
108
+ timeout: 600 # was 120 — "the commands just need longer"
109
+ ```
110
+
111
+ Raising the timeout converts 137 into 124 and converts 124 into a very long
112
+ success. It has removed the only unambiguous signal in the system. If a project
113
+ has a `timeout:` well above the 120-second default, treat it as evidence that
114
+ this diagnosis was already made once and got it wrong.
115
+
116
+ ## The budget
117
+
118
+ Two bounds. Report both, always, because they fail for different reasons and
119
+ take different fixes.
120
+
121
+ ### 1. Admission — free, static, and usually the finding
122
+
123
+ ```
124
+ concurrency × sandbox.memory ≤ vm_memory
125
+ ```
126
+
127
+ This is what podman will _permit_ to be allocated. It needs no probe, no
128
+ container and no timing: it is two numbers out of `agents.yaml` and one out of
129
+ `podman info`. When it fails, nothing in the system will refuse the sum — the
130
+ only thing keeping the machine alive is that the containers do not all peak at
131
+ once, which is luck rather than configuration.
132
+
133
+ It is a warning and not the verdict, because a container permitted 4 GiB that
134
+ only ever wants 900 MiB does no harm. The exception is not a warning at all:
135
+ when `sandbox.memory` alone exceeds the VM, the promise is impossible at
136
+ `--concurrency 1` and the batch width has nothing to do with it. That is the
137
+ scaffolded default on an unresized machine — `memory: 4096` against 2048 MiB.
138
+
139
+ ### 2. Working set — measured, and the one that names a number
140
+
141
+ ```
142
+ required_vm_memory ≥ largest_index_size
143
+ + (concurrency × per_container_peak_rss)
144
+ + 1 GiB headroom
145
+ ```
146
+
147
+ Every term is measured, never assumed:
148
+
149
+ - **`largest_index_size`** — `du -sk` over each index under `assets/`. An index
150
+ is read through the page cache, and the cache lives in the VM's memory. A 3.2
151
+ GiB index inside a 2 GiB VM cannot be cached at all, which is why the same
152
+ `grep` takes 5 seconds on the host and 110 inside the container.
153
+ - **`per_container_peak_rss`** — **sampled at `--concurrency 1`**, with
154
+ `podman stats`, running the command the batch will actually run. Never
155
+ estimated from the image size, and never divided out of a loaded run, because a
156
+ run under pressure reports the RSS it was _allowed_, not the one it wanted.
157
+ - **`1 GiB headroom`** — not optional. Check `swapTotal` first: a podman machine
158
+ normally reports **`SwapTotal 0`**, so there is no pressure valve. The kernel
159
+ does not slow down and then recover; it kills something immediately. Headroom
160
+ is the whole of the margin.
161
+
162
+ ## The check
163
+
164
+ ```sh
165
+ .github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh --concurrency 8
166
+ ```
167
+
168
+ | Flag | Effect |
169
+ | ------------------- | ------------------------------------------------------ |
170
+ | `--concurrency <n>` | The batch width to answer for. Default 1 |
171
+ | `--assets <dir>` | Where the indexes are. Default `assets` |
172
+ | `--skip-probe` | Admission bound only — instant, and needs no container |
173
+ | `--peak-rss <MiB>` | Use a known figure instead of sampling one |
174
+ | `--json` | The same answer as data |
175
+
176
+ | Exit | Means |
177
+ | ---: | --------------------------------------------------------- |
178
+ | `0` | Sufficient. Start the batch |
179
+ | `1` | Insufficient. The output names by how much, and two fixes |
180
+ | `3` | Not applicable — no podman, no VM, or no sandboxed agent |
181
+
182
+ Non-zero is usable as a gate, which is the point of it being a script:
183
+
184
+ ```sh
185
+ .github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh --concurrency 8 \
186
+ && zen run batch --input cases.json --concurrency 8
187
+ ```
188
+
189
+ It **mutates nothing**. It starts one short-lived container with `--rm`, reads,
190
+ and prints; it never stops, resizes or reconfigures the machine.
191
+
192
+ What it prints is a verdict with the numbers that produced it and the exact
193
+ command that fixes it — the shape `zen models test` uses:
194
+
195
+ ```
196
+ host 36.0 GiB total
197
+ vm 2.0 GiB total · 0 swap · 2 cpus podman-machine-default
198
+ container 4096 MiB cap · 4 cpus agents.yaml
199
+ index 3.2 GiB assets/docs-db (largest of 2)
200
+ peak rss 912 MiB sampled at concurrency 1
201
+
202
+ admission 8 x 4096 MiB = 32.0 GiB permitted against a 2.0 GiB vm OVERSUBSCRIBED
203
+ concurrency 8 needs ~11.4 GiB vm has 2.0 GiB INSUFFICIENT
204
+ fix: podman machine stop && podman machine set --memory 12288 && podman machine start
205
+ or: run the batch at --concurrency 1
206
+ ```
207
+
208
+ **Both remedies, every time.** Resizing is not available on every machine — a
209
+ shared host, a CI runner, a laptop with 8 GiB — and lowering concurrency always
210
+ is. A check that offers only the fix the reader cannot apply has told them
211
+ nothing.
212
+
213
+ Target runtime is under 60 seconds so that running it is never a decision. A
214
+ full concurrency sweep would be better evidence and would not get run; that
215
+ sweep is the _diagnostic_ and this is the _gate_. `--skip-probe` drops it to
216
+ about a second when the admission bound is all that is in question.
217
+
218
+ ## Separating the index from the provider
219
+
220
+ Any probe of a capacity problem must label each command as one of two kinds:
221
+
222
+ | Kind | Makes a network call | Examples |
223
+ | ------- | ------------------------------ | ---------------------------------------------- |
224
+ | `local` | No | `zen rag docs stats`, `grep`, `list`, `show` |
225
+ | `embed` | Yes, one, before anything else | `zen rag docs search`, `zen rag schema search` |
226
+
227
+ Without the split, a slow `search` is indistinguishable from a throttled one,
228
+ and the operator is sent to the wrong vendor. With it, the reading is mechanical:
229
+
230
+ | Observed | Verdict |
231
+ | ----------------------------------------------- | -------------------------------------- |
232
+ | Container slow, host fast | The VM. Capacity, and only capacity |
233
+ | Both slow, `embed` only | The embedding endpoint. Not this skill |
234
+ | Both slow, `local` too | Neither — the index itself |
235
+ | Container slow on `local`, host slow on `embed` | Both, independently |
236
+
237
+ This is also why the check probes with a `local` command. A gate that needed a
238
+ credential and a network round trip would fail for reasons that have nothing to
239
+ do with the machine.
240
+
241
+ ## A machine spec is part of a round
242
+
243
+ Record it into the round directory, next to the findings:
244
+
245
+ ```sh
246
+ {
247
+ echo "host $(($(sysctl -n hw.memsize) / 1073741824)) GiB"
248
+ podman info --format json | jq -r '.host | "vm \(.memTotal / 1073741824 | floor) GiB · swap \(.swapTotal) · \(.cpus) cpus"'
249
+ echo "batch --concurrency 8"
250
+ } > .finetune/rounds/r5/machine.txt
251
+ ```
252
+
253
+ The consequence is not bookkeeping, it is validity: **token counts and
254
+ wall-clock are only comparable within one machine spec.** A round that ran at 2
255
+ GiB and a round that ran at 12 GiB differ in trajectory length for reasons that
256
+ have nothing to do with the prose — retries, timeouts, abandoned branches and
257
+ compactions all move with pressure. Resizing the VM invalidates a baseline
258
+ exactly as editing a prompt does, and for the same reason.
259
+
260
+ And the harder rule: **a round containing any exit 137 is void, not graded.** Its
261
+ trajectories are of a system being killed, not of a system following
262
+ instructions. No prose may be changed on its evidence. Re-run it on a machine
263
+ that fits and grade that.
264
+
265
+ ## The host side too
266
+
267
+ The containers are not the only process. `zen run batch` is itself a Node
268
+ process on the host, holding every item's envelope, and it has been OOM-killed
269
+ in its own right — the shell reports `zsh: killed` and there is no run directory
270
+ to inspect, which looks like nothing happened at all.
271
+
272
+ Two remedies, in order:
273
+
274
+ 1. Lower `--concurrency`. It reduces both sides at once.
275
+ 2. `NODE_OPTIONS=--max-old-space-size=8192 zen run batch …`, when the host
276
+ genuinely has the memory and Node's default heap is the binding constraint.
277
+
278
+ The check reports host total and free alongside the VM's, so the two are never
279
+ confused for each other.
280
+
281
+ ## When to escalate
282
+
283
+ The check answers _is there enough_. When the answer is yes and it is still
284
+ slow, the question has become _what is it spending the time on_, and that wants
285
+ a sweep rather than a gate. The method is in
286
+ [references/loadtest.md](./references/loadtest.md): a 2×2 of host against
287
+ container and `local` against `embed`, at rising concurrency, written to CSV.
288
+
289
+ Escalate when, and only when:
290
+
291
+ - The check passes and a batch is still timing out.
292
+ - The verdict is "both slow", so the split has not settled which side it is.
293
+ - A resize did not move the numbers — that refutes the capacity hypothesis and
294
+ something else is wrong.
295
+
296
+ Do not escalate to establish that 8 concurrent containers do not fit in 2 GiB.
297
+ The check already said so, and a sweep that confirms it has spent twenty minutes
298
+ agreeing.
299
+
300
+ ## The evidence this skill was written from
301
+
302
+ One project, one afternoon, measured rather than reasoned:
303
+
304
+ | Fact | Measured |
305
+ | --------------------------- | ------------------------------------------ |
306
+ | podman VM default on macOS | 2 GiB, on a host with 36 GiB |
307
+ | `docs` index | 3.2 GiB — larger than the whole VM |
308
+ | `docs grep`, host, c=8 | 5.5 s |
309
+ | `docs grep`, container, c=8 | 105–118 s, and **3 of 8 exited 137** |
310
+ | Embedding calls, c=8 | ~2× latency, and **zero 429s** |
311
+ | VM swap | **0** — no valve, so the kill is immediate |
312
+
313
+ Read the last two rows together. The provider was never throttling; the
314
+ hypothesis that it was survived five rounds because the only visible symptom was
315
+ a timeout, and the one measurement that would have refuted it — the same command
316
+ run on the host — was never made.
317
+
318
+ ## What this does not cover
319
+
320
+ - **Sandbox configuration generally.** `persist`, mounts, `keys`, hardening,
321
+ images: load **zen-sandbox**.
322
+ - **Docker, Colima, Lima, or Linux-native podman.** Detected and reported as not
323
+ applicable, never attempted.
324
+ - **Provider rate limits.** A probe that finds throttling hands off; diagnosing
325
+ a quota is the provider's documentation and **zen-cli**'s `zen models test`.
326
+ - **Index construction and sizing.** How large an index ought to be, and how to
327
+ make it smaller, is **zen-rag-docs**.
328
+
329
+ ## Related
330
+
331
+ - **zen-sandbox** — what a container is, how to configure it, and the mounts
332
+ - **zen-finetune** — the tuning loop this is a precondition of; its Phase 3
333
+ - **zen-cli** — `zen sandbox status|up|disk`, and what each exit code means
334
+ - **zen-inspect** — finding the failed node once the round has been re-run
@@ -0,0 +1,181 @@
1
+ # Load-testing: separating the machine from the endpoint
2
+
3
+ `preflight_sandbox.sh` answers a static question — does the arithmetic fit. This
4
+ answers a different one: the batch is slow or dying and you do not yet know
5
+ what is causing it. It costs minutes rather than seconds, so reach for it only
6
+ when the gate has already passed and the symptom is still there.
7
+
8
+ The method below is written from a run that did work. It is a method, not a
9
+ script to paste: the commands and the index names belong to whatever project
10
+ you are in. Build the harness to fit, and throw it away afterwards.
11
+
12
+ ## Two hypotheses, and why you cannot tell them apart by staring
13
+
14
+ A slow or killed batch has two plausible causes and they present identically at
15
+ the call site — a long wall clock, then a timeout:
16
+
17
+ | | Hypothesis | What it predicts |
18
+ | ------ | --------------------------------------------- | ----------------------------------------------------------------------- |
19
+ | **H1** | The podman VM is under-provisioned | Latency depends on _where_ the command runs, and not on what kind it is |
20
+ | **H2** | The embedding or model endpoint is throttling | Latency depends on _what kind_ of call it is, and not on where it runs |
21
+
22
+ They are distinguishable because they are separable. H1 lives in the container
23
+ boundary, H2 lives in the network call, and a single 2x2 tells you which line
24
+ the variance sits on.
25
+
26
+ ## The 2x2
27
+
28
+ Two factors, crossed, at several concurrencies:
29
+
30
+ - **where** — `host` (the command run directly) against `container`
31
+ (the same command inside the sandbox)
32
+ - **kind** — `local` (touches only the index, makes no network call) against
33
+ `embed` (makes a provider call)
34
+
35
+ Pick two or three probes of each kind. For a `zen rag` index these fall out
36
+ naturally:
37
+
38
+ | Probe | kind | Why it is in the set |
39
+ | -------- | ----- | ---------------------------------------------------------------------- |
40
+ | `stats` | local | Opens the index and reads the manifest; nearly no work |
41
+ | `grep` | local | Reads the whole index through the page cache; the heaviest local probe |
42
+ | `list` | local | Walks the node table |
43
+ | `search` | embed | The query is embedded before anything is read |
44
+ | `schema` | embed | A second embed path, to show the first was not a fluke |
45
+
46
+ Run each cell at concurrency 1, 2, 4 and 8, several replicas each, and write one
47
+ row per run. Concurrency 1 is not optional: it is the baseline every other row
48
+ is read against, and it is also the figure `--peak-rss` wants.
49
+
50
+ A useful row shape, and one that a later `awk` can group without parsing:
51
+
52
+ ```
53
+ probe,kind,where,concurrency,replica,seconds,exit_code
54
+ ```
55
+
56
+ Keep `exit_code`. A cell that got faster because half its replicas were killed
57
+ at 137 is not a fast cell, and a mean that silently includes them will tell you
58
+ the opposite of the truth.
59
+
60
+ ## Reading the grid
61
+
62
+ Compare means within a `probe`, across `where` and `concurrency`:
63
+
64
+ - **container rows slow, host rows fast** — H1, the podman VM
65
+ - **both slow, but only on `kind=embed`** — H2, the embedding API
66
+ - **both slow, including `kind=local`** — neither: the index itself
67
+ - **container rows carry the non-zero exit codes** — H1, and specifically memory;
68
+ go back to the gate and read `admission` rather than running anything else
69
+
70
+ The run this skill was written from produced the first of those, unambiguously:
71
+ `grep` at concurrency 8 took 5.5 s on the host and 105-118 s in the container,
72
+ with three of the eight replicas exiting 137.
73
+
74
+ ## When the finger points at the endpoint
75
+
76
+ If the grid implicates `kind=embed`, test the endpoint on its own before
77
+ believing it. Drive one model reference at rising concurrency — 1, 2, 4, 8, 16 —
78
+ and classify each reply rather than timing it alone:
79
+
80
+ ```
81
+ ref,concurrency,replica,seconds,exit_code,verdict
82
+ ```
83
+
84
+ Classify from the reply body, in this order, because a throttle can arrive
85
+ wearing a 200:
86
+
87
+ | verdict | Recognised by |
88
+ | ----------- | ----------------------------------------------------------------------- |
89
+ | `throttled` | `429`, `RESOURCE_EXHAUSTED`, `rate limit`, `quota`, `too many requests` |
90
+ | `refused` | the model declined the request |
91
+ | `blocked` | a safety or policy filter fired |
92
+ | `no-answer` | empty body, or a body with no content |
93
+ | `ok` | a substantive reply |
94
+ | `other` | anything left |
95
+
96
+ Keep the raw replies on disk. A verdict you cannot go back and check is a guess
97
+ with a column heading.
98
+
99
+ Then:
100
+
101
+ - **mean flat as concurrency rises, nothing `throttled`** — H2 is out. The
102
+ endpoint is not your problem; go back to H1.
103
+ - **mean climbing, nothing `throttled`** — the endpoint serialises. Lower
104
+ concurrency will not lose you throughput, so lower it.
105
+ - **anything `throttled`** — H2 confirmed. Stop here.
106
+
107
+ A confirmed throttle is the end of this document's usefulness. Rate limits are a
108
+ provider matter — quota, region, a different reference, a backoff — and nothing
109
+ in this skill or in the sandbox will move them. Hand it off; do not spend a
110
+ fine-tuning round on it.
111
+
112
+ The run this skill was written from found embedding calls roughly 2x slower at
113
+ concurrency 8 and **zero** throttled replies. That is what refuted H2, and it is
114
+ the reason the sentence about provider tolerance in `zen-finetune` is not the
115
+ whole story.
116
+
117
+ ## A skeleton
118
+
119
+ Substitute your own index names and model reference. Keep the structure — the
120
+ value is in the grid, not in the timing code.
121
+
122
+ ```bash
123
+ #!/usr/bin/env bash
124
+ set -eu
125
+ stamp="$(date +%Y%m%d-%H%M%S)"
126
+ out=".tmp/loadtest/rag-${stamp}.csv"
127
+ mkdir -p "$(dirname "$out")"
128
+ echo "probe,kind,where,concurrency,replica,seconds,exit_code" > "$out"
129
+
130
+ INDEX=@INDEX@ # e.g. docs
131
+ IMAGE="$(zen sandbox status | awk '$1 == "image" { print $2 }')"
132
+
133
+ run_one() { # probe kind where concurrency replica
134
+ case "$1" in
135
+ stats) cmd="zen rag $INDEX stats" ;;
136
+ grep) cmd="zen rag $INDEX grep zzzz-not-present" ;;
137
+ search) cmd="zen rag $INDEX search 'how does this work'" ;;
138
+ esac
139
+ start="$(date +%s)"
140
+ if [ "$3" = host ]; then
141
+ sh -c "$cmd" > /dev/null 2>&1 || true
142
+ rc=$?
143
+ else
144
+ podman run --rm -v "$PWD:/work:ro" -w /work --entrypoint="" \
145
+ "$IMAGE" sh -c "$cmd" > /dev/null 2>&1 || true
146
+ rc=$?
147
+ fi
148
+ echo "$1,$2,$3,$4,$5,$(($(date +%s) - start)),$rc" >> "$out"
149
+ }
150
+
151
+ for c in 1 2 4 8; do
152
+ for where in host container; do
153
+ for spec in "stats local" "grep local" "search embed"; do
154
+ set -- ${=spec} # zsh does not split unquoted expansions
155
+ for r in $(seq 1 "$c"); do run_one "$1" "$2" "$where" "$c" "$r" & done
156
+ wait
157
+ done
158
+ done
159
+ done
160
+
161
+ echo "wrote $out"
162
+ ```
163
+
164
+ Two things this deliberately does not do, and neither should yours:
165
+
166
+ - It does not use `python3 -c`, `node -e` or any other inline interpreter. That
167
+ is a house rule, and a load test is not an exemption from it.
168
+ - It does not mutate the machine. If the answer is "resize", say so and let a
169
+ person run `podman machine set` — see the main skill for why stopping the
170
+ machine is not free.
171
+
172
+ Whole-second timing is coarse for the fast cells. If you need better, use a
173
+ timing utility the image already has; do not reach for an inline interpreter to
174
+ get microseconds you will not read.
175
+
176
+ ## Afterwards
177
+
178
+ Whatever the grid says, write the finding down where the next round will see it,
179
+ together with the machine spec it was measured on. A load test that is not
180
+ recorded next to its `vm memory` figure will be re-run by the next person from
181
+ scratch, because they will have no way to know whether it still applies.