@mu-crew/muzzle 0.1.1 → 0.2.0
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/bin/muzzle +6 -2
- package/extension/muzzle.ts +16 -0
- package/package.json +4 -4
- package/skills/muzzle/SKILL.md +34 -2
- package/skills/muzzle/recipes/tune.md +81 -1
package/bin/muzzle
CHANGED
|
@@ -34,8 +34,12 @@ try {
|
|
|
34
34
|
if (typeof process.execve === "function") {
|
|
35
35
|
try {
|
|
36
36
|
process.execve(binary, [binary, ...process.argv.slice(2)], process.env);
|
|
37
|
-
} catch {
|
|
38
|
-
//
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// Say so: the spawn below brings back the group-kill leak (D16), and
|
|
39
|
+
// silently is how it went unnoticed. Spawn then reports any real error.
|
|
40
|
+
console.error(
|
|
41
|
+
`muzzle: execve failed (${error.code ?? error.message}), falling back to spawn: a group kill may leak the job`,
|
|
42
|
+
);
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
package/extension/muzzle.ts
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
// tool_result: read the report, append `muzzle: waited Ws for N slots, ran
|
|
8
8
|
// Rs` and a `muzzle` custom entry with the same numbers (D26).
|
|
9
9
|
//
|
|
10
|
+
// One copy per pi process: the pi package and `-e <path>` can both load this
|
|
11
|
+
// file, and two copies would each wrap the call (the CLI also refuses to wrap
|
|
12
|
+
// a command that already runs muzzle, D22). The first copy to load claims
|
|
13
|
+
// GUARD on globalThis; later copies register nothing. The claim is released
|
|
14
|
+
// on session_shutdown, so /reload and session replacement load it again.
|
|
15
|
+
//
|
|
10
16
|
// Nothing here may block a call: a missing binary or a failing wrap leaves
|
|
11
17
|
// the call as it is. A throwing tool_call handler blocks the tool in pi, so
|
|
12
18
|
// every path is caught.
|
|
@@ -134,6 +140,9 @@ function wrap(bin: string, command: string, cwd: string, timeout?: number): Prom
|
|
|
134
140
|
});
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
/** The process-wide claim; registry-global so separate module copies share it. */
|
|
144
|
+
export const GUARD = Symbol.for("@mu-crew/muzzle/extension");
|
|
145
|
+
|
|
137
146
|
export interface Deps {
|
|
138
147
|
findBinary?: () => string | null;
|
|
139
148
|
tmpRoot?: string;
|
|
@@ -142,6 +151,10 @@ export interface Deps {
|
|
|
142
151
|
export function createExtension(deps: Deps = {}) {
|
|
143
152
|
const findBinary = deps.findBinary ?? (() => findMuzzle());
|
|
144
153
|
return function (pi: ExtensionAPI) {
|
|
154
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
155
|
+
if (g[GUARD] !== undefined) return;
|
|
156
|
+
const claim = {};
|
|
157
|
+
g[GUARD] = claim;
|
|
145
158
|
let bin: string | null | undefined;
|
|
146
159
|
let reportDir: string | undefined;
|
|
147
160
|
const pending = new Map<string, string>();
|
|
@@ -167,6 +180,8 @@ export function createExtension(deps: Deps = {}) {
|
|
|
167
180
|
if (bin === null) warn(ctx, "muzzle not found: bash calls run unwrapped");
|
|
168
181
|
}
|
|
169
182
|
if (bin === null) return;
|
|
183
|
+
// Belt and braces for the guard: our own rewrite is never rewritten.
|
|
184
|
+
if (input.command.startsWith(`${shellQuote(bin)} run `)) return;
|
|
170
185
|
const timeout = typeof input.timeout === "number" ? input.timeout : undefined;
|
|
171
186
|
const wrapped = await wrap(bin, input.command, ctx.cwd, timeout);
|
|
172
187
|
if (wrapWarned) {
|
|
@@ -213,6 +228,7 @@ export function createExtension(deps: Deps = {}) {
|
|
|
213
228
|
});
|
|
214
229
|
|
|
215
230
|
pi.on("session_shutdown", async () => {
|
|
231
|
+
if (g[GUARD] === claim) delete g[GUARD];
|
|
216
232
|
if (reportDir) rmSync(reportDir, { recursive: true, force: true });
|
|
217
233
|
reportDir = undefined;
|
|
218
234
|
pending.clear();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mu-crew/muzzle",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Per-host admission for heavy commands, with a run log that tunes itself",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"optionalDependencies": {
|
|
45
|
-
"@mu-crew/muzzle-linux-x64": "0.
|
|
46
|
-
"@mu-crew/muzzle-linux-arm64": "0.
|
|
47
|
-
"@mu-crew/muzzle-darwin-arm64": "0.
|
|
45
|
+
"@mu-crew/muzzle-linux-x64": "0.2.0",
|
|
46
|
+
"@mu-crew/muzzle-linux-arm64": "0.2.0",
|
|
47
|
+
"@mu-crew/muzzle-darwin-arm64": "0.2.0"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/skills/muzzle/SKILL.md
CHANGED
|
@@ -53,13 +53,21 @@ muzzle run --rule gate --run-timeout 900 -- contrib/fast-gate
|
|
|
53
53
|
- **`timeout` goes inside `muzzle run`, never outside it.** An outer
|
|
54
54
|
`timeout` counts the queue wait and kills the gate before it starts. Use
|
|
55
55
|
`--run-timeout S`, which counts from admission and exits 124.
|
|
56
|
-
- In pi, a command that
|
|
57
|
-
extension, so pi's own bash `timeout` still counts the wait. Give a
|
|
56
|
+
- In pi, a command that already runs muzzle (`muzzle run …`, by any
|
|
57
|
+
path) is left alone by the extension, so pi's own bash `timeout` still counts the wait. Give a
|
|
58
58
|
hand-wrapped call no pi timeout, and put the limit in `--run-timeout`.
|
|
59
59
|
- muzzle sets `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS`, `MAKEFLAGS` and
|
|
60
60
|
`MUZZLE_SLOTS` from the claim and overwrites any it inherits. Drop
|
|
61
61
|
hand-set caps from the command; an assignment inside the command string
|
|
62
62
|
still wins.
|
|
63
|
+
- **Wrap a script as a whole.** muzzle can't see inside a script, so a
|
|
64
|
+
script you write that runs builds or tests (`./check.sh`, `python3
|
|
65
|
+
/tmp/x.py`, a heredoc) matches no rule and runs unclaimed. Wrap the
|
|
66
|
+
whole script under the rule of the heaviest thing it runs:
|
|
67
|
+
`muzzle run --rule cargo-test -- ./check.sh`, or
|
|
68
|
+
`muzzle run --rule cargo-test -- bash -c 'cargo build && cargo test'`.
|
|
69
|
+
Don't move a gate into a script to skip the queue: the machine still
|
|
70
|
+
pays for it, and every other agent's gate slows down.
|
|
63
71
|
- A background launch (`(gate > log 2>&1 &)`) keeps its slots: `muzzle run`
|
|
64
72
|
returns at once and a keeper holds the claim until the job exits. Polling
|
|
65
73
|
the log is fine. A poll that repeats the gate's command line would claim a
|
|
@@ -98,6 +106,30 @@ muzzle run --rule gate --run-timeout 900 -- contrib/fast-gate
|
|
|
98
106
|
running in the container. Its slots stay held until it exits, so find
|
|
99
107
|
and kill it there.
|
|
100
108
|
|
|
109
|
+
## The config
|
|
110
|
+
|
|
111
|
+
`~/.config/muzzle/config.toml` holds global `[[rule]]` entries. A
|
|
112
|
+
`[path."<dir>"]` table changes rules for the repos under a directory, and
|
|
113
|
+
`include = ["machine.toml"]` merges a per-machine file. Example:
|
|
114
|
+
|
|
115
|
+
```toml
|
|
116
|
+
include = ["machine.toml"]
|
|
117
|
+
|
|
118
|
+
[[rule]]
|
|
119
|
+
name = "cargo-test"
|
|
120
|
+
match = '\bcargo (test|nextest|clippy|build)\b'
|
|
121
|
+
slots = 3
|
|
122
|
+
|
|
123
|
+
[path."~/hacking/crew"]
|
|
124
|
+
[[path."~/hacking/crew".rule]]
|
|
125
|
+
name = "cargo-test"
|
|
126
|
+
slots = 2 # reweight only; match is inherited
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There is no discovery mode. To set muzzle up on a machine, write the rules
|
|
130
|
+
from the heavy commands you know, check them with `muzzle match`, and tune
|
|
131
|
+
from the run log: [recipes/tune.md](recipes/tune.md).
|
|
132
|
+
|
|
101
133
|
## Recipes
|
|
102
134
|
|
|
103
135
|
- Changing a rule's weight or pattern, reading `muzzle report`:
|
|
@@ -8,6 +8,19 @@ under, so `report --compare` can split by them later.
|
|
|
8
8
|
Run on the host whose config you are changing. `runs.jsonl` is local machine
|
|
9
9
|
state: read it here, and never publish it.
|
|
10
10
|
|
|
11
|
+
## 0. A new machine: write the config, then trial it
|
|
12
|
+
|
|
13
|
+
There is no discovery mode. Start from what you know:
|
|
14
|
+
|
|
15
|
+
1. List the heavy commands agents run here: gates, `cargo test`, `npm test`,
|
|
16
|
+
e2e suites. museum helps (step 2).
|
|
17
|
+
2. Write a rule for each, in the shared file, with a weight from the
|
|
18
|
+
command's usual job cap (`CARGO_BUILD_JOBS=4` means 4). Check each
|
|
19
|
+
pattern with `muzzle match -- 'contrib/gate --full'`.
|
|
20
|
+
3. Put this machine's budget and weights in an untracked `machine.toml` next
|
|
21
|
+
to it (layout in step 3), so one shared file serves every machine.
|
|
22
|
+
4. Run real work for a day or two, then start the loop at step 1.
|
|
23
|
+
|
|
11
24
|
## 1. Read the report, machine figures first
|
|
12
25
|
|
|
13
26
|
```sh
|
|
@@ -22,6 +35,7 @@ Read the machine section before the rows:
|
|
|
22
35
|
| CPU efficiency (low) | claims are bigger than the jobs use: weights too big |
|
|
23
36
|
| utilisation near 100% while the machine's CPU stays low | the budget is saturated but the cores aren't: the reserve is too big |
|
|
24
37
|
| queue non-empty | how often anyone waited at all. Near 0% means the weights cost nothing yet |
|
|
38
|
+
| unclaimed load (cores) | load that no claim accounts for: `load1 − held` at admission. High means heavy work runs outside muzzle. Find the unmatched heavy commands (step 2) and add rules, or raise `reserve` to leave room for them. It is rough: the load average lags, and on Linux it counts I/O wait |
|
|
25
39
|
|
|
26
40
|
Then the rows. `suggest` names its evidence and carries a tag:
|
|
27
41
|
|
|
@@ -38,6 +52,24 @@ Then the rows. `suggest` names its evidence and carries a tag:
|
|
|
38
52
|
Narrow to one rule with `--rule NAME`, or to a period with `--since D`
|
|
39
53
|
and `--until D` (`now`, `7d`, `12h`, `30m`, `YYYY-MM-DD`, or RFC 3339).
|
|
40
54
|
|
|
55
|
+
### Per repo
|
|
56
|
+
|
|
57
|
+
One rule can behave very differently per repo: a `cargo test` that takes
|
|
58
|
+
1 s in one repo can take 30 s in another. Split the rows by repo:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
muzzle report --by repo --since 7d --rule cargo-test
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each row is one (rule, repo); `(none)` is runs outside any repo. The
|
|
65
|
+
`now` column is the slots the **current** config gives that repo, and
|
|
66
|
+
where they come from: `now 3 (from [path."~/hacking/crew"] in
|
|
67
|
+
machine.toml)`. When the runs used a different weight, it adds `; runs used
|
|
68
|
+
4`. `not in the current config here` means the rule is off or absent for
|
|
69
|
+
that repo now, so there is no suggestion. When suggestions for one rule
|
|
70
|
+
differ a lot between repos, give the outliers a path table (step 3).
|
|
71
|
+
`--by repo` can't be combined with `--compare`.
|
|
72
|
+
|
|
41
73
|
**Done when** you have written down, for the rule you will change, its
|
|
42
74
|
current slots, run p50/p90, cores p50/p90 and the suggestion.
|
|
43
75
|
|
|
@@ -54,11 +86,53 @@ Check a candidate against the live config with
|
|
|
54
86
|
`muzzle match -- 'contrib/gate --full'`. It prints the rule, or `no rule
|
|
55
87
|
matches`.
|
|
56
88
|
|
|
89
|
+
Heavy work inside a script (`./run.sh`, `python3 /tmp/x.py`) matches no
|
|
90
|
+
rule, because muzzle only sees the script's name. A high unclaimed load
|
|
91
|
+
with no unmatched commands in museum usually means this. The fix is
|
|
92
|
+
wrapping such scripts with `muzzle run --rule cargo-test -- ./run.sh`
|
|
93
|
+
(SKILL.md), not a rule for every script name.
|
|
94
|
+
|
|
57
95
|
## 3. Edit the config
|
|
58
96
|
|
|
59
97
|
`~/.config/muzzle/config.toml`. Change one rule per round, so the next
|
|
60
98
|
report shows what that change did.
|
|
61
99
|
|
|
100
|
+
Where a change goes:
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
# ~/.config/muzzle/config.toml: shared, tracked in dotfiles
|
|
104
|
+
include = ["machine.toml"]
|
|
105
|
+
|
|
106
|
+
[[rule]]
|
|
107
|
+
name = "cargo-test"
|
|
108
|
+
match = '\bcargo (test|nextest|clippy|build)\b'
|
|
109
|
+
slots = 3
|
|
110
|
+
|
|
111
|
+
[path."~/hacking/swayward-wm"] # every repo under here, every worktree
|
|
112
|
+
[[path."~/hacking/swayward-wm".rule]]
|
|
113
|
+
name = "cargo-test"
|
|
114
|
+
slots = 6 # reweight only; match inherited
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```toml
|
|
118
|
+
# ~/.config/muzzle/machine.toml: this machine only, untracked
|
|
119
|
+
reserve = 2
|
|
120
|
+
[[rule]]
|
|
121
|
+
name = "cargo-test"
|
|
122
|
+
slots = 4 # this machine's global weight
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **A weight for every repo on this machine**: the global rule in
|
|
126
|
+
`machine.toml`.
|
|
127
|
+
- **A weight for some repos**: a `[path."<dir>"]` table with just `name`
|
|
128
|
+
and `slots`. The key is a directory; it covers every repo under it and
|
|
129
|
+
all their worktrees. A nearer table wins over a further one.
|
|
130
|
+
- **A rule that should not fire in one repo**: `enabled = false` in its
|
|
131
|
+
table.
|
|
132
|
+
- After the edit, `muzzle match --json --cwd ~/hacking/swayward-wm/swayward
|
|
133
|
+
-- 'cargo test'` shows the slots there and `slots_from`, the file and
|
|
134
|
+
table they came from.
|
|
135
|
+
|
|
62
136
|
- **Weights**: set `slots` to the suggestion. For an `experiment`, that
|
|
63
137
|
is the `try N` value, and step 5 decides whether it stays.
|
|
64
138
|
- **New rules**: give each a `name`. The name is the label in reports and
|
|
@@ -83,7 +157,9 @@ Check the edit loads, and note the new hash:
|
|
|
83
157
|
muzzle doctor
|
|
84
158
|
```
|
|
85
159
|
|
|
86
|
-
A bad config exits 2 and names the key.
|
|
160
|
+
A bad config exits 2 and names the key and the file. `doctor` also lists
|
|
161
|
+
each include as `found` or `not found`: a typo in an include path is
|
|
162
|
+
skipped silently otherwise.
|
|
87
163
|
|
|
88
164
|
**Hand-wrapped calls carry their own weight.** A `--slots` on
|
|
89
165
|
`muzzle run` overrides the config, so editing the config changes nothing for briefs,
|
|
@@ -117,6 +193,10 @@ After a few days, or at least 5 clean runs at the new weight:
|
|
|
117
193
|
muzzle report --compare 2026-11-01..2026-11-03 2026-11-04..now --rule cargo-test
|
|
118
194
|
```
|
|
119
195
|
|
|
196
|
+
For a path-table change, compare per repo instead: run
|
|
197
|
+
`muzzle report --by repo --since 2026-11-04 --rule cargo-test` and check
|
|
198
|
+
the repo's row against the one you wrote down in step 1.
|
|
199
|
+
|
|
120
200
|
The output shows both periods, split by config hash when it changed inside
|
|
121
201
|
one, then an `A -> C` table of deltas. An experiment reads `confirmed` when
|
|
122
202
|
run p50 fell, `rejected` when it didn't.
|