@bluewombat/slots 0.7.0 → 0.8.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/README.md +31 -5
- package/agents/cursor.mjs +109 -13
- package/assembly/fix.mjs +14 -0
- package/builders/producer.mjs +14 -0
- package/builders/repair.mjs +14 -0
- package/gates/ci-green.mjs +60 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -95,7 +95,13 @@ or `--report`. A role slot after `--` is what names one:
|
|
|
95
95
|
`cursor.mjs` needs `cursor-agent` on PATH. Without `--agent-home` it uses the
|
|
96
96
|
CLI's own login (`cursor-agent login`). With `--agent-home DIR` and
|
|
97
97
|
`--api-key-env NAME` the vendor process gets that directory as `HOME` and
|
|
98
|
-
`CURSOR_API_KEY` from `NAME`; the value never appears on the argv.
|
|
98
|
+
`CURSOR_API_KEY` from `NAME`; the value never appears on the argv. It also gets
|
|
99
|
+
a short environment (`PATH`, locale, proxy and certificate variables and a few
|
|
100
|
+
others, plus the names given with `--pass-env`), not the operator's whole
|
|
101
|
+
shell; git's credential helpers are cleared for it; and `ssh`, `scp` plus the
|
|
102
|
+
commands given with `--deny-shell` are denied in its own `cli-config.json`,
|
|
103
|
+
rewritten every run. That
|
|
104
|
+
lowers a risk and confines nothing (RESERVATIONS F32). `claude.mjs`
|
|
99
105
|
needs `claude`, signed in once, and refuses those two flags: Claude is not
|
|
100
106
|
isolated yet, and starting it would keep the operator's own config.
|
|
101
107
|
|
|
@@ -112,6 +118,8 @@ isolated yet, and starting it would keep the operator's own config.
|
|
|
112
118
|
| `--agent-arg VALUE` | Appended to the CLI argv, before the prompt. Repeatable |
|
|
113
119
|
| `--agent-home DIR` | Isolate the vendor process: its `HOME` is this directory. Cursor only; Claude refuses |
|
|
114
120
|
| `--api-key-env NAME` | With `--agent-home`, read `process.env[NAME]` for the vendor key. Never the value itself |
|
|
121
|
+
| `--pass-env NAME` | With `--agent-home`, also hand the vendor this environment variable. Repeatable |
|
|
122
|
+
| `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
|
|
115
123
|
| `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
|
|
116
124
|
| `--profile NAME` | The project's deck profile for this role, read in the working directory. Cursor only; Claude refuses |
|
|
117
125
|
| `--plugins-out DIR` | Where `--profile` is built, one folder per profile. Required with `--profile`; never the workspace |
|
|
@@ -239,14 +247,32 @@ assembly, so `assembly/fix` does not fill it at all.
|
|
|
239
247
|
|
|
240
248
|
| Role | Required | Also fills |
|
|
241
249
|
| ------------------- | ------------------------------------- | ---------------------------------------------------------- |
|
|
242
|
-
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}`
|
|
243
|
-
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
244
|
-
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{id}}`, `{{attempt}}`
|
|
250
|
+
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{history}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
251
|
+
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
252
|
+
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{history}}`, `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
|
|
245
253
|
| `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
|
|
246
254
|
|
|
247
255
|
A first-pass command that receives `--report` is `fail-blocking`: that report
|
|
248
256
|
belongs to the repair command. A repair or fix command without `--report` is the same.
|
|
249
257
|
|
|
258
|
+
`{{history}}` is what the Task went through before this Attempt, oldest first (the
|
|
259
|
+
refusal it came with, each Attempt, each Gate's answer; see Implementer's SPECS §
|
|
260
|
+
History). It is empty on a first Attempt and vanishes whole then, so a template
|
|
261
|
+
that does not place it works as before.
|
|
262
|
+
|
|
263
|
+
### The way out
|
|
264
|
+
|
|
265
|
+
Every shipped producer prompt ends with the same exit: when what the agent was
|
|
266
|
+
given is not enough to act on, it changes nothing and ends its final message with
|
|
267
|
+
one line, `MASON_BLOCKED: <what is missing>`. The slot reads the last such line
|
|
268
|
+
in the agent's final message, and answers `fail-blocking` with that reason, so the
|
|
269
|
+
Task stops as `escalated` and a person reads it instead of another Attempt
|
|
270
|
+
inventing a change. This is the same kind of signal as `assembly/validate`'s
|
|
271
|
+
`MASON_VERDICT:` line, and it is not tied to any refuser. A clean exit prints
|
|
272
|
+
`{ "said": "<the agent's final message, clipped>" }` on stdout, which the Task
|
|
273
|
+
keeps for its History. A Project's own template should keep a paragraph that
|
|
274
|
+
tells the agent about the line, or the agent has no way to use it.
|
|
275
|
+
|
|
250
276
|
### The repair prompt
|
|
251
277
|
|
|
252
278
|
A pass a Gate refused is a different job from a first pass: not "do this" but
|
|
@@ -420,7 +446,7 @@ the work in front of the checks did that before the Gate ran; a workspace
|
|
|
420
446
|
with nothing published is `fail-blocking`, because no Attempt of that Task
|
|
421
447
|
can change it.
|
|
422
448
|
|
|
423
|
-
It waits while checks are running, so give it a ceiling of its own. On a red
|
|
449
|
+
It waits while checks are running, so give it a ceiling of its own (the Gate's `timeoutMs`, which Implementer passes to it as `--timeout-ms`). A CI that does not answer is not a refusal of the work: a Gate killed by its clock reads as one, and a refusal sends the work to a producer with nothing to fix. So a few seconds before the ceiling, with no check yet or checks still running, `ci-green` answers `fail-blocking` itself, saying what it was waiting for and for how long, and a person reads it. A check that failed still refuses at once, with its reason. On a red
|
|
424
450
|
check the report carries the failing job's log, cut at the error the runner
|
|
425
451
|
marked — a check name and the word "failure" tell the next Attempt nothing it
|
|
426
452
|
can act on. Right after a push the checks may not be created yet, and an empty
|
package/agents/cursor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
AGENT_OPTIONS,
|
|
7
7
|
findExecutable,
|
|
@@ -30,6 +30,12 @@ import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
|
30
30
|
// --agent-home DIR isolate this process: HOME is DIR, not the operator's
|
|
31
31
|
// --api-key-env NAME required with --agent-home. Read process.env[NAME];
|
|
32
32
|
// the value is never put on the argv
|
|
33
|
+
// --pass-env NAME with --agent-home: also hand the vendor this variable
|
|
34
|
+
// of the operator. Repeatable. The vendor otherwise
|
|
35
|
+
// gets a short list (KEPT_ENV), not the whole environment
|
|
36
|
+
// --deny-shell NAME with --agent-home: also refuse this command to the
|
|
37
|
+
// vendor (`gh`, `vercel`…). Repeatable. ssh and scp are
|
|
38
|
+
// always refused
|
|
33
39
|
// --plugin-dir / --profile / --plugins-out / --deck-bin plugin folders,
|
|
34
40
|
// see agent-plugins.mjs
|
|
35
41
|
// --id / --attempt / --context how a transcript is filed
|
|
@@ -38,6 +44,45 @@ const NAME = "Cursor CLI";
|
|
|
38
44
|
const CLI_NAMES = ["cursor-agent", "agent"];
|
|
39
45
|
const INSTALL_DIRS = [join(homedir(), ".local/bin"), join(homedir(), ".cursor/bin")];
|
|
40
46
|
|
|
47
|
+
/**
|
|
48
|
+
* What the vendor gets of the operator's environment, besides what this file
|
|
49
|
+
* sets itself: enough to find tools, name the locale and reach the network.
|
|
50
|
+
* Tokens, agent sockets and the rest of an interactive shell stay with the
|
|
51
|
+
* operator; a Project that needs one more names it with `--pass-env`.
|
|
52
|
+
*/
|
|
53
|
+
const KEPT_ENV = [
|
|
54
|
+
"PATH",
|
|
55
|
+
"TERM",
|
|
56
|
+
"LANG",
|
|
57
|
+
"LC_ALL",
|
|
58
|
+
"LC_CTYPE",
|
|
59
|
+
"TZ",
|
|
60
|
+
"TMPDIR",
|
|
61
|
+
"USER",
|
|
62
|
+
"LOGNAME",
|
|
63
|
+
"SHELL",
|
|
64
|
+
"PNPM_HOME",
|
|
65
|
+
"HTTP_PROXY",
|
|
66
|
+
"HTTPS_PROXY",
|
|
67
|
+
"NO_PROXY",
|
|
68
|
+
"http_proxy",
|
|
69
|
+
"https_proxy",
|
|
70
|
+
"no_proxy",
|
|
71
|
+
"SSL_CERT_FILE",
|
|
72
|
+
"SSL_CERT_DIR",
|
|
73
|
+
"NODE_EXTRA_CA_CERTS",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Commands refused to the vendor whatever the Project says: the ones that
|
|
78
|
+
* reach for the operator's own logins through a socket or a key. A Project adds
|
|
79
|
+
* its own with `--deny-shell` (the GitHub CLI reads a keychain whatever HOME
|
|
80
|
+
* is; `security` is macOS only; each machine has its own). The vendor's
|
|
81
|
+
* permission list refuses a command even under `--force`. It is a list of
|
|
82
|
+
* names, not a sandbox: `python -c` goes round it.
|
|
83
|
+
*/
|
|
84
|
+
const DENIED_SHELL = ["ssh", "scp"];
|
|
85
|
+
|
|
41
86
|
const cwd = process.cwd();
|
|
42
87
|
|
|
43
88
|
const parsed = parseOptions(process.argv.slice(2), AGENT_OPTIONS, NAME);
|
|
@@ -142,42 +187,93 @@ function cursorIsolation(options, dir) {
|
|
|
142
187
|
reason: `The environment variable ${keyEnv} is empty. An isolated Cursor agent needs it, or the CLI tries to store credentials in the login keychain.`,
|
|
143
188
|
};
|
|
144
189
|
}
|
|
145
|
-
|
|
190
|
+
const passEnv = listOption(options, "passEnv");
|
|
191
|
+
const bad = passEnv.find((name) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name));
|
|
192
|
+
if (bad !== undefined) {
|
|
193
|
+
return { ok: false, reason: `--pass-env must name an environment variable, not "${bad}".` };
|
|
194
|
+
}
|
|
195
|
+
const denyShell = listOption(options, "denyShell");
|
|
196
|
+
const badCommand = denyShell.find((name) => !/^[A-Za-z0-9._+-]+$/.test(name));
|
|
197
|
+
if (badCommand !== undefined) {
|
|
198
|
+
return {
|
|
199
|
+
ok: false,
|
|
200
|
+
reason: `--deny-shell must name a command, not "${badCommand}".`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const agentHome = resolve(dir, home);
|
|
204
|
+
const env = isolatedVendorEnv(agentHome, apiKey, passEnv);
|
|
205
|
+
denyShellCommands(agentHome, denyShell);
|
|
206
|
+
return { ok: true, env };
|
|
146
207
|
}
|
|
147
208
|
|
|
148
209
|
/**
|
|
149
210
|
* @param {string} agentHome
|
|
150
211
|
* @param {string} apiKey
|
|
212
|
+
* @param {readonly string[]} passEnv
|
|
151
213
|
* @returns {NodeJS.ProcessEnv}
|
|
152
214
|
*/
|
|
153
|
-
function isolatedVendorEnv(agentHome, apiKey) {
|
|
215
|
+
function isolatedVendorEnv(agentHome, apiKey, passEnv) {
|
|
154
216
|
mkdirSync(agentHome, { recursive: true });
|
|
155
217
|
const parentHome = homedir();
|
|
156
218
|
/** @type {NodeJS.ProcessEnv} */
|
|
157
|
-
const env = {
|
|
158
|
-
|
|
159
|
-
|
|
219
|
+
const env = { HOME: agentHome };
|
|
220
|
+
for (const name of [...KEPT_ENV, ...passEnv]) {
|
|
221
|
+
if (process.env[name] !== undefined) {
|
|
222
|
+
env[name] = process.env[name];
|
|
223
|
+
}
|
|
160
224
|
}
|
|
161
225
|
env.CURSOR_API_KEY = apiKey;
|
|
162
226
|
env.AGENT_CLI_CREDENTIAL_STORE = "memory";
|
|
227
|
+
// The operator's git config may name a credential helper (a keychain with
|
|
228
|
+
// their account). An empty helper first clears that list for the vendor's
|
|
229
|
+
// git, and a missing credential fails instead of prompting.
|
|
230
|
+
env.GIT_CONFIG_COUNT = "1";
|
|
231
|
+
env.GIT_CONFIG_KEY_0 = "credential.helper";
|
|
232
|
+
env.GIT_CONFIG_VALUE_0 = "";
|
|
233
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
163
234
|
const gitConfig = parentGitConfig(parentHome);
|
|
164
|
-
if (gitConfig
|
|
165
|
-
delete env.GIT_CONFIG_GLOBAL;
|
|
166
|
-
} else {
|
|
235
|
+
if (gitConfig !== undefined) {
|
|
167
236
|
env.GIT_CONFIG_GLOBAL = gitConfig;
|
|
168
237
|
}
|
|
169
238
|
const npmCache = process.env.npm_config_cache;
|
|
170
239
|
env.npm_config_cache =
|
|
171
240
|
npmCache !== undefined && npmCache.length > 0 ? npmCache : join(parentHome, ".npm");
|
|
172
241
|
const corepack = parentCorepack(parentHome);
|
|
173
|
-
if (corepack
|
|
174
|
-
delete env.COREPACK_HOME;
|
|
175
|
-
} else {
|
|
242
|
+
if (corepack !== undefined) {
|
|
176
243
|
env.COREPACK_HOME = corepack;
|
|
177
244
|
}
|
|
178
245
|
return env;
|
|
179
246
|
}
|
|
180
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Write the denials into the vendor's own config in `agentHome`, keeping what
|
|
250
|
+
* else is in it. Every pass, because the vendor can edit that file.
|
|
251
|
+
*
|
|
252
|
+
* @param {string} agentHome
|
|
253
|
+
* @param {readonly string[]} extra commands the Project also refuses
|
|
254
|
+
*/
|
|
255
|
+
function denyShellCommands(agentHome, extra) {
|
|
256
|
+
const file = join(agentHome, ".cursor", "cli-config.json");
|
|
257
|
+
/** @type {Record<string, any>} */
|
|
258
|
+
let config = {};
|
|
259
|
+
try {
|
|
260
|
+
config = JSON.parse(readFileSync(file, "utf8"));
|
|
261
|
+
} catch {
|
|
262
|
+
// none yet, or unreadable: start from nothing rather than refuse to run
|
|
263
|
+
}
|
|
264
|
+
const permissions = config.permissions ?? {};
|
|
265
|
+
const deny = Array.isArray(permissions.deny) ? permissions.deny : [];
|
|
266
|
+
config.permissions = {
|
|
267
|
+
...permissions,
|
|
268
|
+
allow: Array.isArray(permissions.allow) ? permissions.allow : [],
|
|
269
|
+
deny: [...new Set([...deny, ...[...DENIED_SHELL, ...extra].map((name) => `Shell(${name})`)])],
|
|
270
|
+
};
|
|
271
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
272
|
+
const next = `${file}.${process.pid}.tmp`;
|
|
273
|
+
writeFileSync(next, `${JSON.stringify(config, null, 2)}\n`);
|
|
274
|
+
renameSync(next, file);
|
|
275
|
+
}
|
|
276
|
+
|
|
181
277
|
/**
|
|
182
278
|
* The global git file the parent already resolves. `~/.gitconfig` wins over
|
|
183
279
|
* the XDG file, which is git's own order when both exist.
|
package/assembly/fix.mjs
CHANGED
|
@@ -40,6 +40,19 @@ This was asked in the first place, and still holds:
|
|
|
40
40
|
|
|
41
41
|
{{task}}
|
|
42
42
|
|
|
43
|
+
{{history}}
|
|
44
|
+
|
|
45
|
+
## When you cannot act
|
|
46
|
+
|
|
47
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
48
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
49
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
50
|
+
end your final message with exactly one line:
|
|
51
|
+
|
|
52
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
53
|
+
|
|
54
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
55
|
+
|
|
43
56
|
## Rules of this run
|
|
44
57
|
|
|
45
58
|
{{rules}}
|
|
@@ -79,6 +92,7 @@ const rendered = fillPrompt(
|
|
|
79
92
|
template.value,
|
|
80
93
|
{
|
|
81
94
|
task: intention,
|
|
95
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
82
96
|
report,
|
|
83
97
|
refused_by:
|
|
84
98
|
refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
|
package/builders/producer.mjs
CHANGED
|
@@ -28,6 +28,19 @@ This checkout already includes any earlier subtasks that were folded into the
|
|
|
28
28
|
feature. Do not rewrite that work. Add or change only what this task names, and
|
|
29
29
|
leave the project's own checks green on the whole tree.
|
|
30
30
|
|
|
31
|
+
{{history}}
|
|
32
|
+
|
|
33
|
+
## When you cannot act
|
|
34
|
+
|
|
35
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
36
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
37
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
38
|
+
end your final message with exactly one line:
|
|
39
|
+
|
|
40
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
41
|
+
|
|
42
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
43
|
+
|
|
31
44
|
Rules of this run:
|
|
32
45
|
{{rules}}
|
|
33
46
|
`;
|
|
@@ -68,6 +81,7 @@ const rendered = fillPrompt(
|
|
|
68
81
|
template.value,
|
|
69
82
|
{
|
|
70
83
|
task: intention,
|
|
84
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
71
85
|
done_when: doneWhen(take(process.argv, "--definition-of-done")),
|
|
72
86
|
rules: rules.value.trim(),
|
|
73
87
|
id: take(process.argv, "--id") ?? "",
|
package/builders/repair.mjs
CHANGED
|
@@ -40,6 +40,19 @@ This was asked in the first place, and still holds:
|
|
|
40
40
|
|
|
41
41
|
{{done_when}}
|
|
42
42
|
|
|
43
|
+
{{history}}
|
|
44
|
+
|
|
45
|
+
## When you cannot act
|
|
46
|
+
|
|
47
|
+
If what you were given is not enough to act on (the refusal names nothing you
|
|
48
|
+
can read or run here, the request contradicts the rules of this run, or the
|
|
49
|
+
cause is outside this directory), do not invent a change. Change nothing and
|
|
50
|
+
end your final message with exactly one line:
|
|
51
|
+
|
|
52
|
+
MASON_BLOCKED: <what is missing, in one line>
|
|
53
|
+
|
|
54
|
+
A person reads that line and decides. It is a correct answer, not a failure.
|
|
55
|
+
|
|
43
56
|
## Rules of this run
|
|
44
57
|
|
|
45
58
|
{{rules}}
|
|
@@ -79,6 +92,7 @@ const rendered = fillPrompt(
|
|
|
79
92
|
template.value,
|
|
80
93
|
{
|
|
81
94
|
task: intention,
|
|
95
|
+
history: (take(process.argv, "--history") ?? "").trim(),
|
|
82
96
|
report,
|
|
83
97
|
refused_by:
|
|
84
98
|
refusedBy.length > 0 ? refusedBy : "the producer of the last attempt, which did not finish",
|
package/gates/ci-green.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
3
|
+
import { emitVerdict, GATE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
4
4
|
|
|
5
5
|
// The work line's own checks, on the work this workspace published: GitHub
|
|
6
6
|
// Actions' check runs, and the commit statuses CI outside Actions reports
|
|
@@ -20,6 +20,12 @@ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
|
20
20
|
// to it failed only at the first assembly of a real run
|
|
21
21
|
// --require-checks wait for a check to appear; without it, a commit with no
|
|
22
22
|
// check at all passes
|
|
23
|
+
//
|
|
24
|
+
// It also receives `--timeout-ms` from Implementer: the ceiling it will be
|
|
25
|
+
// killed at. A Gate that waits on a CI it does not run must not wait to be cut
|
|
26
|
+
// off: a kill reads as "refused", and a refusal sends the work to an agent that
|
|
27
|
+
// has nothing to fix. Near that ceiling it says so itself, `fail-blocking`, with
|
|
28
|
+
// what it was waiting for.
|
|
23
29
|
|
|
24
30
|
// What the runner writes at the top of every step it runs.
|
|
25
31
|
const STEP_OPEN = "##[group]Run ";
|
|
@@ -63,8 +69,10 @@ if (pull === undefined) {
|
|
|
63
69
|
process.exit(0);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
// Ask again until the answer settles
|
|
67
|
-
//
|
|
72
|
+
// Ask again until the answer settles, or until just before Implementer would
|
|
73
|
+
// kill this Gate (`--timeout-ms`); without that flag, until it does.
|
|
74
|
+
const startedAt = Date.now();
|
|
75
|
+
const stopAt = stopTime(startedAt, take(process.argv, "--timeout-ms"));
|
|
68
76
|
for (;;) {
|
|
69
77
|
const checks = await readChecks(pull.sha);
|
|
70
78
|
if (checks === undefined) {
|
|
@@ -77,16 +85,61 @@ for (;;) {
|
|
|
77
85
|
}
|
|
78
86
|
if (checks.total === 0 && options.requireChecks) {
|
|
79
87
|
// Just after a push there is a window where the checks are not created
|
|
80
|
-
// yet, and an empty list then says nothing. Ask again
|
|
81
|
-
//
|
|
82
|
-
|
|
88
|
+
// yet, and an empty list then says nothing. Ask again, until the wait
|
|
89
|
+
// runs out: a commit no check ever picks up is not a refusal of the work.
|
|
90
|
+
if (Date.now() >= stopAt) {
|
|
91
|
+
stopWaiting(pull, "no check has appeared");
|
|
92
|
+
}
|
|
93
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
83
94
|
continue;
|
|
84
95
|
}
|
|
85
96
|
if (checks.running === 0) {
|
|
86
97
|
emitVerdict("pass");
|
|
87
98
|
process.exit(0);
|
|
88
99
|
}
|
|
89
|
-
|
|
100
|
+
if (Date.now() >= stopAt) {
|
|
101
|
+
stopWaiting(
|
|
102
|
+
pull,
|
|
103
|
+
`${checks.running} check${checks.running === 1 ? " was" : "s were"} still running`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
await sleep(Math.min(options.pollMs, Math.max(0, stopAt - Date.now())));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The moment to stop waiting: a margin before Implementer's ceiling (5 %, between
|
|
111
|
+
* 2 and 30 seconds, and at most half of it), so the verdict is out before the
|
|
112
|
+
* kill. `Infinity` when Implementer gave no ceiling.
|
|
113
|
+
*
|
|
114
|
+
* @param {number} from
|
|
115
|
+
* @param {string | undefined} raw
|
|
116
|
+
*/
|
|
117
|
+
function stopTime(from, raw) {
|
|
118
|
+
const ceiling = Number.parseInt(raw ?? "", 10);
|
|
119
|
+
if (!Number.isInteger(ceiling) || ceiling <= 0) {
|
|
120
|
+
return Number.POSITIVE_INFINITY;
|
|
121
|
+
}
|
|
122
|
+
const margin = Math.min(Math.max(2_000, ceiling * 0.05), 30_000, ceiling / 2);
|
|
123
|
+
return from + ceiling - margin;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The CI did not answer. That says nothing of the work, and nothing the work
|
|
128
|
+
* could change: blocking, so a person reads it instead of an agent being sent
|
|
129
|
+
* to repair what was never judged.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ number: number; sha: string }} pr
|
|
132
|
+
* @param {string} what
|
|
133
|
+
* @returns {never}
|
|
134
|
+
*/
|
|
135
|
+
function stopWaiting(pr, what) {
|
|
136
|
+
const seconds = Math.round((Date.now() - startedAt) / 1000);
|
|
137
|
+
const lasted = seconds >= 120 ? `${Math.round(seconds / 60)} min` : `${seconds} s`;
|
|
138
|
+
emitVerdict(
|
|
139
|
+
"fail-blocking",
|
|
140
|
+
`ci-green stopped waiting after ${lasted}: ${what} on ${pr.sha.slice(0, 7)} (pull request #${pr.number}). The CI did not answer in time; this is not a refusal of the work, and no change to it can make the CI answer.`,
|
|
141
|
+
);
|
|
142
|
+
process.exit(0);
|
|
90
143
|
}
|
|
91
144
|
|
|
92
145
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bluewombat/slot-kit": "^0.
|
|
56
|
+
"@bluewombat/slot-kit": "^0.8.0",
|
|
57
57
|
"@pinkpuma/deck": "1.0.0"
|
|
58
58
|
}
|
|
59
59
|
}
|