mtech-ai-local-runner 0.1.72 → 0.1.73
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/dist/src/cli.js
CHANGED
|
@@ -518,5 +518,9 @@ export function isDirectCliInvocation(moduleUrl, argvPath) {
|
|
|
518
518
|
if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
|
|
519
519
|
main(process.argv.slice(2), process.env).then((code) => {
|
|
520
520
|
process.exitCode = code;
|
|
521
|
+
// A daemon that stopped (to apply Fix consent, decision baf0cd89) must leave so launchd starts a fresh one.
|
|
522
|
+
// The unref'd timer only fires if a socket or timer still holds the event loop five seconds later.
|
|
523
|
+
if (process.argv[2] === "run")
|
|
524
|
+
setTimeout(() => process.exit(code), 5000).unref();
|
|
521
525
|
});
|
|
522
526
|
}
|
package/dist/src/commands/fix.js
CHANGED
|
@@ -25,19 +25,108 @@ function flags(argv) {
|
|
|
25
25
|
return values;
|
|
26
26
|
}
|
|
27
27
|
/** A job owns the host until it ends: never replace or restart under it. */
|
|
28
|
-
async function
|
|
28
|
+
export async function fixHostBusy(stateRoot) {
|
|
29
29
|
for (const file of BUSY)
|
|
30
|
-
if (await exists(path.join(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
if (await exists(path.join(stateRoot, file)))
|
|
31
|
+
return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const JOB_ACTIVE = 'FIX_HOST_JOB_ACTIVE: wait until the current job ends, then run this again.';
|
|
35
|
+
async function idle(io) {
|
|
36
|
+
if (await fixHostBusy(io.stateRoot)) {
|
|
37
|
+
io.stderr(JOB_ACTIVE);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
34
40
|
return true;
|
|
35
41
|
}
|
|
36
|
-
async function backup(
|
|
37
|
-
const dir = path.join(
|
|
42
|
+
async function backup(stateRoot, file) {
|
|
43
|
+
const dir = path.join(stateRoot, 'backups');
|
|
38
44
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
39
45
|
await copyFile(file, path.join(dir, `local-fix-host-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
|
|
40
46
|
}
|
|
47
|
+
/** The existing host, when it is a v3 host this runner would load; anything else is replaced whole. */
|
|
48
|
+
async function currentConsentHost(file, runnerId) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
const s = await lstat(file);
|
|
52
|
+
if (!s.isFile() || s.isSymbolicLink() || s.size > 128 * 1024)
|
|
53
|
+
return undefined;
|
|
54
|
+
raw = JSON.parse(await readFile(file, 'utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join() !== 'consent,enabled,git,runnerId,schema')
|
|
60
|
+
return undefined;
|
|
61
|
+
if (raw.schema !== 'local-fix-host-installation-v3' || raw.enabled !== true || raw.runnerId !== runnerId)
|
|
62
|
+
return undefined;
|
|
63
|
+
const git = raw.git;
|
|
64
|
+
if (!git || typeof git !== 'object' || Array.isArray(git) || Object.keys(git).sort().join() !== 'executable,sha256' || typeof git.executable !== 'string' || typeof git.sha256 !== 'string')
|
|
65
|
+
return undefined;
|
|
66
|
+
try {
|
|
67
|
+
assertLocalFixConsent(raw.consent);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
return raw;
|
|
73
|
+
}
|
|
74
|
+
const union = (first, second) => [...first, ...second.filter(value => !first.includes(value))];
|
|
75
|
+
/**
|
|
76
|
+
* The one writer of local-fix-host.json consent, shared by `fix enable` (replace) and the daemon applying a Slack
|
|
77
|
+
* grant (extend; Work Item 6f9e64ae, decision baf0cd89). Refuses while a job or recovery is pending. Extend merges
|
|
78
|
+
* into a v3 host this runner would load and keeps its pinned Git; any other file is backed up and replaced by a
|
|
79
|
+
* fresh v3 host with newly pinned Git, as `fix enable` writes. Writes are owner-only and atomic; a replaced file is
|
|
80
|
+
* kept under backups/. Restarting the runner is the caller's job.
|
|
81
|
+
*/
|
|
82
|
+
export async function applyFixConsent(input) {
|
|
83
|
+
const requested = { repository_keys: [...input.repositoryKeys], verification_modes: [...input.verificationModes] };
|
|
84
|
+
try {
|
|
85
|
+
assertLocalFixConsent(requested);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { ok: false, code: 'FIX_CONSENT_INVALID' };
|
|
89
|
+
}
|
|
90
|
+
if (await fixHostBusy(input.stateRoot))
|
|
91
|
+
return { ok: false, code: 'FIX_HOST_JOB_ACTIVE' };
|
|
92
|
+
const file = path.join(input.stateRoot, HOST_FILE);
|
|
93
|
+
const existing = input.mode === 'extend' ? await currentConsentHost(file, input.runnerId) : undefined;
|
|
94
|
+
let host;
|
|
95
|
+
if (existing) {
|
|
96
|
+
const consent = { repository_keys: union(existing.consent.repository_keys, requested.repository_keys), verification_modes: union(existing.consent.verification_modes, requested.verification_modes) };
|
|
97
|
+
try {
|
|
98
|
+
assertLocalFixConsent(consent);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { ok: false, code: 'FIX_CONSENT_INVALID' };
|
|
102
|
+
}
|
|
103
|
+
if (consent.repository_keys.length === existing.consent.repository_keys.length && consent.verification_modes.length === existing.consent.verification_modes.length)
|
|
104
|
+
return { ok: true, changed: false, consent: existing.consent };
|
|
105
|
+
host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git: { executable: existing.git.executable, sha256: existing.git.sha256 }, consent };
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const executable = await realpath(input.git ?? await (input.findGit ?? defaultGit)());
|
|
109
|
+
const git = { executable, sha256: await measureCommandFile(executable) };
|
|
110
|
+
await assertPinnedGit(git);
|
|
111
|
+
host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git, consent: requested };
|
|
112
|
+
}
|
|
113
|
+
const privateRoot = path.join(input.stateRoot, 'local-fix-publication');
|
|
114
|
+
if (!await exists(privateRoot))
|
|
115
|
+
await mkdir(privateRoot, { mode: 0o700 });
|
|
116
|
+
const s = await lstat(privateRoot);
|
|
117
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0)
|
|
118
|
+
return { ok: false, code: 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE', privateRoot };
|
|
119
|
+
const serialized = JSON.stringify(host, null, 1);
|
|
120
|
+
let previous;
|
|
121
|
+
if (await exists(file)) {
|
|
122
|
+
previous = await readFile(file, 'utf8').catch(() => undefined);
|
|
123
|
+
await backup(input.stateRoot, file);
|
|
124
|
+
}
|
|
125
|
+
const temporary = path.join(input.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
|
|
126
|
+
await writeFile(temporary, serialized, { mode: 0o600, flag: 'wx' });
|
|
127
|
+
await rename(temporary, file);
|
|
128
|
+
return { ok: true, changed: previous !== serialized, consent: host.consent };
|
|
129
|
+
}
|
|
41
130
|
async function restart(io, summary) {
|
|
42
131
|
if (await io.serviceLoaded()) {
|
|
43
132
|
await io.restartService();
|
|
@@ -77,7 +166,7 @@ export async function runFixCommand(argv, io) {
|
|
|
77
166
|
io.stdout(JSON.stringify({ ok: true, removed: false }));
|
|
78
167
|
return 0;
|
|
79
168
|
}
|
|
80
|
-
await backup(io, file);
|
|
169
|
+
await backup(io.stateRoot, file);
|
|
81
170
|
await rm(file);
|
|
82
171
|
return restart(io, { ok: true, removed: true });
|
|
83
172
|
}
|
|
@@ -103,24 +192,15 @@ export async function runFixCommand(argv, io) {
|
|
|
103
192
|
io.stderr('MESSENGER_MODE_HAS_NO_EXECUTOR: log in to Claude Code or Codex, then rerun npx mtech-ai-local-runner@latest setup before fix enable.');
|
|
104
193
|
return 1;
|
|
105
194
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const s = await lstat(privateRoot);
|
|
115
|
-
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0) {
|
|
116
|
-
io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + privateRoot + ' must be a 0700 directory owned by this user.');
|
|
195
|
+
const applied = await applyFixConsent({ stateRoot: io.stateRoot, runnerId: local.runnerId, repositoryKeys: consent.repository_keys, verificationModes: consent.verification_modes, mode: 'replace', git: values.get('git'), findGit: io.findGit });
|
|
196
|
+
if (!applied.ok) {
|
|
197
|
+
if (applied.code === 'FIX_HOST_JOB_ACTIVE')
|
|
198
|
+
io.stderr(JOB_ACTIVE);
|
|
199
|
+
else if (applied.code === 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE')
|
|
200
|
+
io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + applied.privateRoot + ' must be a 0700 directory owned by this user.');
|
|
201
|
+
else
|
|
202
|
+
io.stderr('FIX_CONSENT_INVALID: give registered repository keys (lowercase, comma separated, no repeats) and modes gateway_publish_v1 or gateway_ci_v1.');
|
|
117
203
|
return 1;
|
|
118
204
|
}
|
|
119
|
-
|
|
120
|
-
if (await exists(file))
|
|
121
|
-
await backup(io, file);
|
|
122
|
-
const temporary = path.join(io.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
|
|
123
|
-
await writeFile(temporary, JSON.stringify(host, null, 1), { mode: 0o600, flag: 'wx' });
|
|
124
|
-
await rename(temporary, file);
|
|
125
|
-
return restart(io, { ok: true, repository_keys: consent.repository_keys, verification_modes: consent.verification_modes });
|
|
205
|
+
return restart(io, { ok: true, repository_keys: applied.consent.repository_keys, verification_modes: applied.consent.verification_modes });
|
|
126
206
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { applyFixConsent, fixHostBusy } from "../commands/fix.js";
|
|
2
|
+
const covers = (report, grant) => !!report && report.repository_keys.includes(grant.repositoryKey) && report.verification_modes.includes(grant.verificationMode);
|
|
3
|
+
export class FixConsentRuntime {
|
|
4
|
+
deps;
|
|
5
|
+
/** Grants deferred while busy. Each later sync's own list replaces them, so a grant the Gateway dropped is never applied from memory. */
|
|
6
|
+
deferred = [];
|
|
7
|
+
due = false;
|
|
8
|
+
stopping = false;
|
|
9
|
+
logged = new Set();
|
|
10
|
+
queue = Promise.resolve();
|
|
11
|
+
constructor(deps) {
|
|
12
|
+
this.deps = deps;
|
|
13
|
+
}
|
|
14
|
+
/** True once a changed host waits for the restart; new claims are held until then. */
|
|
15
|
+
get restartDue() { return this.due; }
|
|
16
|
+
/** Applies the grants of one sync when idle. The restart waits for afterSync, so the rest of that sync (its session opens) is still handled. */
|
|
17
|
+
handleGrants(grants) {
|
|
18
|
+
return this.serial(async () => { this.deferred = grants.slice(0, 20); await this.apply(); });
|
|
19
|
+
}
|
|
20
|
+
/** After every sync: drops deferred grants the sync no longer lists, and restarts once a changed host is due and the runner is idle. */
|
|
21
|
+
afterSync(result) {
|
|
22
|
+
return this.serial(async () => {
|
|
23
|
+
if (!Array.isArray(result.fixConsentGrants) || result.fixConsentGrants.length === 0)
|
|
24
|
+
this.deferred = [];
|
|
25
|
+
await this.apply();
|
|
26
|
+
await this.restartWhenIdle();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
serial(task) {
|
|
30
|
+
const next = this.queue.then(task, task);
|
|
31
|
+
this.queue = next.catch(() => undefined);
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
34
|
+
async busy() {
|
|
35
|
+
return this.deps.jobRunning() || await fixHostBusy(this.deps.stateRoot);
|
|
36
|
+
}
|
|
37
|
+
once(key, event) {
|
|
38
|
+
if (this.logged.has(key))
|
|
39
|
+
return;
|
|
40
|
+
this.logged.add(key);
|
|
41
|
+
this.deps.log(event);
|
|
42
|
+
}
|
|
43
|
+
async apply() {
|
|
44
|
+
if (this.stopping)
|
|
45
|
+
return;
|
|
46
|
+
const pending = this.deferred.filter(grant => !covers(this.deps.report(), grant));
|
|
47
|
+
if (pending.length > 0 && await this.busy()) {
|
|
48
|
+
this.deferred = pending;
|
|
49
|
+
for (const grant of pending)
|
|
50
|
+
this.once(`deferred:${grant.id}`, { event: "fix_consent_deferred", grant_id: grant.id, repository_key: grant.repositoryKey, reason: "job_active" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.deferred = [];
|
|
54
|
+
const applied = [];
|
|
55
|
+
for (const grant of pending) {
|
|
56
|
+
let result;
|
|
57
|
+
try {
|
|
58
|
+
result = await applyFixConsent({ stateRoot: this.deps.stateRoot, runnerId: this.deps.runnerId,
|
|
59
|
+
repositoryKeys: [grant.repositoryKey], verificationModes: [grant.verificationMode], mode: "extend" });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Git could not be pinned or the file could not be written; the error text may name local paths, so only the code is logged.
|
|
63
|
+
result = { ok: false, code: "FIX_CONSENT_APPLY_FAILED" };
|
|
64
|
+
}
|
|
65
|
+
if (!result.ok && result.code === "FIX_HOST_JOB_ACTIVE") {
|
|
66
|
+
this.deferred.push(grant);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
this.reject(grant, result.code);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (result.changed) {
|
|
74
|
+
this.due = true;
|
|
75
|
+
applied.push(grant);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// Already on disk, yet the host loaded at start does not report it: a restart would load the same file again.
|
|
79
|
+
if (!this.due)
|
|
80
|
+
this.reject(grant, "FIX_HOST_NOT_LOADED");
|
|
81
|
+
}
|
|
82
|
+
if (applied.length > 0) {
|
|
83
|
+
this.deps.log({ event: "fix_consent_applied", grant_ids: applied.map(grant => grant.id),
|
|
84
|
+
repository_keys: [...new Set(applied.map(grant => grant.repositoryKey))], verification_modes: [...new Set(applied.map(grant => grant.verificationMode))] });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async restartWhenIdle() {
|
|
88
|
+
if (this.stopping || !this.due || await fixHostBusy(this.deps.stateRoot) || this.deps.jobRunning())
|
|
89
|
+
return;
|
|
90
|
+
// No await between the last check and the stop: the daemon stops its job runner first, so no claim starts after it.
|
|
91
|
+
this.stopping = true;
|
|
92
|
+
this.deps.log({ event: "runner_restarting", reason: "fix_consent" });
|
|
93
|
+
this.deps.restart();
|
|
94
|
+
}
|
|
95
|
+
reject(grant, code) {
|
|
96
|
+
this.once(`rejected:${grant.id}:${code}`, { event: "fix_consent_rejected", grant_id: grant.id, repository_key: grant.repositoryKey, code });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -34,6 +34,7 @@ import { packagedHookCommandParts } from "../safety/hook-command.js";
|
|
|
34
34
|
import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
|
|
35
35
|
import { AskRuntime, withAsks } from '../asks/runtime.js';
|
|
36
36
|
import { GuideRuntime, withGuide } from '../guide/runtime.js';
|
|
37
|
+
import { FixConsentRuntime } from './fix-consent.js';
|
|
37
38
|
export async function buildDaemon(input) {
|
|
38
39
|
const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
|
|
39
40
|
if (claudeParts !== 0 && claudeParts !== 3)
|
|
@@ -254,6 +255,12 @@ export async function buildDaemon(input) {
|
|
|
254
255
|
});
|
|
255
256
|
const stopping = new AbortController();
|
|
256
257
|
const closureAcks = new PendingClosureAcks();
|
|
258
|
+
// Slack Fix consent (Work Item 6f9e64ae, decision baf0cd89): applied for this runner only while idle; a changed host
|
|
259
|
+
// restarts the daemon through its graceful stop, and launchd (KeepAlive) starts it again with the new host.
|
|
260
|
+
const fixConsent = new FixConsentRuntime({ stateRoot: input.stateRoot, runnerId: input.runnerId,
|
|
261
|
+
report: () => localFixHost?.report, jobRunning: () => poller.running,
|
|
262
|
+
restart: () => { daemon.stop(); },
|
|
263
|
+
log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
|
|
257
264
|
const opener = new SessionOpener({ home: input.env.HOME ?? "" });
|
|
258
265
|
const dispatch = createSyncDispatcher({
|
|
259
266
|
open: request => opener.open(request),
|
|
@@ -263,9 +270,12 @@ export async function buildDaemon(input) {
|
|
|
263
270
|
},
|
|
264
271
|
guide,
|
|
265
272
|
...(claudeProduction ? { claude: claudeProduction } : {}),
|
|
273
|
+
fixConsent,
|
|
266
274
|
asks,
|
|
267
275
|
closureAcks,
|
|
268
|
-
|
|
276
|
+
// While a changed Fix host waits for its restart, the next runner claims the job instead.
|
|
277
|
+
jobs: { notify: () => { if (!fixConsent.restartDue)
|
|
278
|
+
poller.notify(); } },
|
|
269
279
|
delay: abortableDelay,
|
|
270
280
|
signal: stopping.signal,
|
|
271
281
|
reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
|
|
@@ -283,7 +293,12 @@ export async function buildDaemon(input) {
|
|
|
283
293
|
}
|
|
284
294
|
},
|
|
285
295
|
authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
|
|
286
|
-
dispatch
|
|
296
|
+
dispatch: async (result) => { try {
|
|
297
|
+
await dispatch(result);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
await fixConsent.afterSync(result);
|
|
301
|
+
} },
|
|
287
302
|
openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
|
|
288
303
|
report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
|
|
289
304
|
});
|
|
@@ -17,6 +17,28 @@ export class GatewayToolError extends Error {
|
|
|
17
17
|
this.code = code;
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
|
+
const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
|
|
21
|
+
const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
22
|
+
const FIX_CONSENT_GRANTS_MAX = 20;
|
|
23
|
+
/** Keeps the well-formed grants, at most 20; a malformed entry is dropped, never a reason to refuse the sync. */
|
|
24
|
+
function parseFixConsentGrants(value) {
|
|
25
|
+
const grants = [];
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
if (grants.length >= FIX_CONSENT_GRANTS_MAX)
|
|
28
|
+
break;
|
|
29
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
30
|
+
continue;
|
|
31
|
+
const { id, repository_key: repositoryKey, verification_mode: verificationMode } = item;
|
|
32
|
+
if (typeof id !== "string" || !FIX_CONSENT_GRANT_ID.test(id))
|
|
33
|
+
continue;
|
|
34
|
+
if (typeof repositoryKey !== "string" || !FIX_CONSENT_REPOSITORY_KEY.test(repositoryKey))
|
|
35
|
+
continue;
|
|
36
|
+
if (verificationMode !== "gateway_publish_v1" && verificationMode !== "gateway_ci_v1")
|
|
37
|
+
continue;
|
|
38
|
+
grants.push({ id, repositoryKey, verificationMode });
|
|
39
|
+
}
|
|
40
|
+
return grants;
|
|
41
|
+
}
|
|
20
42
|
const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
|
|
21
43
|
const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
|
|
22
44
|
const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
|
|
@@ -38,6 +60,7 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
38
60
|
...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
|
|
39
61
|
...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
|
|
40
62
|
...(body.ask_alert === true ? { askAlert: true } : {}),
|
|
63
|
+
...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
|
|
41
64
|
jobAvailable: body.job_available === true,
|
|
42
65
|
push: parsePush(body.push, runnerId),
|
|
43
66
|
syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
|
|
@@ -102,6 +102,15 @@ export function createSyncDispatcher(deps) {
|
|
|
102
102
|
fail("asks", "ASK_DISPATCH_FAILED", error);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
// Before the job hint: applying a grant may restart an idle runner, which should win over a new claim.
|
|
106
|
+
if (deps.fixConsent && Array.isArray(result.fixConsentGrants) && result.fixConsentGrants.length > 0) {
|
|
107
|
+
try {
|
|
108
|
+
await deps.fixConsent.handleGrants(result.fixConsentGrants);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
fail("fix_consent", "FIX_CONSENT_DISPATCH_FAILED", error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
105
114
|
if (result.jobAvailable)
|
|
106
115
|
deps.jobs?.notify();
|
|
107
116
|
for (const request of result.opens) {
|