@zenera/cli 1.1.23 → 1.1.25

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 (184) hide show
  1. package/README.md +88 -2
  2. package/dist/ask.d.ts +51 -0
  3. package/dist/ask.js +166 -0
  4. package/dist/banner.js +2 -2
  5. package/dist/batch.d.ts +83 -0
  6. package/dist/batch.js +413 -0
  7. package/dist/catalog.d.ts +11 -1
  8. package/dist/catalog.js +80 -7
  9. package/dist/command.d.ts +6 -0
  10. package/dist/commands/inspect.d.ts +57 -0
  11. package/dist/commands/inspect.js +539 -91
  12. package/dist/commands/list.js +128 -19
  13. package/dist/commands/memory.js +461 -32
  14. package/dist/commands/meta.js +311 -21
  15. package/dist/commands/models.js +110 -14
  16. package/dist/commands/open.js +4 -4
  17. package/dist/commands/run.js +175 -36
  18. package/dist/commands/sandbox.js +148 -15
  19. package/dist/engine.d.ts +24 -0
  20. package/dist/engine.js +61 -8
  21. package/dist/eventlog.d.ts +59 -0
  22. package/dist/eventlog.js +146 -0
  23. package/dist/liveness.d.ts +7 -0
  24. package/dist/liveness.js +20 -3
  25. package/dist/main.js +5 -2
  26. package/dist/meta/editor.d.ts +9 -0
  27. package/dist/meta/editor.js +60 -0
  28. package/dist/meta/finetune/children.d.ts +8 -0
  29. package/dist/meta/finetune/children.js +76 -0
  30. package/dist/meta/finetune/command.d.ts +30 -0
  31. package/dist/meta/finetune/command.js +338 -0
  32. package/dist/meta/finetune/dataset/anchors.d.ts +41 -0
  33. package/dist/meta/finetune/dataset/anchors.js +239 -0
  34. package/dist/meta/finetune/dataset/changes.d.ts +36 -0
  35. package/dist/meta/finetune/dataset/changes.js +319 -0
  36. package/dist/meta/finetune/dataset/command.d.ts +17 -0
  37. package/dist/meta/finetune/dataset/command.js +1011 -0
  38. package/dist/meta/finetune/dataset/drift.d.ts +33 -0
  39. package/dist/meta/finetune/dataset/drift.js +71 -0
  40. package/dist/meta/finetune/dataset/export.d.ts +26 -0
  41. package/dist/meta/finetune/dataset/export.js +51 -0
  42. package/dist/meta/finetune/dataset/index.d.ts +2 -0
  43. package/dist/meta/finetune/dataset/index.js +2 -0
  44. package/dist/meta/finetune/dataset/sample.d.ts +50 -0
  45. package/dist/meta/finetune/dataset/sample.js +197 -0
  46. package/dist/meta/finetune/dataset/schema.d.ts +33 -0
  47. package/dist/meta/finetune/dataset/schema.js +216 -0
  48. package/dist/meta/finetune/dataset/store.d.ts +35 -0
  49. package/dist/meta/finetune/dataset/store.js +177 -0
  50. package/dist/meta/finetune/dataset/types.d.ts +125 -0
  51. package/dist/meta/finetune/dataset/types.js +39 -0
  52. package/dist/meta/finetune/failure.d.ts +15 -0
  53. package/dist/meta/finetune/failure.js +26 -0
  54. package/dist/meta/finetune/feedback.d.ts +45 -0
  55. package/dist/meta/finetune/feedback.js +85 -0
  56. package/dist/meta/finetune/improvements.d.ts +51 -0
  57. package/dist/meta/finetune/improvements.js +244 -0
  58. package/dist/meta/finetune/index.d.ts +2 -0
  59. package/dist/meta/finetune/index.js +2 -0
  60. package/dist/meta/finetune/journal.d.ts +35 -0
  61. package/dist/meta/finetune/journal.js +135 -0
  62. package/dist/meta/finetune/live.d.ts +74 -0
  63. package/dist/meta/finetune/live.js +226 -0
  64. package/dist/meta/finetune/loop.d.ts +3 -0
  65. package/dist/meta/finetune/loop.js +114 -0
  66. package/dist/meta/finetune/memories.d.ts +47 -0
  67. package/dist/meta/finetune/memories.js +154 -0
  68. package/dist/meta/finetune/report.d.ts +33 -0
  69. package/dist/meta/finetune/report.js +1284 -0
  70. package/dist/meta/finetune/retry.d.ts +24 -0
  71. package/dist/meta/finetune/retry.js +96 -0
  72. package/dist/meta/finetune/sampler.d.ts +18 -0
  73. package/dist/meta/finetune/sampler.js +41 -0
  74. package/dist/meta/finetune/system.d.ts +27 -0
  75. package/dist/meta/finetune/system.js +132 -0
  76. package/dist/meta/finetune/train.d.ts +39 -0
  77. package/dist/meta/finetune/train.js +391 -0
  78. package/dist/meta/finetune/tui.d.ts +16 -0
  79. package/dist/meta/finetune/tui.js +291 -0
  80. package/dist/meta/finetune/tuning.d.ts +167 -0
  81. package/dist/meta/finetune/tuning.js +289 -0
  82. package/dist/meta/finetune/usage.d.ts +62 -0
  83. package/dist/meta/finetune/usage.js +225 -0
  84. package/dist/meta/host.d.ts +14 -0
  85. package/dist/meta/host.js +20 -0
  86. package/dist/meta/index.d.ts +11 -0
  87. package/dist/meta/index.js +11 -0
  88. package/dist/meta/launch.d.ts +81 -0
  89. package/dist/meta/launch.js +286 -0
  90. package/dist/meta/model.d.ts +44 -0
  91. package/dist/meta/model.js +119 -0
  92. package/dist/meta/prompts.d.ts +18 -0
  93. package/dist/meta/prompts.js +61 -0
  94. package/dist/meta/relay.d.ts +20 -0
  95. package/dist/meta/relay.js +123 -0
  96. package/dist/meta/tokens.d.ts +52 -0
  97. package/dist/meta/tokens.js +226 -0
  98. package/dist/meta/window.d.ts +61 -0
  99. package/dist/meta/window.js +198 -0
  100. package/dist/meta/wire.d.ts +42 -0
  101. package/dist/meta/wire.js +129 -0
  102. package/dist/modelref.d.ts +16 -0
  103. package/dist/modelref.js +41 -0
  104. package/dist/narrate.js +5 -2
  105. package/dist/podman.d.ts +42 -0
  106. package/dist/podman.js +198 -5
  107. package/dist/progress.d.ts +33 -0
  108. package/dist/progress.js +402 -0
  109. package/dist/projects.d.ts +0 -5
  110. package/dist/projects.js +2 -15
  111. package/dist/request.d.ts +45 -0
  112. package/dist/request.js +300 -0
  113. package/dist/sandbox.d.ts +2 -0
  114. package/dist/sandbox.js +20 -3
  115. package/dist/scaffold.d.ts +15 -6
  116. package/dist/scaffold.js +5 -60
  117. package/dist/session.d.ts +26 -1
  118. package/dist/session.js +46 -32
  119. package/dist/term.d.ts +5 -0
  120. package/dist/term.js +28 -3
  121. package/dist/trace.d.ts +84 -0
  122. package/dist/trace.js +737 -0
  123. package/dist/tui/models.d.ts +33 -0
  124. package/dist/tui/models.js +389 -0
  125. package/dist/tui/wrap.d.ts +5 -0
  126. package/dist/tui/wrap.js +12 -0
  127. package/dist/usage.d.ts +78 -0
  128. package/dist/usage.js +51 -0
  129. package/dist/validate.js +25 -3
  130. package/package.json +3 -2
  131. package/templates/editor/.github/copilot-instructions.md +41 -107
  132. package/templates/editor/.github/prompts/analyze.prompt.md +31 -0
  133. package/templates/editor/.github/prompts/dataset.prompt.md +108 -0
  134. package/templates/editor/.github/prompts/finetune-apply.prompt.md +91 -0
  135. package/templates/editor/.github/prompts/finetune.prompt.md +744 -0
  136. package/templates/editor/.github/prompts/new-agent.prompt.md +3 -1
  137. package/templates/editor/.github/prompts/new-skill.prompt.md +2 -0
  138. package/templates/editor/.github/prompts/project-review.prompt.md +4 -3
  139. package/templates/editor/.github/skills/zen-analyze-run/SKILL.md +483 -0
  140. package/templates/editor/.github/skills/zen-cli/SKILL.md +18 -4
  141. package/templates/editor/.github/skills/zen-cli/references/check.md +7 -5
  142. package/templates/editor/.github/skills/zen-cli/references/dataset.md +34 -0
  143. package/templates/editor/.github/skills/zen-cli/references/finetune.md +77 -0
  144. package/templates/editor/.github/skills/zen-cli/references/inspect.md +300 -21
  145. package/templates/editor/.github/skills/zen-cli/references/memory.md +121 -28
  146. package/templates/editor/.github/skills/zen-cli/references/meta.md +61 -0
  147. package/templates/editor/.github/skills/zen-cli/references/models.md +6 -2
  148. package/templates/editor/.github/skills/zen-cli/references/projects.md +12 -5
  149. package/templates/editor/.github/skills/zen-cli/references/rag.md +24 -6
  150. package/templates/editor/.github/skills/zen-cli/references/run.md +201 -4
  151. package/templates/editor/.github/skills/zen-cli/references/sandbox.md +10 -1
  152. package/templates/editor/.github/skills/zen-code-python/SKILL.md +123 -0
  153. package/templates/editor/.github/skills/zen-code-python/references/code_python.md +48 -0
  154. package/templates/editor/.github/skills/zen-dataset/SKILL.md +253 -0
  155. package/templates/editor/.github/skills/zen-finetune/SKILL.md +1791 -0
  156. package/templates/editor/.github/skills/zen-finetune/references/readme-template.md +268 -0
  157. package/templates/editor/.github/skills/zen-finetune/scripts/batch.mjs +390 -0
  158. package/templates/editor/.github/skills/zen-finetune/scripts/difficult.mjs +243 -0
  159. package/templates/editor/.github/skills/zen-finetune/scripts/memory.mjs +280 -0
  160. package/templates/editor/.github/skills/zen-finetune/scripts/next.mjs +398 -0
  161. package/templates/editor/.github/skills/zen-finetune/scripts/report.mjs +797 -0
  162. package/templates/editor/.github/skills/zen-finetune/scripts/select.mjs +354 -0
  163. package/templates/editor/.github/skills/zen-finetune/scripts/usage.mjs +722 -0
  164. package/templates/editor/.github/skills/zen-inspect/SKILL.md +431 -0
  165. package/templates/editor/.github/skills/zen-inspect-ask/SKILL.md +270 -0
  166. package/templates/editor/.github/skills/zen-memory/SKILL.md +78 -29
  167. package/templates/editor/.github/skills/zen-memory-warmup/SKILL.md +363 -531
  168. package/templates/editor/.github/skills/zen-rag-docs/SKILL.md +20 -8
  169. package/templates/editor/.github/skills/zen-review/SKILL.md +5 -3
  170. package/templates/editor/.github/skills/zen-review/scripts/check-paths.sh +5 -4
  171. package/templates/editor/.github/skills/zen-sandbox/SKILL.md +25 -2
  172. package/templates/editor/.github/skills/zen-sandbox-capacity/SKILL.md +334 -0
  173. package/templates/editor/.github/skills/zen-sandbox-capacity/references/loadtest.md +181 -0
  174. package/templates/editor/.github/skills/zen-sandbox-capacity/scripts/preflight_sandbox.sh +457 -0
  175. package/templates/editor/.github/skills/zen-topology/SKILL.md +283 -0
  176. package/templates/editor/.vscode/agents.schema.json +1 -1
  177. package/templates/editor/.vscode/settings.json +2 -0
  178. package/templates/project/agents/files-instructions.md +35 -13
  179. package/templates/project/agents/fork-instructions.md +27 -0
  180. package/templates/project/agents/memory-instructions.md +106 -34
  181. package/templates/project/agents/tools-instructions.md +7 -4
  182. package/templates/project/gitignore +5 -0
  183. package/dist/meta.d.ts +0 -221
  184. package/dist/meta.js +0 -684
