@mu-crew/muzzle 0.0.0-stage → 0.1.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 +60 -0
- package/extension/muzzle.ts +223 -0
- package/extension/package.json +3 -0
- package/package.json +47 -4
- package/skills/muzzle/SKILL.md +109 -0
- package/skills/muzzle/recipes/bench.md +125 -0
- package/skills/muzzle/recipes/harnesses.md +149 -0
- package/skills/muzzle/recipes/tune.md +153 -0
- package/README.md +0 -3
package/bin/muzzle
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("node:child_process");
|
|
4
|
+
|
|
5
|
+
const packages = {
|
|
6
|
+
"linux-x64": "@mu-crew/muzzle-linux-x64",
|
|
7
|
+
"linux-arm64": "@mu-crew/muzzle-linux-arm64",
|
|
8
|
+
"darwin-arm64": "@mu-crew/muzzle-darwin-arm64",
|
|
9
|
+
};
|
|
10
|
+
const platform = `${process.platform}-${process.arch}`;
|
|
11
|
+
const packageName = packages[platform];
|
|
12
|
+
|
|
13
|
+
if (!packageName) {
|
|
14
|
+
console.error(
|
|
15
|
+
`muzzle: unsupported platform ${platform}; supported platforms: ${Object.keys(packages).join(", ")}`,
|
|
16
|
+
);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let binary;
|
|
21
|
+
try {
|
|
22
|
+
binary = require.resolve(`${packageName}/bin/muzzle`);
|
|
23
|
+
} catch {
|
|
24
|
+
console.error(
|
|
25
|
+
`muzzle: could not find ${packageName}; reinstall @mu-crew/muzzle for ${platform}`,
|
|
26
|
+
);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// D16: muzzle must be the process pi (or a shell) put at the head of the
|
|
31
|
+
// group, or it moves the job to a group of its own and a SIGKILL of the
|
|
32
|
+
// original group leaks the job and its slots. execve replaces node with
|
|
33
|
+
// muzzle in place, keeping the pid and the group. Node >= 22.15 / 23.11.
|
|
34
|
+
if (typeof process.execve === "function") {
|
|
35
|
+
try {
|
|
36
|
+
process.execve(binary, [binary, ...process.argv.slice(2)], process.env);
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through to spawn, which reports the error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Older Node: run muzzle as a child and forward signals. A group SIGKILL
|
|
43
|
+
// still leaks the job here.
|
|
44
|
+
const child = spawn(binary, process.argv.slice(2), { stdio: "inherit" });
|
|
45
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
46
|
+
for (const signal of signals) {
|
|
47
|
+
process.on(signal, () => child.kill(signal));
|
|
48
|
+
}
|
|
49
|
+
child.on("error", (error) => {
|
|
50
|
+
console.error(`muzzle: failed to run ${binary}: ${error.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
child.on("exit", (code, signal) => {
|
|
54
|
+
if (signal) {
|
|
55
|
+
for (const handledSignal of signals) process.removeAllListeners(handledSignal);
|
|
56
|
+
process.kill(process.pid, signal);
|
|
57
|
+
} else {
|
|
58
|
+
process.exit(code ?? 1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// muzzle pi extension (spec §Integrations "pi extension"; D22, D24, D26).
|
|
2
|
+
//
|
|
3
|
+
// tool_call on bash: ask `muzzle wrap` for the rewrite (D22: matching,
|
|
4
|
+
// quoting and the timeout move live in the CLI). On a match, run the wrapped
|
|
5
|
+
// command with `--report <tmp>/<id>.json` and drop pi's own timeout, so the
|
|
6
|
+
// queue wait can't trip it (D24: it is now `--run-timeout`).
|
|
7
|
+
// tool_result: read the report, append `muzzle: waited Ws for N slots, ran
|
|
8
|
+
// Rs` and a `muzzle` custom entry with the same numbers (D26).
|
|
9
|
+
//
|
|
10
|
+
// Nothing here may block a call: a missing binary or a failing wrap leaves
|
|
11
|
+
// the call as it is. A throwing tool_call handler blocks the tool in pi, so
|
|
12
|
+
// every path is caught.
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { accessSync, closeSync, constants, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
|
|
15
|
+
import { createRequire } from "node:module";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { delimiter, join } from "node:path";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
const PLATFORM_PACKAGES: Record<string, string> = {
|
|
21
|
+
"linux-x64": "@mu-crew/muzzle-linux-x64",
|
|
22
|
+
"linux-arm64": "@mu-crew/muzzle-linux-arm64",
|
|
23
|
+
"darwin-arm64": "@mu-crew/muzzle-darwin-arm64",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// `muzzle wrap` only reads config and matches a regex; it answers in a few
|
|
27
|
+
// ms. A wedged one must not hold the call for long.
|
|
28
|
+
const WRAP_TIMEOUT_MS = 5000;
|
|
29
|
+
|
|
30
|
+
export interface FindOptions {
|
|
31
|
+
/** Resolve a package-relative path, as `require.resolve` does. */
|
|
32
|
+
resolve?: (id: string) => string;
|
|
33
|
+
platform?: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isExecutable(file: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
if (!statSync(file).isFile()) return false;
|
|
40
|
+
accessSync(file, constants.X_OK);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A script, not the binary: bin/muzzle from a global `npm i -g` is a node
|
|
49
|
+
* shim. On a Node without process.execve it runs muzzle as a child, so
|
|
50
|
+
* muzzle isn't the group leader and pi's group SIGKILL leaks the job and its
|
|
51
|
+
* slots (D16).
|
|
52
|
+
*/
|
|
53
|
+
function isScript(file: string): boolean {
|
|
54
|
+
let fd: number | undefined;
|
|
55
|
+
try {
|
|
56
|
+
fd = openSync(file, "r");
|
|
57
|
+
const head = Buffer.alloc(2);
|
|
58
|
+
return readSync(fd, head, 0, 2, 0) === 2 && head.toString("latin1") === "#!";
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
} finally {
|
|
62
|
+
if (fd !== undefined) closeSync(fd);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The binary from this package's platform dependency first, then `muzzle` on
|
|
68
|
+
* PATH; null when neither exists. The platform binary is used directly,
|
|
69
|
+
* not through bin/muzzle, so a wrapped call doesn't pay for a node start
|
|
70
|
+
* and muzzle leads the bash group (D16). PATH entries that are scripts are
|
|
71
|
+
* skipped for the same reason.
|
|
72
|
+
*/
|
|
73
|
+
export function findMuzzle(opts: FindOptions = {}): string | null {
|
|
74
|
+
const resolve = opts.resolve ?? createRequire(import.meta.url).resolve;
|
|
75
|
+
const pkg = PLATFORM_PACKAGES[opts.platform ?? `${process.platform}-${process.arch}`];
|
|
76
|
+
if (pkg) {
|
|
77
|
+
try {
|
|
78
|
+
const own = resolve(`${pkg}/bin/muzzle`);
|
|
79
|
+
if (isExecutable(own)) return own;
|
|
80
|
+
} catch {
|
|
81
|
+
// not installed for this platform; fall through to PATH
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const dir of (opts.path ?? process.env.PATH ?? "").split(delimiter)) {
|
|
85
|
+
if (!dir) continue;
|
|
86
|
+
const candidate = join(dir, "muzzle");
|
|
87
|
+
if (isExecutable(candidate) && !isScript(candidate)) return candidate;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** POSIX single quoting, as `muzzle wrap` does it. */
|
|
93
|
+
export function shellQuote(s: string): string {
|
|
94
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A file name for a toolCallId. Nested ids carry `/` (`<parent>/<n>`). */
|
|
98
|
+
export function reportName(toolCallId: string): string {
|
|
99
|
+
return `${toolCallId.replace(/[^A-Za-z0-9_-]/g, (c) => `%${c.charCodeAt(0).toString(16)}`)}.json`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Report {
|
|
103
|
+
waited_s: number;
|
|
104
|
+
ran_s: number;
|
|
105
|
+
slots: number | null;
|
|
106
|
+
class: string | null;
|
|
107
|
+
rule: string | null;
|
|
108
|
+
exit: number;
|
|
109
|
+
enforced: boolean;
|
|
110
|
+
kept?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** D26, the same text `muzzle run` prints when `--report` is unset. */
|
|
114
|
+
export function resultLine(r: Report): string {
|
|
115
|
+
// D17: a nested run claims nothing; its report has no slots.
|
|
116
|
+
if (r.slots === null) return `muzzle: nested in an outer claim, ran ${Math.round(r.ran_s)}s`;
|
|
117
|
+
return `muzzle: waited ${Math.round(r.waited_s)}s for ${r.slots} slots, ran ${Math.round(r.ran_s)}s`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function wrap(bin: string, command: string, cwd: string, timeout?: number): Promise<string | null> {
|
|
121
|
+
const args = ["wrap", "--json", "--cwd", cwd];
|
|
122
|
+
if (typeof timeout === "number") args.push("--timeout", String(timeout));
|
|
123
|
+
args.push("--", command);
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
execFile(bin, args, { timeout: WRAP_TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
126
|
+
if (err) return reject(new Error(stderr.trim() || err.message));
|
|
127
|
+
try {
|
|
128
|
+
const reply = JSON.parse(stdout);
|
|
129
|
+
resolve(typeof reply.command === "string" ? reply.command : null);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
reject(e);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface Deps {
|
|
138
|
+
findBinary?: () => string | null;
|
|
139
|
+
tmpRoot?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createExtension(deps: Deps = {}) {
|
|
143
|
+
const findBinary = deps.findBinary ?? (() => findMuzzle());
|
|
144
|
+
return function (pi: ExtensionAPI) {
|
|
145
|
+
let bin: string | null | undefined;
|
|
146
|
+
let reportDir: string | undefined;
|
|
147
|
+
const pending = new Map<string, string>();
|
|
148
|
+
// A wrap-failure warning is on the footer; the next good wrap clears it.
|
|
149
|
+
let wrapWarned = false;
|
|
150
|
+
|
|
151
|
+
const warn = (ctx: { ui: { setStatus(key: string, text: string | undefined): void } }, text: string | undefined) => {
|
|
152
|
+
try {
|
|
153
|
+
ctx.ui.setStatus("muzzle", text);
|
|
154
|
+
} catch {
|
|
155
|
+
// no UI in this mode
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
160
|
+
try {
|
|
161
|
+
if (event.toolName !== "bash") return;
|
|
162
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
163
|
+
if (typeof input.command !== "string") return;
|
|
164
|
+
if (bin === undefined) {
|
|
165
|
+
bin = findBinary();
|
|
166
|
+
// Looked up once per runtime, so the warning shows once.
|
|
167
|
+
if (bin === null) warn(ctx, "muzzle not found: bash calls run unwrapped");
|
|
168
|
+
}
|
|
169
|
+
if (bin === null) return;
|
|
170
|
+
const timeout = typeof input.timeout === "number" ? input.timeout : undefined;
|
|
171
|
+
const wrapped = await wrap(bin, input.command, ctx.cwd, timeout);
|
|
172
|
+
if (wrapWarned) {
|
|
173
|
+
wrapWarned = false;
|
|
174
|
+
warn(ctx, undefined);
|
|
175
|
+
}
|
|
176
|
+
if (wrapped === null || !wrapped.startsWith("muzzle run ")) return;
|
|
177
|
+
reportDir ??= mkdtempSync(join(deps.tmpRoot ?? tmpdir(), "muzzle-pi-"));
|
|
178
|
+
const report = join(reportDir, reportName(event.toolCallId));
|
|
179
|
+
rmSync(report, { force: true });
|
|
180
|
+
input.command = `${shellQuote(bin)} run --report ${shellQuote(report)} ${wrapped.slice("muzzle run ".length)}`;
|
|
181
|
+
delete input.timeout;
|
|
182
|
+
pending.set(event.toolCallId, report);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
wrapWarned = true;
|
|
185
|
+
warn(ctx, `muzzle wrap failed, calls run unwrapped: ${e instanceof Error ? e.message : String(e)}`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
pi.on("tool_result", async (event) => {
|
|
190
|
+
const report = pending.get(event.toolCallId);
|
|
191
|
+
if (report === undefined) return;
|
|
192
|
+
pending.delete(event.toolCallId);
|
|
193
|
+
let r: Report;
|
|
194
|
+
try {
|
|
195
|
+
r = JSON.parse(readFileSync(report, "utf8"));
|
|
196
|
+
} catch {
|
|
197
|
+
// No report: aborted or killed before muzzle could write it.
|
|
198
|
+
return;
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(report, { force: true });
|
|
201
|
+
}
|
|
202
|
+
const line = resultLine(r);
|
|
203
|
+
const content = [...event.content];
|
|
204
|
+
const last = content[content.length - 1];
|
|
205
|
+
if (last?.type === "text") {
|
|
206
|
+
content[content.length - 1] = { ...last, text: last.text.trimEnd() ? `${last.text.trimEnd()}\n\n${line}` : line };
|
|
207
|
+
} else {
|
|
208
|
+
content.push({ type: "text", text: line });
|
|
209
|
+
}
|
|
210
|
+
pi.appendEntry("muzzle", { toolCallId: event.toolCallId, ...r });
|
|
211
|
+
// Keep structuredContent: replacing content alone would drop it.
|
|
212
|
+
return { content, structuredContent: event.structuredContent };
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("session_shutdown", async () => {
|
|
216
|
+
if (reportDir) rmSync(reportDir, { recursive: true, force: true });
|
|
217
|
+
reportDir = undefined;
|
|
218
|
+
pending.clear();
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default createExtension();
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mu-crew/muzzle",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Per-host admission for heavy commands, with a run log that tunes itself",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/mu-crew/muzzle.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-coding-agent",
|
|
13
|
+
"agents",
|
|
14
|
+
"cpu",
|
|
15
|
+
"queue"
|
|
16
|
+
],
|
|
17
|
+
"bin": {
|
|
18
|
+
"muzzle": "bin/muzzle"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/muzzle",
|
|
22
|
+
"extension",
|
|
23
|
+
"skills"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test 'test/*.test.ts'"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extension/muzzle.ts"
|
|
31
|
+
],
|
|
32
|
+
"skills": [
|
|
33
|
+
"./skills"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@earendil-works/pi-coding-agent": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"@mu-crew/muzzle-linux-x64": "0.1.0",
|
|
46
|
+
"@mu-crew/muzzle-linux-arm64": "0.1.0",
|
|
47
|
+
"@mu-crew/muzzle-darwin-arm64": "0.1.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: muzzle
|
|
3
|
+
description: Per-host admission for heavy commands (gates, builds, test suites). Use when a bash result carries a `muzzle:` line (queued, admitted, waited/ran, gave up, killed while queued), when wrapping a heavy command by hand with `muzzle run`, when tuning muzzle's weights or benchmarking it, or when hooking muzzle into a harness.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# muzzle
|
|
7
|
+
|
|
8
|
+
muzzle queues each heavy command for a weighted share of this machine's slot
|
|
9
|
+
budget (cores minus a reserve), runs it, and logs what it used. Rules in
|
|
10
|
+
`~/.config/muzzle/config.toml` decide which commands claim slots and how
|
|
11
|
+
many. A command that matches no rule runs at once, unclaimed. There is no
|
|
12
|
+
daemon: every waiting muzzle re-checks the queue itself. `muzzle <cmd> --help`
|
|
13
|
+
is the reference for flags; this skill covers what the help can't tell you.
|
|
14
|
+
|
|
15
|
+
In pi, the muzzle extension wraps matching bash calls for you. Elsewhere you
|
|
16
|
+
wrap by hand, or a harness hook does it.
|
|
17
|
+
|
|
18
|
+
## Reading muzzle's lines
|
|
19
|
+
|
|
20
|
+
| line (stderr, or appended to a pi result) | means | do |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| `muzzle: queued 3/5, 14/14 slots held (worker-7 gate 6, …)` | waiting; printed every 30 s. Holders read `agent rule slots` | wait. The command has not started |
|
|
23
|
+
| `muzzle: admitted after 312s (6 slots)` | the wait ended, the command starts now | nothing |
|
|
24
|
+
| `muzzle: waited 312s for 6 slots, ran 241s` | the run finished; the exit status is the command's own | judge the command by its output, not by the wait |
|
|
25
|
+
| `muzzle: nested in an outer claim, ran 4s` | it ran inside another claim and took no slots | nothing |
|
|
26
|
+
| `muzzle: gave up after 60s queued (position 2/4, --timeout)` | exit 75: `--timeout` expired before admission, nothing ran | retry later, or drop `--timeout` |
|
|
27
|
+
| `muzzle: killed while queued (position 2/4, waited 120s). Put timeout inside …` | something killed muzzle while it waited: usually a harness or `timeout` counting the wait | move the timeout (below); nothing ran |
|
|
28
|
+
|
|
29
|
+
A long wait is the queue working, not a failure: other gates are using the
|
|
30
|
+
machine. Run `muzzle status` to see who holds the slots.
|
|
31
|
+
|
|
32
|
+
## Wrapping by hand
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
muzzle run --rule cargo-test -- cargo test --locked
|
|
36
|
+
muzzle run --rule gate --run-timeout 900 -- contrib/fast-gate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `--rule NAME` alone takes the weight from the config, so a later tuning
|
|
40
|
+
applies. `--slots N` pins the weight and overrides the config. A name the
|
|
41
|
+
config doesn't have prints `rule NAME is not in the config; claiming 1
|
|
42
|
+
slot(s)` and runs: fix the name from the `name =` lines in the config.
|
|
43
|
+
- **muzzle is the whole command.** Start the call with `muzzle`, and put
|
|
44
|
+
pipes, `cd` and `&&` inside `bash -c`:
|
|
45
|
+
`muzzle run --rule gate -- bash -c 'cd repo && contrib/fast-gate 2>&1 | tail -50'`.
|
|
46
|
+
`CMD` runs as argv, not through a shell, so a bare pipe can't go there.
|
|
47
|
+
- Piped or followed by another command (`muzzle run … | tail`), muzzle no
|
|
48
|
+
longer leads the shell's process group. Esc or a pi timeout then kills
|
|
49
|
+
muzzle but leaves the job running, holding its slots.
|
|
50
|
+
- In pi, a call that doesn't start with `muzzle` (`cd repo && muzzle run
|
|
51
|
+
--class high …`) is wrapped again by the extension. The inner run is
|
|
52
|
+
nested, so its `--class`, `--slots` and `--run-timeout` are ignored.
|
|
53
|
+
- **`timeout` goes inside `muzzle run`, never outside it.** An outer
|
|
54
|
+
`timeout` counts the queue wait and kills the gate before it starts. Use
|
|
55
|
+
`--run-timeout S`, which counts from admission and exits 124.
|
|
56
|
+
- In pi, a command that starts with `muzzle ` is left alone by the
|
|
57
|
+
extension, so pi's own bash `timeout` still counts the wait. Give a
|
|
58
|
+
hand-wrapped call no pi timeout, and put the limit in `--run-timeout`.
|
|
59
|
+
- muzzle sets `CARGO_BUILD_JOBS`, `RUST_TEST_THREADS`, `MAKEFLAGS` and
|
|
60
|
+
`MUZZLE_SLOTS` from the claim and overwrites any it inherits. Drop
|
|
61
|
+
hand-set caps from the command; an assignment inside the command string
|
|
62
|
+
still wins.
|
|
63
|
+
- A background launch (`(gate > log 2>&1 &)`) keeps its slots: `muzzle run`
|
|
64
|
+
returns at once and a keeper holds the claim until the job exits. Polling
|
|
65
|
+
the log is fine. A poll that repeats the gate's command line would claim a
|
|
66
|
+
second time, so poll with `tail` or `muzzle status`.
|
|
67
|
+
|
|
68
|
+
## Escape options
|
|
69
|
+
|
|
70
|
+
- **Unblocking work goes first with `--class high`**: a merge check or fix
|
|
71
|
+
that other agents are waiting on. High waiters go ahead of every normal
|
|
72
|
+
waiter. They never stop a running job. Agents spawned by mu
|
|
73
|
+
(`MU_MANAGED_AGENT=1`) default to `normal`, humans and other callers to
|
|
74
|
+
`high`. `MUZZLE_CLASS=high` in the environment does the same for every
|
|
75
|
+
call in that shell.
|
|
76
|
+
`muzzle run --class high --rule gate -- contrib/fast-gate`
|
|
77
|
+
- **Light commands run without muzzle**: status checks, reads, `git`, a
|
|
78
|
+
quick script. Leave them unwrapped when wrapping by hand; a command that
|
|
79
|
+
matches no rule never queues. If muzzle claims slots for a command that
|
|
80
|
+
only names a gate (a `grep`, a heredoc), the rule pattern is too loose.
|
|
81
|
+
Fix the config ([tune.md](recipes/tune.md), step 3) rather than working
|
|
82
|
+
around it per call.
|
|
83
|
+
|
|
84
|
+
## Enforced or advisory
|
|
85
|
+
|
|
86
|
+
`muzzle status` and `muzzle doctor` say `enforced` or `advisory`.
|
|
87
|
+
|
|
88
|
+
- **enforced**: Linux with a systemd user manager. The job runs in a scope
|
|
89
|
+
with a memory cap of the claim's share of RAM and a CPU weight. A job
|
|
90
|
+
that exceeds the cap is killed and logged as `killed: "oom"`. Ask for more
|
|
91
|
+
slots, or tune the rule.
|
|
92
|
+
- **advisory**: macOS, a container, or no user manager. Admission and the
|
|
93
|
+
queue still work. Nothing caps the job, and CPU and memory figures come
|
|
94
|
+
from rusage.
|
|
95
|
+
- A job that enters a container (`distrobox enter`, `podman exec`,
|
|
96
|
+
`toolbox run`, `docker exec`) is admitted but not capped or measured, and
|
|
97
|
+
is logged `enforced: false`. If the caller is killed, the job may keep
|
|
98
|
+
running in the container. Its slots stay held until it exits, so find
|
|
99
|
+
and kill it there.
|
|
100
|
+
|
|
101
|
+
## Recipes
|
|
102
|
+
|
|
103
|
+
- Changing a rule's weight or pattern, reading `muzzle report`:
|
|
104
|
+
[recipes/tune.md](recipes/tune.md).
|
|
105
|
+
- Making a trace, replaying it, the interactive probe:
|
|
106
|
+
[recipes/bench.md](recipes/bench.md).
|
|
107
|
+
- Installing or checking pi, Claude Code, Codex, opencode, or hand-wrapping;
|
|
108
|
+
harness timeouts; why a hook's `allow` skips the permission prompt:
|
|
109
|
+
[recipes/harnesses.md](recipes/harnesses.md).
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Benchmark muzzle on a real day
|
|
2
|
+
|
|
3
|
+
Make a trace from museum, check it, replay it without and with muzzle, and
|
|
4
|
+
read the summary. Run from a clone of the muzzle repo: the trace tools live
|
|
5
|
+
in its `bench/` directory, and `bench/README.md` there is the full
|
|
6
|
+
reference.
|
|
7
|
+
|
|
8
|
+
A trace is the one artefact here that gets published, so the sensitivity
|
|
9
|
+
rules come first.
|
|
10
|
+
|
|
11
|
+
## Sensitivity rules
|
|
12
|
+
|
|
13
|
+
- **linuxpc only.** The importer reads only `linuxpc/` session files and
|
|
14
|
+
refuses other hosts. The checker rejects a trace that names any other.
|
|
15
|
+
- **No command text, paths, names or other hosts in a trace.** Rows keep
|
|
16
|
+
only allowlisted fields. Agents become `a1, a2, …` and projects
|
|
17
|
+
`p1, p2, …`. `--keep-project NAME` keeps a name you have already made
|
|
18
|
+
public, and it also needs a `project NAME` line in
|
|
19
|
+
`bench/allowed-labels.txt`.
|
|
20
|
+
- **The sidecar stays local.** The mapping from each row back to its command
|
|
21
|
+
goes to `$XDG_STATE_HOME/muzzle/bench/<trace>.private.jsonl`. It is never
|
|
22
|
+
committed, and the pre-commit hook refuses it.
|
|
23
|
+
- **`runs.jsonl` is never published**, including the one a replay writes
|
|
24
|
+
under `--out`. `result.json` and `summary.md` carry no paths or command
|
|
25
|
+
text, and are the files that go in `docs/bench/`.
|
|
26
|
+
|
|
27
|
+
## 1. Make the trace
|
|
28
|
+
|
|
29
|
+
Install the pre-commit hook once per clone:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
ln -s ../../bench/pre-commit "$(git rev-parse --git-common-dir)/hooks/pre-commit"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then import one UTC day:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bench/from-museum --date 2026-10-21
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Rule labels come from your live config, through `muzzle match` run
|
|
42
|
+
locally. Waits and polls are labelled `light`, and an unmatched call of
|
|
43
|
+
30 s or more is `unmatched-heavy`. A rule name missing from
|
|
44
|
+
`allowed-labels.txt` stops the import: add a `rule NAME` line and re-run.
|
|
45
|
+
|
|
46
|
+
Measured durations exist only from 2026-10-17. Earlier days are
|
|
47
|
+
estimated, so use them as a workload to replay and not as a baseline. Each
|
|
48
|
+
row's `dur_src` says which it is.
|
|
49
|
+
|
|
50
|
+
## 2. Check it
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bench/check-trace
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
With no arguments it checks every file under `bench/traces/`. Errors name
|
|
57
|
+
the line, field and kind, never the value. Before committing a first batch
|
|
58
|
+
of traces from a new source, have one read-only refuter look for
|
|
59
|
+
identifying timing or label patterns the schema would let through.
|
|
60
|
+
|
|
61
|
+
**Done when** `check-trace` passes and nothing under `bench/` but
|
|
62
|
+
`traces/*.jsonl` is staged.
|
|
63
|
+
|
|
64
|
+
## 3. Tier 1: synthetic, anywhere
|
|
65
|
+
|
|
66
|
+
CPU burners sized to each call's rule. Cheap and repeatable, and runs on
|
|
67
|
+
macOS:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
muzzle bench run bench/traces/2026-10-21.jsonl --mode none --tier synthetic --window busiest:3h --speed 10 --out /tmp/b-none
|
|
71
|
+
muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier synthetic --window busiest:3h --speed 10 --out /tmp/b-muzzle
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `--mode none` is today's practice: every call starts at its offset with
|
|
75
|
+
the role caps as `CARGO_BUILD_JOBS` (orchestrator 6, worker 4, others 2)
|
|
76
|
+
and no queue.
|
|
77
|
+
- `--mode muzzle` runs every matched call through `muzzle run` with your
|
|
78
|
+
current config and a private state directory under `--out`.
|
|
79
|
+
- `--window busiest:3h` replays the 3 hours with the most matched
|
|
80
|
+
call-seconds. `--speed 10` compresses time tenfold. Use `--speed 1` for
|
|
81
|
+
figures you intend to quote.
|
|
82
|
+
- Run nothing else heavy meanwhile: a replay measures the whole machine.
|
|
83
|
+
|
|
84
|
+
## 4. Tier 2: real commands, linuxpc
|
|
85
|
+
|
|
86
|
+
Copy `bench/real.toml.example` to `bench/real.toml` (gitignored) and fill
|
|
87
|
+
in the repo, pinned commit, worktree directory, setup command and one
|
|
88
|
+
command per rule label. Each replayed agent gets its own worktree, built
|
|
89
|
+
once before the replay so both modes start equally warm.
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
muzzle bench run bench/traces/2026-10-21.jsonl --mode none --tier real --window busiest:3h --out /tmp/r-none
|
|
93
|
+
muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier real --window busiest:3h --out /tmp/r-muzzle
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A real-speed busiest window takes about 3 hours per mode. Run it
|
|
97
|
+
overnight.
|
|
98
|
+
|
|
99
|
+
## 5. Read `summary.md`
|
|
100
|
+
|
|
101
|
+
Each `--out` directory has `summary.md` (and `result.json` with the same
|
|
102
|
+
figures). Put the two modes side by side:
|
|
103
|
+
|
|
104
|
+
- **makespan and throughput**: the cost. A queue can make the whole window
|
|
105
|
+
take longer.
|
|
106
|
+
- **wait p50/p90 and run p50/p90**: where the time went. Under muzzle,
|
|
107
|
+
waiting replaces contention.
|
|
108
|
+
- **most slots held**: never above the budget under muzzle.
|
|
109
|
+
- **git status p95 and 10 ms sleep late p99**: the interactive probe. This
|
|
110
|
+
is what the queue buys.
|
|
111
|
+
- **load1 p50/max and OOM kills.**
|
|
112
|
+
|
|
113
|
+
Report slowdowns as plainly as gains.
|
|
114
|
+
|
|
115
|
+
## The probe on its own
|
|
116
|
+
|
|
117
|
+
`muzzle bench probe` samples the same interactive figures without a
|
|
118
|
+
replay, one JSON line per sample. For a week of live use, a cron line every
|
|
119
|
+
5 minutes:
|
|
120
|
+
|
|
121
|
+
```crontab
|
|
122
|
+
*/5 * * * * $HOME/.local/bin/muzzle bench probe --out $HOME/.local/state/muzzle/bench/probe.jsonl
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
By hand: `muzzle bench probe --samples 12 --interval 5`.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Hook muzzle into a harness
|
|
2
|
+
|
|
3
|
+
Install, then check, one harness at a time. Every integration calls
|
|
4
|
+
`muzzle wrap` or `muzzle hook`, which do the matching, quoting and timeout
|
|
5
|
+
handling. The full snippets and payload notes are in the muzzle repo's
|
|
6
|
+
[`docs/harnesses/README.md`](https://github.com/mu-crew/muzzle/blob/main/docs/harnesses/README.md).
|
|
7
|
+
|
|
8
|
+
First, for every harness:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm i -g @mu-crew/muzzle
|
|
12
|
+
muzzle doctor
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`doctor` must show the config with its rule count and hash, and
|
|
16
|
+
`enforced` or `advisory`. It also reports each harness as found or not.
|
|
17
|
+
|
|
18
|
+
## Limits
|
|
19
|
+
|
|
20
|
+
| harness | how | timeout | `allow` | tested |
|
|
21
|
+
| --- | --- | --- | --- | --- |
|
|
22
|
+
| pi | extension rewrites the bash call | moved to `--run-timeout`: the wait never trips it | n/a: pi has no permission prompt | yes |
|
|
23
|
+
| Claude Code | `PreToolUse` hook, `muzzle hook claude` | passed on as `--run-timeout`, but Claude's own Bash timeout still counts the wait | only for one simple command whose rule matches at the command word | yes, stored payloads |
|
|
24
|
+
| Codex | `PreToolUse` hook, `muzzle hook codex` | the payload has none; Codex's shell timeout counts the wait | always, compound commands too | yes, stored payloads |
|
|
25
|
+
| opencode | example plugin calling `muzzle wrap` | passed on, still counts the wait | n/a | **no** |
|
|
26
|
+
| Cursor, aider, cron, humans | wrap by hand | use `--run-timeout` | n/a | n/a |
|
|
27
|
+
|
|
28
|
+
Cursor's `beforeShellExecution` hook can only allow or deny, so it can't
|
|
29
|
+
rewrite a call.
|
|
30
|
+
|
|
31
|
+
## pi
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pi install npm:@mu-crew/muzzle
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This loads the extension and this skill. Check: a matched bash call's
|
|
38
|
+
result ends with `muzzle: waited Ws for N slots, ran Rs`, and the call
|
|
39
|
+
shows in `muzzle status` while it runs. If muzzle is missing, the footer
|
|
40
|
+
says so once and calls run unwrapped. Esc or a pi timeout kills the bash
|
|
41
|
+
group, muzzle and the job with it, and frees the slots.
|
|
42
|
+
|
|
43
|
+
## Claude Code
|
|
44
|
+
|
|
45
|
+
Merge into `~/.claude/settings.json` (or a repo's `.claude/settings.json`):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"hooks": {
|
|
50
|
+
"PreToolUse": [
|
|
51
|
+
{
|
|
52
|
+
"matcher": "Bash",
|
|
53
|
+
"hooks": [{ "type": "command", "command": "muzzle hook claude" }]
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then raise the Bash timeout ceiling, because Claude's timeout counts the
|
|
61
|
+
queue wait: set `BASH_MAX_TIMEOUT_MS` (and `BASH_DEFAULT_TIMEOUT_MS`) in
|
|
62
|
+
the `env` block of `settings.json`. A call killed while queued prints
|
|
63
|
+
muzzle's `killed while queued` line.
|
|
64
|
+
|
|
65
|
+
## Codex
|
|
66
|
+
|
|
67
|
+
Merge into `~/.codex/hooks.json` (or a trusted repo's `.codex/hooks.json`):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"hooks": {
|
|
72
|
+
"PreToolUse": [
|
|
73
|
+
{
|
|
74
|
+
"matcher": "^Bash$",
|
|
75
|
+
"hooks": [{ "type": "command", "command": "muzzle hook codex" }]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Codex asks you to trust a new hook; review it with `/hooks`. Read the
|
|
83
|
+
warning below before enabling it.
|
|
84
|
+
|
|
85
|
+
## Warning: `allow` skips the harness's permission prompt
|
|
86
|
+
|
|
87
|
+
A hook reply with `permissionDecision: "allow"` approves the call, so the
|
|
88
|
+
harness doesn't ask you. muzzle's rules match anywhere in the string, so
|
|
89
|
+
`cargo build && curl -s https://example.com/x | sh` matches a cargo rule.
|
|
90
|
+
|
|
91
|
+
- **Codex always gets `allow`**, because it rejects a rewrite without one.
|
|
92
|
+
A matched call is approved by muzzle, compound commands included. Use
|
|
93
|
+
the Codex hook only where you would approve those calls anyway, or keep
|
|
94
|
+
the rules narrow.
|
|
95
|
+
- **Claude Code gets `allow` only when the rule names the program**: one
|
|
96
|
+
simple command (no `;`, `&`, `&&`, `|`, `||`, newline, `$(…)`, backticks,
|
|
97
|
+
process substitution or subshell outside quotes) whose matched pattern
|
|
98
|
+
starts at the command word, after assignments and `env`, `time`,
|
|
99
|
+
`timeout [opts] N` or `nice`. A match in an argument or a comment
|
|
100
|
+
(`echo cargo test`, `rm -rf ~/x # cargo test`) doesn't count. Anything
|
|
101
|
+
else is rewritten with no decision, so
|
|
102
|
+
Claude's own check runs on `muzzle run … -- bash -c '…'`, and since your
|
|
103
|
+
`Bash(cargo build:*)` rules don't match that form, it prompts.
|
|
104
|
+
- **Keep `Bash(muzzle run:*)` out of Claude's allow rules.** It would
|
|
105
|
+
approve every wrapped string, including the compound ones muzzle left
|
|
106
|
+
for you to review.
|
|
107
|
+
|
|
108
|
+
## opencode (untested)
|
|
109
|
+
|
|
110
|
+
Copy `examples/opencode-muzzle.ts` from the muzzle repo to
|
|
111
|
+
`.opencode/plugins/` or `~/.config/opencode/plugins/`. No test runs it.
|
|
112
|
+
Check that a matched command shows in `muzzle status` while it runs: some
|
|
113
|
+
opencode versions ignore the rewritten command.
|
|
114
|
+
|
|
115
|
+
## Hand-wrapping and gate scripts
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
muzzle run --rule gate -- contrib/fast-gate
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`timeout` goes inside, as `--run-timeout S`; an outer `timeout` counts the
|
|
122
|
+
wait. A repo's gate script can opt in at the top and still work without
|
|
123
|
+
muzzle. The variable stops the re-exec from looping:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
if [ -z "$IN_MUZZLE" ] && command -v muzzle >/dev/null; then
|
|
127
|
+
IN_MUZZLE=1 exec muzzle run --rule gate -- "$0" "$@"
|
|
128
|
+
fi
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## PATH shims, do it yourself
|
|
132
|
+
|
|
133
|
+
muzzle ships none. They add work to every call, nest badly and catch
|
|
134
|
+
interactive use. If you want every `cargo` to go through muzzle anyway, a
|
|
135
|
+
script named `cargo` earlier on `PATH` that runs
|
|
136
|
+
`exec muzzle run --rule cargo-test -- /usr/bin/cargo "$@"` works. It claims
|
|
137
|
+
slots for `cargo fmt` and `cargo --version` too.
|
|
138
|
+
|
|
139
|
+
## Checking a hook by hand
|
|
140
|
+
|
|
141
|
+
`muzzle doctor` reports `claude hook` and `codex hook` as `installed` when
|
|
142
|
+
it finds `muzzle hook` in that harness's config. To see a reply:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cargo test"}}' | muzzle hook claude
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
A matched command comes back as `updatedInput` with the `muzzle run …`
|
|
149
|
+
form; an unmatched one comes back as `{}`.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Tune muzzle's weights
|
|
2
|
+
|
|
3
|
+
The loop that turns `runs.jsonl` into weights. muzzle never edits its
|
|
4
|
+
config. You do, one rule at a time, and you keep a change only when the
|
|
5
|
+
numbers agree. Every run records the config hash, rule and slots it ran
|
|
6
|
+
under, so `report --compare` can split by them later.
|
|
7
|
+
|
|
8
|
+
Run on the host whose config you are changing. `runs.jsonl` is local machine
|
|
9
|
+
state: read it here, and never publish it.
|
|
10
|
+
|
|
11
|
+
## 1. Read the report, machine figures first
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
muzzle report --since 7d
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Read the machine section before the rows:
|
|
18
|
+
|
|
19
|
+
| figure | high means |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| blocked-idle (slot-s) | slots sat free while someone waited: the head of the queue is too big to fit, so everything behind it waits (strict FIFO). Shrink the over-sized rule |
|
|
22
|
+
| CPU efficiency (low) | claims are bigger than the jobs use: weights too big |
|
|
23
|
+
| 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
|
+
| queue non-empty | how often anyone waited at all. Near 0% means the weights cost nothing yet |
|
|
25
|
+
|
|
26
|
+
Then the rows. `suggest` names its evidence and carries a tag:
|
|
27
|
+
|
|
28
|
+
- `N measured (p90 cores X over K clean runs)`: from at least 5 clean runs
|
|
29
|
+
(exit 0, not killed). Memory can only raise it.
|
|
30
|
+
- `try N experiment (saturated at M)`: cores p50 is at the weight, so the
|
|
31
|
+
job may want more. Untested until step 5 confirms it.
|
|
32
|
+
- `N oom (…)`: the scope killed runs at the memory cap. This covers their
|
|
33
|
+
peak plus 25%.
|
|
34
|
+
- `insufficient data (K runs)`: fewer than 5 clean runs. Wait for more.
|
|
35
|
+
- `not measured (container)`: those runs entered a container, so muzzle
|
|
36
|
+
saw no CPU or memory. No suggestion can come from them.
|
|
37
|
+
|
|
38
|
+
Narrow to one rule with `--rule NAME`, or to a period with `--since D`
|
|
39
|
+
and `--until D` (`now`, `7d`, `12h`, `30m`, `YYYY-MM-DD`, or RFC 3339).
|
|
40
|
+
|
|
41
|
+
**Done when** you have written down, for the rule you will change, its
|
|
42
|
+
current slots, run p50/p90, cores p50/p90 and the suggestion.
|
|
43
|
+
|
|
44
|
+
## 2. Find heavy commands no rule matches
|
|
45
|
+
|
|
46
|
+
`report` can't see these: an unmatched command never reaches muzzle. Look
|
|
47
|
+
in museum:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
museum-search calls --min-secs 30 --tool bash
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Check a candidate against the live config with
|
|
54
|
+
`muzzle match -- 'contrib/gate --full'`. It prints the rule, or `no rule
|
|
55
|
+
matches`.
|
|
56
|
+
|
|
57
|
+
## 3. Edit the config
|
|
58
|
+
|
|
59
|
+
`~/.config/muzzle/config.toml`. Change one rule per round, so the next
|
|
60
|
+
report shows what that change did.
|
|
61
|
+
|
|
62
|
+
- **Weights**: set `slots` to the suggestion. For an `experiment`, that
|
|
63
|
+
is the `try N` value, and step 5 decides whether it stays.
|
|
64
|
+
- **New rules**: give each a `name`. The name is the label in reports and
|
|
65
|
+
history, so keep it when the pattern changes.
|
|
66
|
+
- **False positives**: a pattern that matches anywhere in the string also
|
|
67
|
+
claims slots for commands that only *mention* the gate: a heredoc, a
|
|
68
|
+
`grep`, a `pgrep -f` poll, a `cat` of the script. A poll that claims
|
|
69
|
+
holds the gate's slots a second time while the real gate runs. Anchor the
|
|
70
|
+
command word in command position: start of the string, after `;`, `&`,
|
|
71
|
+
`|`, `(` or a newline, or just inside a `-c '…'` quote.
|
|
72
|
+
`bench/linuxpc-config.toml` in the muzzle repo has worked patterns.
|
|
73
|
+
Test both sides of the anchor:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
muzzle match -- 'cd repo && contrib/fast-gate'
|
|
77
|
+
muzzle match -- 'pgrep -f contrib/fast-gate'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Check the edit loads, and note the new hash:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
muzzle doctor
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A bad config exits 2 and names the key.
|
|
87
|
+
|
|
88
|
+
**Hand-wrapped calls carry their own weight.** A `--slots` on
|
|
89
|
+
`muzzle run` overrides the config, so editing the config changes nothing for briefs,
|
|
90
|
+
scripts or cron lines that pass `--slots`. Update those too, or change them
|
|
91
|
+
to `--rule NAME` alone, which reads the weight from the config. The pi
|
|
92
|
+
extension and the harness hooks read the config on every call. Renaming a
|
|
93
|
+
rule breaks every `--rule OLD`: those calls warn `rule OLD is not in the
|
|
94
|
+
config; claiming 1 slot(s)` and under-claim until they are updated.
|
|
95
|
+
|
|
96
|
+
## 4. Check it on a replay
|
|
97
|
+
|
|
98
|
+
Before living with the change, replay a recent trace at tier 1 against the
|
|
99
|
+
old and the new config. The trace's busiest window is where weights matter.
|
|
100
|
+
See [bench.md](bench.md) for making a trace.
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
muzzle bench run bench/traces/2026-10-21.jsonl --mode muzzle --tier synthetic --window busiest:3h --speed 10 --out /tmp/tune-after
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Run the same command with the old config in place and `--out
|
|
107
|
+
/tmp/tune-before`, then compare the two `summary.md` files: makespan, wait
|
|
108
|
+
p90, most slots held and the probe. A tier-1 replay burns CPU for each
|
|
109
|
+
rule's slots, so it shows queueing and contention, not whether the real
|
|
110
|
+
job wants more cores. Step 5 settles that.
|
|
111
|
+
|
|
112
|
+
## 5. Keep it, then compare live windows
|
|
113
|
+
|
|
114
|
+
After a few days, or at least 5 clean runs at the new weight:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
muzzle report --compare 2026-11-01..2026-11-03 2026-11-04..now --rule cargo-test
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The output shows both periods, split by config hash when it changed inside
|
|
121
|
+
one, then an `A -> C` table of deltas. An experiment reads `confirmed` when
|
|
122
|
+
run p50 fell, `rejected` when it didn't.
|
|
123
|
+
|
|
124
|
+
Rules for reading it (from tuning muzzle's own gate, 2026-10-29):
|
|
125
|
+
|
|
126
|
+
- **Compare windows only after the last change to the gated code.** A suite
|
|
127
|
+
that grew between the windows moves run time on its own. In that dogfood
|
|
128
|
+
run, run p90 went from 34 s to 58 s while the weight went down, because
|
|
129
|
+
the suite had grown.
|
|
130
|
+
- **Prefer cores over wall time.** Cores used (`cpu_s / ran_s`) says what
|
|
131
|
+
the job can use. Wall time also measures the code and the machine.
|
|
132
|
+
- **Change a weight only when two windows agree.** Both must come after the
|
|
133
|
+
last suite change. One window's suggestion moves with the code. The same
|
|
134
|
+
rule suggested 2 over one hour and 4 over the next, so a weight tuned
|
|
135
|
+
from one window oscillates.
|
|
136
|
+
|
|
137
|
+
Revert when the change made p90 or the wait worse and the code didn't move.
|
|
138
|
+
|
|
139
|
+
## 6. Say why, next to the rule
|
|
140
|
+
|
|
141
|
+
Add one comment line above or below the edited rule: the evidence and the
|
|
142
|
+
date.
|
|
143
|
+
|
|
144
|
+
```toml
|
|
145
|
+
[[rule]]
|
|
146
|
+
name = "cargo-test"
|
|
147
|
+
match = '…'
|
|
148
|
+
slots = 3
|
|
149
|
+
# why: 4 -> 3, 2026-10-29: p90 cores 2.2 over 10 clean runs; run p50 22 -> 21 s at 3.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Done when** the rule's comment names the before and after figures, and
|
|
153
|
+
every hand-wrapped `--slots` for that rule matches the config or is gone.
|
package/README.md
DELETED