@@ -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.
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Whether this machine can run the batch you are about to start.
4
+ #
5
+ # preflight_sandbox.sh --concurrency 8 the gate: exit 0 fits, 1 does not
6
+ # preflight_sandbox.sh --skip-probe the free half, in about a second
7
+ # preflight_sandbox.sh --peak-rss 912 use a figure you measured yourself
8
+ #
9
+ # Containers do not run on the host. On macOS they run inside one podman virtual
10
+ # machine whose memory is fixed, is not the host's, and is 2 GiB by default — so
11
+ # eight concurrent containers over a multi-gigabyte index thrash and are killed
12
+ # while the host sits nearly empty. The symptom is exit 124, which everybody
13
+ # already has a theory about, so the VM is the last thing anyone looks at.
14
+ #
15
+ # Two bounds are reported, because they fail for different reasons:
16
+ #
17
+ # admission concurrency x sandbox.memory <= vm_memory
18
+ # what podman will permit to be allocated. Static and free.
19
+ # working set largest_index + concurrency x peak_rss + 1 GiB <= vm_memory
20
+ # what the work actually wants. The index is counted once: it
21
+ # is page cache in the VM and every container shares it.
22
+ #
23
+ # It measures; it never changes anything. `podman machine set` needs the machine
24
+ # stopped, which kills every running container, so that command is printed and
25
+ # handed over rather than run.
26
+ #
27
+ # Exit 0 sufficient, 1 insufficient, 3 not applicable. See
28
+ # .github/skills/zen-sandbox-capacity/SKILL.md.
29
+
30
+ set -eu
31
+
32
+ # Taken before the cd, or a relative $0 stops naming this file.
33
+ SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
34
+ cd "$(dirname "$0")/../../../.."
35
+
36
+ CONCURRENCY=1
37
+ ASSETS=assets
38
+ SKIP_PROBE=0
39
+ PEAK_RSS=0
40
+ AS_JSON=0
41
+ PROBE_TIMEOUT=25
42
+
43
+ # A node-based agent container costs this much before it has done anything, so
44
+ # a generic probe that measures less than it has measured the probe, not the
45
+ # agent. Override with a real figure using --peak-rss.
46
+ # A gate that guesses should guess high: guessing low returns a cheerful "ok"
47
+ # and the batch dies anyway. 1024 is a round number just above the only figure
48
+ # anyone has actually measured here (912 MiB), so an unprobed answer errs
49
+ # toward refusing. Pass --peak-rss with a real number and this is unused.
50
+ PEAK_FLOOR_MIB=1024
51
+ HEADROOM_MIB=1024
52
+
53
+ # What zen scaffolds when `sandbox:` names neither, and what `strict` uses.
54
+ DEFAULT_CONTAINER_MIB=4096
55
+ STRICT_CONTAINER_MIB=2048
56
+
57
+ usage() {
58
+ sed -n '3,7p' "$SELF" | sed 's/^# \{0,1\}//'
59
+ echo
60
+ echo " --concurrency <n> batch width to answer for; default $CONCURRENCY"
61
+ echo " --assets <dir> where the indexes are; default $ASSETS"
62
+ echo " --skip-probe admission bound only; starts no container"
63
+ echo " --peak-rss <MiB> a measured per-container figure, instead of sampling"
64
+ echo " --json the same answer as data"
65
+ }
66
+
67
+ while [ $# -gt 0 ]; do
68
+ case "$1" in
69
+ --concurrency | -c) CONCURRENCY="${2:?--concurrency needs a number}"; shift 2 ;;
70
+ --assets) ASSETS="${2:?--assets needs a directory}"; shift 2 ;;
71
+ --peak-rss) PEAK_RSS="${2:?--peak-rss needs a number of MiB}"; shift 2 ;;
72
+ --skip-probe) SKIP_PROBE=1; shift ;;
73
+ --json) AS_JSON=1; shift ;;
74
+ -h | --help) usage; exit 0 ;;
75
+ *)
76
+ echo "preflight_sandbox.sh: unknown argument $1" >&2
77
+ usage >&2
78
+ exit 2
79
+ ;;
80
+ esac
81
+ done
82
+
83
+ case "$CONCURRENCY$PEAK_RSS" in
84
+ *[!0-9]*)
85
+ echo "preflight_sandbox.sh: --concurrency and --peak-rss take whole numbers" >&2
86
+ exit 2
87
+ ;;
88
+ esac
89
+ if [ "$CONCURRENCY" -lt 1 ]; then
90
+ echo "preflight_sandbox.sh: --concurrency must be at least 1" >&2
91
+ exit 2
92
+ fi
93
+
94
+ NOTES=""
95
+ note() {
96
+ NOTES="$NOTES$1
97
+ "
98
+ }
99
+
100
+ mib() { awk -v b="$1" 'BEGIN { printf "%d", b / 1048576 }'; }
101
+ gib() { awk -v m="$1" 'BEGIN { printf "%.1f", m / 1024 }'; }
102
+
103
+ # Everything below prints through here, so --json can suppress the prose without
104
+ # every site having to know about it.
105
+ say() {
106
+ if [ "$AS_JSON" -eq 0 ]; then printf '%s\n' "$1"; fi
107
+ }
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # 1. Does this apply at all
111
+ #
112
+ # Three ways not to. Each is a real answer and none of them is a failure, so
113
+ # they share exit 3 and each says what could not be checked rather than what
114
+ # went wrong.
115
+ # ---------------------------------------------------------------------------
116
+
117
+ not_applicable() {
118
+ if [ "$AS_JSON" -eq 1 ]; then
119
+ printf '{"applicable":false,"reason":%s}\n' "\"$1\""
120
+ else
121
+ echo "not applicable: $1"
122
+ if [ -n "$2" ]; then echo " $2"; fi
123
+ fi
124
+ exit 3
125
+ }
126
+
127
+ if ! command -v podman > /dev/null 2>&1; then
128
+ not_applicable "podman is not on PATH" \
129
+ "nothing here is measurable; install podman, or run \`zen sandbox up\`"
130
+ fi
131
+
132
+ if [ "$(uname -s)" = "Linux" ]; then
133
+ not_applicable "podman is native on Linux — there is no VM to be the ceiling" \
134
+ "containers here are bounded by the host and by cgroups, not by a machine"
135
+ fi
136
+
137
+ CONF=""
138
+ for candidate in agents.yaml agents.yml agents/agents.yaml agents/agents.yml; do
139
+ if [ -f "$candidate" ]; then CONF="$candidate"; break; fi
140
+ done
141
+ if [ -z "$CONF" ]; then
142
+ not_applicable "no agents.yaml in $PWD" \
143
+ "run this from a project, or from anywhere inside one"
144
+ fi
145
+ if ! grep -Eq 'sandbox:(\*|run_command|run_command_background|read_command_output|stop_command)' "$CONF"; then
146
+ not_applicable "no agent in $CONF holds a sandbox tool" \
147
+ "no container is ever started, so capacity cannot be the fault"
148
+ fi
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # 2. The host
152
+ #
153
+ # It bounds what the VM may be given and it is where `zen run batch` itself
154
+ # runs, which has been OOM-killed in its own right. Free pages are the ones
155
+ # that could be handed to a resize today.
156
+ # ---------------------------------------------------------------------------
157
+
158
+ HOST_MIB="$(mib "$(sysctl -n hw.memsize 2> /dev/null || echo 0)")"
159
+ HOST_FREE_MIB=0
160
+ if command -v vm_stat > /dev/null 2>&1; then
161
+ HOST_FREE_MIB="$(
162
+ vm_stat 2> /dev/null | awk '
163
+ /page size of/ { gsub(/[^0-9]/, "", $8); ps = $8 }
164
+ /Pages free/ || /Pages inactive/ { gsub(/[^0-9]/, "", $NF); p += $NF }
165
+ END { printf "%d", (ps ? ps : 4096) * p / 1048576 }' || true
166
+ )"
167
+ fi
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # 3. The VM — the ceiling
171
+ #
172
+ # `podman info` is the authority because it reports the machine as it is
173
+ # running, swap included. A machine that is stopped cannot answer, so the
174
+ # configured size from `machine list` stands in and is labelled as such.
175
+ # ---------------------------------------------------------------------------
176
+
177
+ VM_MIB=0
178
+ VM_SWAP_MIB=0
179
+ VM_CPUS=0
180
+ VM_NAME=""
181
+ VM_STATE=running
182
+
183
+ INFO="$(podman info --format '{{.Host.MemTotal}} {{.Host.SwapTotal}} {{.Host.CPUs}}' 2> /dev/null || true)"
184
+ case "$INFO" in
185
+ [0-9]*)
186
+ VM_MIB="$(mib "$(echo "$INFO" | awk '{print $1}')")"
187
+ VM_SWAP_MIB="$(mib "$(echo "$INFO" | awk '{print $2}')")"
188
+ VM_CPUS="$(echo "$INFO" | awk '{print $3}')"
189
+ ;;
190
+ *) VM_STATE=stopped ;;
191
+ esac
192
+
193
+ # `Memory` is bytes on podman 4 and a string like `2GiB` on podman 5, so it is
194
+ # normalised rather than trusted. Read whatever the state, for the name.
195
+ MACHINE="$(podman machine list --format '{{.Name}} {{.Memory}} {{.CPUs}} {{.Running}}' 2> /dev/null | head -1 || true)"
196
+ if [ -n "$MACHINE" ]; then
197
+ # `list` marks the default machine with a trailing asterisk.
198
+ VM_NAME="$(echo "$MACHINE" | awk '{print $1}' | sed 's/\*$//')"
199
+ if [ "$VM_STATE" = stopped ]; then
200
+ VM_MIB="$(
201
+ echo "$MACHINE" | awk '{
202
+ v = $2
203
+ n = v + 0
204
+ if (v ~ /GiB|GB|[Gg]$/) n = n * 1024
205
+ else if (v ~ /MiB|MB|[Mm]$/) n = n
206
+ else if (n > 1073741823) n = n / 1048576
207
+ printf "%d", n
208
+ }' || true
209
+ )"
210
+ VM_CPUS="$(echo "$MACHINE" | awk '{print $3}')"
211
+ fi
212
+ fi
213
+
214
+ if [ "$VM_STATE" = stopped ]; then
215
+ note "the podman machine is not running, so these are its configured sizes, not live ones"
216
+ note " start it to measure: podman machine start"
217
+ fi
218
+ if [ "$VM_MIB" -eq 0 ]; then
219
+ not_applicable "podman could not report the machine's memory" \
220
+ "try \`podman machine start\`, then run this again"
221
+ fi
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # 4. The container cap, out of agents.yaml
225
+ #
226
+ # A numeric `memory:` can only be the sandbox's: the memory *graph* block takes
227
+ # a directory and an embedding, and an agent's binding takes a boolean or a map.
228
+ # Per-agent overrides are read too, and the largest wins — it is the one that
229
+ # decides whether the sum fits.
230
+ # ---------------------------------------------------------------------------
231
+
232
+ CONTAINER_MIB="$(
233
+ grep -E '^[[:space:]]+memory:[[:space:]]*[0-9]+[[:space:]]*(#.*)?$' "$CONF" 2> /dev/null \
234
+ | sed -E 's/.*memory:[[:space:]]*([0-9]+).*/\1/' | sort -n | tail -1 || true
235
+ )"
236
+ CONTAINER_SOURCE="$CONF"
237
+ if [ -z "$CONTAINER_MIB" ]; then
238
+ if grep -Eq '^[[:space:]]+hardening:[[:space:]]*strict' "$CONF"; then
239
+ CONTAINER_MIB="$STRICT_CONTAINER_MIB"
240
+ CONTAINER_SOURCE="default (strict)"
241
+ else
242
+ CONTAINER_MIB="$DEFAULT_CONTAINER_MIB"
243
+ CONTAINER_SOURCE="default"
244
+ fi
245
+ fi
246
+
247
+ CONTAINER_CPUS="$(
248
+ grep -E '^[[:space:]]+cpus:[[:space:]]*[0-9]+' "$CONF" 2> /dev/null \
249
+ | sed -E 's/.*cpus:[[:space:]]*([0-9]+).*/\1/' | sort -n | tail -1 || true
250
+ )"
251
+ CONTAINER_CPUS="${CONTAINER_CPUS:-4}"
252
+
253
+ if [ "$CONTAINER_MIB" -eq 0 ]; then
254
+ note "sandbox.memory is 0 — unconstrained, so one container may take the whole VM"
255
+ CONTAINER_MIB="$VM_MIB"
256
+ fi
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # 5. The indexes
260
+ #
261
+ # An index is read through the page cache and the cache lives in the VM, so an
262
+ # index larger than the VM cannot be cached at all — which is the difference
263
+ # between a five-second grep and a two-minute one. Counted once: the mount is
264
+ # read-only and shared by every container.
265
+ # ---------------------------------------------------------------------------
266
+
267
+ INDEX_MIB=0
268
+ INDEX_NAME=""
269
+ INDEX_COUNT=0
270
+ if [ -d "$ASSETS" ]; then
271
+ while IFS= read -r manifest; do
272
+ [ -n "$manifest" ] || continue
273
+ dir="$(dirname "$manifest")"
274
+ size="$(du -sk "$dir" 2> /dev/null | awk '{printf "%d", $1 / 1024}' || echo 0)"
275
+ INDEX_COUNT=$((INDEX_COUNT + 1))
276
+ if [ "${size:-0}" -gt "$INDEX_MIB" ]; then
277
+ INDEX_MIB="$size"
278
+ INDEX_NAME="$dir"
279
+ fi
280
+ done <<EOF
281
+ $(find "$ASSETS" -maxdepth 3 -name manifest.json -type f 2> /dev/null || true)
282
+ EOF
283
+ fi
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # 6. The probe — one container, one real read, peak RSS while it runs
287
+ #
288
+ # Process RSS, deliberately, not the cgroup's `memory.current`: that includes
289
+ # page cache, and the index is already a term of its own in the budget. Summing
290
+ # VmRSS across /proc inside the container is exactly the container's processes,
291
+ # because it has its own pid namespace.
292
+ #
293
+ # The command is `local` — it makes no network call. A gate that needed a
294
+ # credential would fail for reasons that are not about this machine.
295
+ # ---------------------------------------------------------------------------
296
+
297
+ PROBE_NAME=none
298
+ PROBE_SECS=0
299
+ SAMPLED_MIB=0
300
+
301
+ probe() {
302
+ if [ -z "$INDEX_NAME" ]; then
303
+ note "no index under ${ASSETS}/ to read — nothing representative to probe"
304
+ return 0
305
+ fi
306
+ image="${ZEN_IMAGE:-$(zen sandbox status 2> /dev/null | awk '$1 == "image" { print $2 }' || true)}"
307
+ if [ -z "$image" ]; then
308
+ note "no sandbox image to probe with — run \`zen sandbox up\`, or pass --peak-rss"
309
+ return 0
310
+ fi
311
+ PROBE_NAME="read $INDEX_NAME"
312
+
313
+ # Passed as one argv element, so no second shell re-parses it. The loop
314
+ # bounds itself rather than trusting `timeout` to be in the project's image.
315
+ script="$(
316
+ cat << EOS
317
+ ( zen --version > /dev/null 2>&1; grep -rl zzzz-not-present /assets > /dev/null 2>&1 ) &
318
+ p=\$!
319
+ peak=0
320
+ ticks=0
321
+ while kill -0 \$p 2> /dev/null; do
322
+ cur=\$(awk '/^VmRSS:/ { s += \$2 } END { print s + 0 }' /proc/[0-9]*/status 2> /dev/null)
323
+ if [ "\${cur:-0}" -gt "\$peak" ]; then peak=\$cur; fi
324
+ ticks=\$((ticks + 1))
325
+ if [ \$ticks -gt $((PROBE_TIMEOUT * 5)) ]; then kill \$p 2> /dev/null; break; fi
326
+ sleep 0.2
327
+ done
328
+ wait \$p 2> /dev/null
329
+ echo "PEAK_KB=\$peak"
330
+ EOS
331
+ )"
332
+
333
+ started="$(date +%s)"
334
+ out="$(
335
+ podman run --rm -v "$PWD/$ASSETS:/assets:ro" \
336
+ --memory "${CONTAINER_MIB}m" --entrypoint="" \
337
+ "$image" sh -c "$script" 2> /dev/null || true
338
+ )"
339
+ PROBE_SECS=$(($(date +%s) - started))
340
+
341
+ kb="$(printf '%s' "$out" | sed -n 's/^PEAK_KB=\([0-9]*\)$/\1/p' | tail -1 || true)"
342
+ if [ -n "${kb:-}" ] && [ "${kb:-0}" -gt 0 ]; then
343
+ SAMPLED_MIB=$((kb / 1024))
344
+ else
345
+ note "the probe reported nothing — using the floor of ${PEAK_FLOOR_MIB} MiB"
346
+ fi
347
+ }
348
+
349
+ if [ "$PEAK_RSS" -gt 0 ]; then
350
+ PEAK_MIB="$PEAK_RSS"
351
+ PEAK_SOURCE="--peak-rss"
352
+ elif [ "$SKIP_PROBE" -eq 1 ]; then
353
+ PEAK_MIB="$PEAK_FLOOR_MIB"
354
+ PEAK_SOURCE="floor (--skip-probe)"
355
+ else
356
+ probe
357
+ if [ "$SAMPLED_MIB" -gt "$PEAK_FLOOR_MIB" ]; then
358
+ PEAK_MIB="$SAMPLED_MIB"
359
+ PEAK_SOURCE="sampled at concurrency 1"
360
+ elif [ "$SAMPLED_MIB" -gt 0 ]; then
361
+ PEAK_MIB="$PEAK_FLOOR_MIB"
362
+ PEAK_SOURCE="floor — the probe measured ${SAMPLED_MIB} MiB, below it"
363
+ else
364
+ # The probe did not run, or reported nothing. `note` has already said
365
+ # which; do not claim a measurement that was never taken.
366
+ PEAK_MIB="$PEAK_FLOOR_MIB"
367
+ PEAK_SOURCE="floor (unmeasured)"
368
+ fi
369
+ fi
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # 7. The verdict
373
+ # ---------------------------------------------------------------------------
374
+
375
+ ADMITTED_MIB=$((CONCURRENCY * CONTAINER_MIB))
376
+ REQUIRED_MIB=$((INDEX_MIB + CONCURRENCY * PEAK_MIB + HEADROOM_MIB))
377
+
378
+ OVERSUBSCRIBED=0
379
+ if [ "$ADMITTED_MIB" -gt "$VM_MIB" ]; then OVERSUBSCRIBED=1; fi
380
+
381
+ SUFFICIENT=1
382
+ if [ "$REQUIRED_MIB" -gt "$VM_MIB" ]; then SUFFICIENT=0; fi
383
+
384
+ # The width that does fit today, which is the remedy available on every machine.
385
+ FITS=0
386
+ budget=$((VM_MIB - INDEX_MIB - HEADROOM_MIB))
387
+ if [ "$budget" -gt 0 ]; then FITS=$((budget / PEAK_MIB)); fi
388
+
389
+ # Rounded up to the next whole GiB, because `machine set` takes MiB and nobody
390
+ # wants a ceiling that is exact.
391
+ SUGGEST_MIB=$(((REQUIRED_MIB + 1023) / 1024 * 1024))
392
+
393
+ if [ "$AS_JSON" -eq 1 ]; then
394
+ printf '{"applicable":true,"sufficient":%s,"oversubscribed":%s,' \
395
+ "$([ "$SUFFICIENT" -eq 1 ] && echo true || echo false)" \
396
+ "$([ "$OVERSUBSCRIBED" -eq 1 ] && echo true || echo false)"
397
+ printf '"concurrency":%s,"hostMib":%s,"hostFreeMib":%s,' \
398
+ "$CONCURRENCY" "$HOST_MIB" "$HOST_FREE_MIB"
399
+ printf '"vm":{"name":"%s","state":"%s","memoryMib":%s,"swapMib":%s,"cpus":%s},' \
400
+ "$VM_NAME" "$VM_STATE" "$VM_MIB" "$VM_SWAP_MIB" "$VM_CPUS"
401
+ printf '"container":{"memoryMib":%s,"cpus":%s,"source":"%s"},' \
402
+ "$CONTAINER_MIB" "$CONTAINER_CPUS" "$CONTAINER_SOURCE"
403
+ printf '"index":{"largestMib":%s,"name":"%s","count":%s},' \
404
+ "$INDEX_MIB" "$INDEX_NAME" "$INDEX_COUNT"
405
+ printf '"peakRssMib":%s,"peakSource":"%s","probe":"%s","probeSeconds":%s,' \
406
+ "$PEAK_MIB" "$PEAK_SOURCE" "$PROBE_NAME" "$PROBE_SECS"
407
+ printf '"admittedMib":%s,"requiredMib":%s,"suggestMib":%s,"fitsAtConcurrency":%s}\n' \
408
+ "$ADMITTED_MIB" "$REQUIRED_MIB" "$SUGGEST_MIB" "$FITS"
409
+ else
410
+ say "host $(gib "$HOST_MIB") GiB total · $(gib "$HOST_FREE_MIB") GiB free"
411
+ swap="$VM_SWAP_MIB MiB swap"
412
+ if [ "$VM_SWAP_MIB" -eq 0 ]; then swap="no swap — the kill is immediate, not gradual"; fi
413
+ say "vm $(gib "$VM_MIB") GiB total · $swap · ${VM_CPUS} cpus ${VM_NAME:-?} ($VM_STATE)"
414
+ say "container ${CONTAINER_MIB} MiB cap · ${CONTAINER_CPUS} cpus ${CONTAINER_SOURCE}"
415
+ if [ "$INDEX_COUNT" -gt 0 ]; then
416
+ say "index $(gib "$INDEX_MIB") GiB ${INDEX_NAME} (largest of ${INDEX_COUNT})"
417
+ else
418
+ say "index none found under ${ASSETS}/"
419
+ fi
420
+ timing=""
421
+ if [ "$PROBE_SECS" -gt 0 ]; then timing=" · probe ${PROBE_SECS}s"; fi
422
+ say "peak rss ${PEAK_MIB} MiB ${PEAK_SOURCE}${timing}"
423
+ say ""
424
+
425
+ if [ "$OVERSUBSCRIBED" -eq 1 ]; then
426
+ say "admission ${CONCURRENCY} x ${CONTAINER_MIB} MiB = $(gib "$ADMITTED_MIB") GiB permitted against a $(gib "$VM_MIB") GiB vm OVERSUBSCRIBED"
427
+ if [ "$CONTAINER_MIB" -gt "$VM_MIB" ]; then
428
+ say " one container alone is permitted more than the whole machine"
429
+ else
430
+ say " nothing will refuse the sum; only the containers not all"
431
+ say " peaking together is keeping this alive"
432
+ fi
433
+ fi
434
+
435
+ if [ "$SUFFICIENT" -eq 1 ]; then
436
+ say "concurrency ${CONCURRENCY} needs ~$(gib "$REQUIRED_MIB") GiB vm has $(gib "$VM_MIB") GiB ok"
437
+ else
438
+ say "concurrency ${CONCURRENCY} needs ~$(gib "$REQUIRED_MIB") GiB vm has $(gib "$VM_MIB") GiB INSUFFICIENT"
439
+ say " fix: podman machine stop && podman machine set --memory ${SUGGEST_MIB} && podman machine start"
440
+ if [ "$FITS" -ge 1 ]; then
441
+ say " or: run the batch at --concurrency ${FITS}"
442
+ else
443
+ say " or: nothing fits — the index alone exceeds this vm; resizing is the only way"
444
+ fi
445
+ if [ "$SUGGEST_MIB" -gt "$HOST_MIB" ]; then
446
+ say " note: that is more than the host has. Lower the concurrency instead"
447
+ fi
448
+ fi
449
+
450
+ if [ -n "$NOTES" ]; then
451
+ say ""
452
+ printf '%s' "$NOTES" | sed 's/^/note: /'
453
+ fi
454
+ fi
455
+
456
+ if [ "$SUFFICIENT" -eq 1 ]; then exit 0; fi
457
+ exit 1