@hivemagnus/magnus-agent 1.1.5 → 1.1.7
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 +4 -0
- package/dist/executors/hermes-gateway-status.d.ts +14 -0
- package/dist/executors/hermes-gateway-status.js +67 -0
- package/dist/executors/hermes-manager.d.ts +6 -0
- package/dist/executors/hermes-manager.js +34 -15
- package/dist/executors/scheduler-discovery.d.ts +17 -6
- package/dist/executors/scheduler-discovery.js +37 -13
- package/dist/executors/scheduler-inventory.d.ts +10 -0
- package/dist/executors/scheduler-inventory.js +57 -9
- package/dist/hermes-sync.js +3 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,6 +18,10 @@ integrations is optional and stays inert until you configure it.
|
|
|
18
18
|
- Command execution with timeouts, signed results and a durable command ledger.
|
|
19
19
|
- Cron management, including finite managed schedules (see
|
|
20
20
|
[MANAGED_SCHEDULER.md](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md)).
|
|
21
|
+
Nodes upgraded from before 1.1.6 may keep one stale remembered Hermes schedule
|
|
22
|
+
source per past Hermes run; see
|
|
23
|
+
[Hermes schedule sources remembered before 1.1.6](https://github.com/ChefTaco/magnus/blob/main/packages/agent/MANAGED_SCHEDULER.md#hermes-schedule-sources-remembered-before-116)
|
|
24
|
+
for the cleanup.
|
|
21
25
|
- File read and write with symlink-resolved path checks. Agent state and
|
|
22
26
|
secrets can never be read or written through the API.
|
|
23
27
|
- Skill publication, listing and removal, for example from
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies `hermes gateway status` and systemd-style output. Mirrored
|
|
3
|
+
* byte-for-byte by the server's copy, which also carries the Python port used
|
|
4
|
+
* by SSH detection; a server test pins all three to one table.
|
|
5
|
+
*
|
|
6
|
+
* `systemctl status` output appends a journal tail, so a running service can
|
|
7
|
+
* print "Stopped ..." or "error" from an earlier run. A systemd `Active:` line
|
|
8
|
+
* is therefore authoritative, and Hermes' own "Recent gateway health" history is
|
|
9
|
+
* skipped. Without an `Active:` line, only summary lines count, and on
|
|
10
|
+
* those a negative state outranks a failure, which outranks a positive one.
|
|
11
|
+
*/
|
|
12
|
+
export type HermesGatewayState = 'running' | 'inactive' | 'failed' | 'unknown';
|
|
13
|
+
export declare function parseGatewayStatusText(text: string): HermesGatewayState;
|
|
14
|
+
//# sourceMappingURL=hermes-gateway-status.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const ANSI_COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
2
|
+
const ACTIVE_LINE = /^\s*active:\s*([a-z-]+)(?:\s*\(([^)]*)\))?/;
|
|
3
|
+
const IGNORED_LINE = new RegExp([
|
|
4
|
+
// Journal tail: "Nov 02 10:00:00 host ..." or "2026-11-02 10:00:00 ...".
|
|
5
|
+
String.raw `^\s*(?:[a-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}[t ]\d{2}:\d{2})`,
|
|
6
|
+
// systemctl status fields other than Active, its unit title and its process tree.
|
|
7
|
+
String.raw `^\s*(?:loaded|docs|main pid|tasks|memory|cpu|cgroup|process|drop-in|triggeredby|invocation|status|ip|mem peak)\s*:`,
|
|
8
|
+
String.raw `^\s*[●○×↻✗✓]?\s*\S+\.service\b`,
|
|
9
|
+
String.raw `^\s*[│├└]`,
|
|
10
|
+
// Boot and session settings say nothing about the gateway's current state.
|
|
11
|
+
String.raw `\b(?:linger|auto-?start|on boot|at boot)\b`,
|
|
12
|
+
// systemctl could not ask: that is not evidence about the gateway either.
|
|
13
|
+
String.raw `failed to connect to bus`,
|
|
14
|
+
].join('|'));
|
|
15
|
+
// `hermes gateway status` ends with history under a heading such as "Recent gateway health:".
|
|
16
|
+
const HISTORY_HEADING = /^\s*recent\b.*:\s*$/;
|
|
17
|
+
const HEALTHY_PHRASE = /\b(?:no|0|zero)\s+(?:errors?|failures?|failed)\b|\b(?:last|previous|recent)\s+(?:errors?|failures?)\b.*$/g;
|
|
18
|
+
const NEGATIVE = /\b(?:not[\s_-]+(?:running|active|started|loaded|installed)|isn'?t\s+running|inactive|stopped|disabled|dead|exited|could not be found)\b/;
|
|
19
|
+
const FAILED = /\b(?:failed|failure|crashed|fatal|errors?|errored|unhealthy|not[\s_-]+healthy)\b/;
|
|
20
|
+
const POSITIVE = /\b(?:running|active|healthy)\b/;
|
|
21
|
+
function withoutHistory(lines) {
|
|
22
|
+
let inHistory = false;
|
|
23
|
+
return lines.filter((line) => {
|
|
24
|
+
if (HISTORY_HEADING.test(line))
|
|
25
|
+
inHistory = true;
|
|
26
|
+
else if (!line.trim())
|
|
27
|
+
inHistory = false;
|
|
28
|
+
return !inHistory;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function activeLineState(state, detail) {
|
|
32
|
+
if (state === 'active')
|
|
33
|
+
return detail.startsWith('exited') ? 'inactive' : 'running';
|
|
34
|
+
if (state === 'reloading')
|
|
35
|
+
return 'running';
|
|
36
|
+
if (state === 'inactive' || state === 'deactivating')
|
|
37
|
+
return 'inactive';
|
|
38
|
+
if (state === 'failed')
|
|
39
|
+
return 'failed';
|
|
40
|
+
// A unit waiting to restart after a crash is failing, one starting up is not known yet.
|
|
41
|
+
if (state === 'activating')
|
|
42
|
+
return detail.includes('auto-restart') ? 'failed' : 'unknown';
|
|
43
|
+
return 'unknown';
|
|
44
|
+
}
|
|
45
|
+
export function parseGatewayStatusText(text) {
|
|
46
|
+
const lines = text.replace(ANSI_COLOUR, '').toLowerCase().split(/\r?\n/);
|
|
47
|
+
const activeStates = lines.flatMap((line) => {
|
|
48
|
+
const match = ACTIVE_LINE.exec(line);
|
|
49
|
+
return match ? [activeLineState(match[1] ?? '', match[2] ?? '')] : [];
|
|
50
|
+
});
|
|
51
|
+
if (activeStates.length > 0) {
|
|
52
|
+
// User and system units can both print a status block.
|
|
53
|
+
for (const state of ['running', 'failed', 'inactive'])
|
|
54
|
+
if (activeStates.includes(state))
|
|
55
|
+
return state;
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
const summary = withoutHistory(lines).filter((line) => !IGNORED_LINE.test(line)).map((line) => line.replace(HEALTHY_PHRASE, ' '));
|
|
59
|
+
if (summary.some((line) => NEGATIVE.test(line)))
|
|
60
|
+
return 'inactive';
|
|
61
|
+
if (summary.some((line) => FAILED.test(line)))
|
|
62
|
+
return 'failed';
|
|
63
|
+
if (summary.some((line) => POSITIVE.test(line)))
|
|
64
|
+
return 'running';
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=hermes-gateway-status.js.map
|
|
@@ -108,6 +108,12 @@ export declare function configureHermes(options: {
|
|
|
108
108
|
warnings: string[];
|
|
109
109
|
state: HermesNodeState;
|
|
110
110
|
}>;
|
|
111
|
+
export declare function hermesInstallationStatus(input: {
|
|
112
|
+
installed: boolean;
|
|
113
|
+
runnable: boolean;
|
|
114
|
+
gatewayStatus: HermesServiceStatus;
|
|
115
|
+
configPresent: boolean;
|
|
116
|
+
}): HermesNodeState['status'];
|
|
111
117
|
export declare function collectHermesState(options?: {
|
|
112
118
|
hermesHome?: string;
|
|
113
119
|
hermesBin?: string;
|
|
@@ -7,6 +7,7 @@ import { Hono } from 'hono';
|
|
|
7
7
|
import { z } from 'zod';
|
|
8
8
|
import { observeHermesDashboard } from './hermes-dashboard-discovery.js';
|
|
9
9
|
import { inspectHiveMemToken } from './hivemem-token.js';
|
|
10
|
+
import { parseGatewayStatusText } from './hermes-gateway-status.js';
|
|
10
11
|
import { childEnvironment } from '../utils/child-env.js';
|
|
11
12
|
import { armChildTimeout } from '../utils/child-timeout.js';
|
|
12
13
|
const OFFICIAL_INSTALL_SCRIPT = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh';
|
|
@@ -417,7 +418,7 @@ function parseProfileListText(text, home) {
|
|
|
417
418
|
name,
|
|
418
419
|
profileHome: join(home, 'profiles', name),
|
|
419
420
|
isDefault: nameCell.startsWith('*') || nameCell.startsWith('◆') || /\bdefault\b/i.test(nameCell),
|
|
420
|
-
gatewayStatus: cells[2]
|
|
421
|
+
gatewayStatus: cells[2] ? parseGatewayStatusText(cells[2]) : 'unknown',
|
|
421
422
|
modelSummary: cells[1] && !['-', '—'].includes(cells[1]) ? cells[1] : null,
|
|
422
423
|
distribution: cells[4] && !['-', '—'].includes(cells[4]) ? cells[4] : null,
|
|
423
424
|
metadata: { source: 'hermes profile list' },
|
|
@@ -425,6 +426,14 @@ function parseProfileListText(text, home) {
|
|
|
425
426
|
}
|
|
426
427
|
return profiles;
|
|
427
428
|
}
|
|
429
|
+
function profileGatewayStatus(profile, installationStatus) {
|
|
430
|
+
const reported = profileString(profile, 'gatewayStatus', 'gateway_status');
|
|
431
|
+
const state = reported ? parseGatewayStatusText(reported) : 'unknown';
|
|
432
|
+
if (state !== 'unknown')
|
|
433
|
+
return state;
|
|
434
|
+
return profile['isDefault'] === true || profile['is_default'] === true || profile['default'] === true || profile['name'] === 'default'
|
|
435
|
+
? installationStatus : 'unknown';
|
|
436
|
+
}
|
|
428
437
|
function profileString(profile, ...keys) {
|
|
429
438
|
for (const key of keys) {
|
|
430
439
|
const value = profile[key];
|
|
@@ -458,15 +467,17 @@ function profileNumber(profile, ...keys) {
|
|
|
458
467
|
return null;
|
|
459
468
|
}
|
|
460
469
|
/**
|
|
461
|
-
* Hermes exposes
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
470
|
+
* Hermes exposes API-server and model routing at the installation level.
|
|
471
|
+
* Profile-list output varies by Hermes release and often contains only names,
|
|
472
|
+
* so carry the observed installation posture into every profile unless that
|
|
473
|
+
* profile explicitly reported its own value. `hermes gateway status` observes
|
|
474
|
+
* only the default profile's gateway, so another profile that reported nothing
|
|
475
|
+
* stays unknown instead of counting as one more running gateway.
|
|
465
476
|
*/
|
|
466
477
|
function enrichProfiles(profiles, installation) {
|
|
467
478
|
return profiles.map((profile) => ({
|
|
468
479
|
...profile,
|
|
469
|
-
gatewayStatus:
|
|
480
|
+
gatewayStatus: profileGatewayStatus(profile, installation.gatewayStatus),
|
|
470
481
|
providerSummary: profileString(profile, 'providerSummary', 'provider_summary', 'provider') ?? installation.configuration.modelProvider,
|
|
471
482
|
modelSummary: profileString(profile, 'modelSummary', 'model_summary', 'model') ?? installation.configuration.modelName,
|
|
472
483
|
apiServerEnabled: profileBoolean(profile, installation.apiServerEnabled, 'apiServerEnabled', 'api_server_enabled'),
|
|
@@ -589,6 +600,15 @@ export async function configureHermes(options, configuration) {
|
|
|
589
600
|
return { warnings, state: await collectHermesState(options, true) };
|
|
590
601
|
}
|
|
591
602
|
let stateCache = null;
|
|
603
|
+
export function hermesInstallationStatus(input) {
|
|
604
|
+
if (!input.installed)
|
|
605
|
+
return 'not_installed';
|
|
606
|
+
if (input.gatewayStatus === 'running')
|
|
607
|
+
return 'running';
|
|
608
|
+
if (!input.runnable)
|
|
609
|
+
return 'degraded';
|
|
610
|
+
return input.configPresent ? 'detected' : 'needs_setup';
|
|
611
|
+
}
|
|
592
612
|
export async function collectHermesState(options = {}, refresh = false) {
|
|
593
613
|
const requested = { hermesHome: options.hermesHome ?? DEFAULT_HERMES_HOME, hermesBin: options.hermesBin ?? 'hermes' };
|
|
594
614
|
const runtime = await discoverRuntimeTarget(requested);
|
|
@@ -605,9 +625,11 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
605
625
|
const fallbackVersion = runtime.fallbackVersion;
|
|
606
626
|
const homeProfiles = await profilesFromHome(resolved.home);
|
|
607
627
|
const installed = versionResult.code === 0 || fallbackVersion?.code === 0 || runtime.homeDetected || configPresent || homeProfiles.length > 0;
|
|
608
|
-
|
|
609
|
-
const
|
|
610
|
-
const
|
|
628
|
+
// A Hermes home without a working binary is leftover data, not a usable installation.
|
|
629
|
+
const runnable = versionResult.code === 0 || fallbackVersion?.code === 0;
|
|
630
|
+
const version = runnable ? firstLine(versionResult.stdout || fallbackVersion?.stdout || versionResult.stderr) : null;
|
|
631
|
+
const gatewayResult = runnable ? await runHermes(runtime.requested, ['gateway', 'status']) : null;
|
|
632
|
+
const profileListResult = runnable ? await runHermes(runtime.requested, ['profile', 'list', '--json'], 15_000) : null;
|
|
611
633
|
let profiles = [];
|
|
612
634
|
if (profileListResult?.code === 0 && profileListResult.stdout.trim()) {
|
|
613
635
|
try {
|
|
@@ -617,7 +639,7 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
617
639
|
}
|
|
618
640
|
catch { /* fall through with empty profiles */ }
|
|
619
641
|
}
|
|
620
|
-
if (profiles.length === 0 &&
|
|
642
|
+
if (profiles.length === 0 && runnable) {
|
|
621
643
|
const textResult = await runHermes(runtime.requested, ['profile', 'list'], 15_000);
|
|
622
644
|
profiles = parseProfileListText(`${textResult.stdout}\n${textResult.stderr}`, resolved.home).slice(0, 50);
|
|
623
645
|
}
|
|
@@ -632,12 +654,9 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
632
654
|
metadata: { source: 'implicit default profile' },
|
|
633
655
|
}];
|
|
634
656
|
}
|
|
635
|
-
const gatewayText = `${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`.toLowerCase();
|
|
636
657
|
const gatewayStatus = !installed
|
|
637
658
|
? 'not_installed'
|
|
638
|
-
:
|
|
639
|
-
: /failed|error/.test(gatewayText) ? 'failed'
|
|
640
|
-
: /inactive|stopped|not running/.test(gatewayText) ? 'inactive' : 'unknown';
|
|
659
|
+
: parseGatewayStatusText(`${gatewayResult?.stdout ?? ''}\n${gatewayResult?.stderr ?? ''}`);
|
|
641
660
|
const modelProvider = yamlBlockValue(config, 'model', 'provider');
|
|
642
661
|
const modelName = yamlBlockValue(config, 'model', 'default');
|
|
643
662
|
const modelBaseUrl = yamlBlockValue(config, 'model', 'base_url');
|
|
@@ -703,7 +722,7 @@ export async function collectHermesState(options = {}, refresh = false) {
|
|
|
703
722
|
const dashboard = await observeHermesDashboard(resolved.home);
|
|
704
723
|
const state = {
|
|
705
724
|
installed,
|
|
706
|
-
status:
|
|
725
|
+
status: hermesInstallationStatus({ installed, runnable, gatewayStatus, configPresent }),
|
|
707
726
|
installMode: installed ? 'official-script' : 'existing',
|
|
708
727
|
version,
|
|
709
728
|
hermesBin: requested.hermesBin,
|
|
@@ -51,12 +51,23 @@ export declare function stableServiceProperties(raw: string): string;
|
|
|
51
51
|
* (waiting/running/elapsed) and description change whenever a timer fires. */
|
|
52
52
|
export declare function stableTimerUnitList(raw: string): string;
|
|
53
53
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
* Whether a program or script file name is Hermes: the `hermes` CLI and its
|
|
55
|
+
* `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
|
|
56
|
+
* component counts; a `.hermes` or `hermes` directory is where unrelated
|
|
57
|
+
* software can live too, so it never identifies a runtime by itself.
|
|
58
|
+
*/
|
|
59
|
+
export declare function hermesProgramName(path: string): boolean;
|
|
60
|
+
/** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
|
|
61
|
+
export declare function hermesModuleName(module: string): boolean;
|
|
62
|
+
/**
|
|
63
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: the
|
|
64
|
+
* basename of its executable, the basename of the script or the module an
|
|
65
|
+
* interpreter runs, or an explicit `--hermes-home` option. Other argument text
|
|
66
|
+
* is data, not identity: an operator's `grep hermes`, a report filter naming a
|
|
67
|
+
* Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
|
|
68
|
+
* installed under a `.hermes` directory is not a runtime (any Hermes it starts
|
|
69
|
+
* is a separate process observed on its own). Every command the dashboard
|
|
70
|
+
* recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
|
|
60
71
|
*/
|
|
61
72
|
export declare function hermesRuntimeCommand(args: string[]): boolean;
|
|
62
73
|
export declare function discoverSchedulerSources(options?: DiscoveryOptions): Promise<{
|
|
@@ -133,38 +133,62 @@ export function stableServiceProperties(raw) {
|
|
|
133
133
|
export function stableTimerUnitList(raw) {
|
|
134
134
|
return raw.split('\n').map(line => line.trim().split(/\s+/).slice(0, 3).join(' ')).join('\n');
|
|
135
135
|
}
|
|
136
|
-
const HERMES_RUNTIME_TEXT = /(?:hermes|run_gateway\.py)/i;
|
|
137
136
|
const SCRIPT_INTERPRETER = /^(?:python(?:[23](?:\.\d+)?)?|pypy3?|node|bun|deno|bash|sh|dash|zsh)$/;
|
|
137
|
+
/** Interpreter options whose next argument is a value (a preloaded module, a
|
|
138
|
+
* warning filter), never the script being run. */
|
|
139
|
+
const INTERPRETER_VALUE_OPTIONS = new Set(['-r', '--require', '--import', '--loader', '--experimental-loader', '-C',
|
|
140
|
+
'--conditions', '--env-file', '--input-type', '-W', '-X', '--check-hash-based-pycs', '-o']);
|
|
141
|
+
const basenameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
|
|
138
142
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
* Whether a program or script file name is Hermes: the `hermes` CLI and its
|
|
144
|
+
* `hermes-*`/`hermes_*` shims, or the gateway runner. Only the final path
|
|
145
|
+
* component counts; a `.hermes` or `hermes` directory is where unrelated
|
|
146
|
+
* software can live too, so it never identifies a runtime by itself.
|
|
147
|
+
*/
|
|
148
|
+
export function hermesProgramName(path) {
|
|
149
|
+
const name = basenameOf(path).replace(/\.(?:py|pyc|js|mjs|cjs|ts|sh|exe)$/i, '');
|
|
150
|
+
return /^hermes(?:[-_][A-Za-z0-9_.-]*)?$/i.test(name) || /^run_gateway$/i.test(name);
|
|
151
|
+
}
|
|
152
|
+
/** Whether a Python module (`-m`) is Hermes: `hermes`, `hermes_cli`, `hermes_cli.main`, `hermes_agent`... */
|
|
153
|
+
export function hermesModuleName(module) {
|
|
154
|
+
return /^hermes(?:_[A-Za-z0-9_]*)?(?:\.[A-Za-z0-9_.]*)?$/i.test(module) || /^run_gateway$/i.test(module);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A process is a possible Hermes runtime when what it executes is Hermes: the
|
|
158
|
+
* basename of its executable, the basename of the script or the module an
|
|
159
|
+
* interpreter runs, or an explicit `--hermes-home` option. Other argument text
|
|
160
|
+
* is data, not identity: an operator's `grep hermes`, a report filter naming a
|
|
161
|
+
* Hermes kind, a shell whose inline code mentions Hermes, or unrelated software
|
|
162
|
+
* installed under a `.hermes` directory is not a runtime (any Hermes it starts
|
|
163
|
+
* is a separate process observed on its own). Every command the dashboard
|
|
164
|
+
* recogniser accepts (hermes-dashboard-discovery.ts) is recognised here too.
|
|
145
165
|
*/
|
|
146
166
|
export function hermesRuntimeCommand(args) {
|
|
147
167
|
const executable = args[0] ?? '';
|
|
148
|
-
if (
|
|
168
|
+
if (hermesProgramName(executable))
|
|
149
169
|
return true;
|
|
150
170
|
if (args.some(arg => /^--hermes-home(?:=|$)/.test(arg)))
|
|
151
171
|
return true;
|
|
152
|
-
if (!SCRIPT_INTERPRETER.test(executable
|
|
172
|
+
if (!SCRIPT_INTERPRETER.test(basenameOf(executable)))
|
|
153
173
|
return false;
|
|
154
174
|
for (let index = 1; index < args.length; index++) {
|
|
155
175
|
const arg = args[index];
|
|
156
176
|
if (arg === '-m')
|
|
157
|
-
return
|
|
177
|
+
return hermesModuleName(args[index + 1] ?? '');
|
|
158
178
|
if (/^-m./.test(arg))
|
|
159
|
-
return
|
|
179
|
+
return hermesModuleName(arg.slice(2));
|
|
160
180
|
// Inline code is not a script identity.
|
|
161
181
|
if (/^-[a-zA-Z]*[ce]$/.test(arg) || ['--command', '--eval', '--print'].includes(arg))
|
|
162
182
|
return false;
|
|
163
183
|
if (arg === '--')
|
|
164
|
-
return
|
|
184
|
+
return hermesProgramName(args[index + 1] ?? '');
|
|
185
|
+
if (INTERPRETER_VALUE_OPTIONS.has(arg)) {
|
|
186
|
+
index++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
165
189
|
if (arg.startsWith('-'))
|
|
166
190
|
continue;
|
|
167
|
-
return
|
|
191
|
+
return hermesProgramName(arg);
|
|
168
192
|
}
|
|
169
193
|
return false;
|
|
170
194
|
}
|
|
@@ -13,6 +13,16 @@ export interface SchedulerInventoryOptions {
|
|
|
13
13
|
atSpools?: string[];
|
|
14
14
|
wrapper?: (job: Awaited<ReturnType<ManagedScheduler['job']>> & {}) => string;
|
|
15
15
|
}
|
|
16
|
+
export declare const hermesJobClaimed: (job: Record<string, unknown>) => boolean;
|
|
17
|
+
/** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
|
|
18
|
+
* job definitions, so adding, editing or enabling a job is a new source while a
|
|
19
|
+
* run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
|
|
20
|
+
export declare function hermesJobsIdentity(path: string, value: unknown): {
|
|
21
|
+
schedulesSha256: string;
|
|
22
|
+
claimsSha256: string;
|
|
23
|
+
enabled: number;
|
|
24
|
+
claims: number;
|
|
25
|
+
};
|
|
16
26
|
export declare function schedulerInventory(options?: SchedulerInventoryOptions): Promise<{
|
|
17
27
|
version: number;
|
|
18
28
|
barrier: {
|
|
@@ -5,6 +5,55 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { hash } from './execution-supervisor-state.js';
|
|
6
6
|
import { aggregateSchedulerBlockers, boundedSchedulerMemberEvidence, boundedSchedulerRead, discoverSchedulerSources, schedulerContentDigest, schedulerMemberEvidenceDigest, schedulerMembersDigest } from './scheduler-discovery.js';
|
|
7
7
|
import { ManagedScheduler } from './managed-scheduler.js';
|
|
8
|
+
/** Per-job fields Hermes rewrites while running or finishing a job. They are
|
|
9
|
+
* run state, not schedule definition: excluding them keeps one remembered source
|
|
10
|
+
* per jobs.json definition instead of one per run. Every other field, including
|
|
11
|
+
* fields this list does not know, is part of the definition. */
|
|
12
|
+
const HERMES_RUN_STATE_FIELDS = new Set(['next_run_at', 'updated_at', 'run_count', 'runs', 'failure_count',
|
|
13
|
+
'consecutive_failures', 'fire_claim', 'run_claim']);
|
|
14
|
+
const hermesRunStateField = (key) => key.startsWith('last_') || HERMES_RUN_STATE_FIELDS.has(key);
|
|
15
|
+
function canonicalJson(value) {
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return value.map(canonicalJson);
|
|
18
|
+
if (!value || typeof value !== 'object')
|
|
19
|
+
return value;
|
|
20
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]));
|
|
21
|
+
}
|
|
22
|
+
function hermesJobDefinition(job) {
|
|
23
|
+
const definition = {};
|
|
24
|
+
for (const [key, value] of Object.entries(job))
|
|
25
|
+
if (!hermesRunStateField(key))
|
|
26
|
+
definition[key] = value;
|
|
27
|
+
// `repeat.times` is configuration; `repeat.completed` is a run counter.
|
|
28
|
+
if (definition['repeat'] && typeof definition['repeat'] === 'object' && !Array.isArray(definition['repeat'])) {
|
|
29
|
+
const { completed: _completed, ...repeat } = definition['repeat'];
|
|
30
|
+
definition['repeat'] = repeat;
|
|
31
|
+
}
|
|
32
|
+
return canonicalJson(definition);
|
|
33
|
+
}
|
|
34
|
+
export const hermesJobClaimed = (job) => Boolean(job['fire_claim'] || job['run_claim'] || ['running', 'working'].includes(job['last_status']));
|
|
35
|
+
/** Path-scoped identity of one Hermes cron/jobs.json. Schedules: the canonical
|
|
36
|
+
* job definitions, so adding, editing or enabling a job is a new source while a
|
|
37
|
+
* run-state rewrite is not. Claims: the set of jobs currently holding a claim. */
|
|
38
|
+
export function hermesJobsIdentity(path, value) {
|
|
39
|
+
const document = Array.isArray(value) ? undefined : value && typeof value === 'object' ? value : undefined;
|
|
40
|
+
const jobs = Array.isArray(value) ? value : document?.['jobs'];
|
|
41
|
+
if (!Array.isArray(jobs) || jobs.length > 1000 || jobs.some(job => !job || typeof job !== 'object' || Array.isArray(job)
|
|
42
|
+
|| job.enabled !== undefined && typeof job.enabled !== 'boolean' || job.paused !== undefined && typeof job.paused !== 'boolean'))
|
|
43
|
+
throw new Error('Invalid native jobs');
|
|
44
|
+
const rows = jobs;
|
|
45
|
+
const definitions = rows.map(job => JSON.stringify(hermesJobDefinition(job))).sort();
|
|
46
|
+
// Top-level document fields other than the job list are kept, except its rewrite stamp.
|
|
47
|
+
const envelope = document ? canonicalJson(Object.fromEntries(Object.entries(document).filter(([key]) => key !== 'jobs' && key !== 'updated_at'))) : null;
|
|
48
|
+
const claimed = rows.filter(hermesJobClaimed).map(job => ['string', 'number'].includes(typeof job['id']) && String(job['id'])
|
|
49
|
+
? 'id:' + String(job['id']) : 'definition:' + hash(JSON.stringify(hermesJobDefinition(job)))).sort();
|
|
50
|
+
return {
|
|
51
|
+
schedulesSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-schedules.v1', source: hash(path), envelope, definitions })),
|
|
52
|
+
claimsSha256: schedulerContentDigest(JSON.stringify({ domain: 'magnus.hermes-claims.v1', source: hash(path), claimed })),
|
|
53
|
+
enabled: rows.filter(job => job['enabled'] !== false && job['paused'] !== true).length,
|
|
54
|
+
claims: claimed.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
8
57
|
export async function schedulerInventory(options = {}) {
|
|
9
58
|
const blockers = [];
|
|
10
59
|
const issues = [];
|
|
@@ -147,15 +196,14 @@ export async function schedulerInventory(options = {}) {
|
|
|
147
196
|
if (raw === undefined)
|
|
148
197
|
continue;
|
|
149
198
|
try {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
blockContent('NATIVE_HERMES_UNSETTLED_CLAIMS', raw, jobs.filter(job => job.fire_claim || job.run_claim || ['running', 'working'].includes(job.last_status)).length);
|
|
199
|
+
const identity = hermesJobsIdentity(path, JSON.parse(raw));
|
|
200
|
+
// Hermes rewrites jobs.json on every run. Evidence and remembered identity
|
|
201
|
+
// follow the job definitions and the set of claimed jobs, not run state.
|
|
202
|
+
sources[hash(path)] = schedulerContentDigest(JSON.stringify({ schedules: identity.schedulesSha256, claims: identity.claimsSha256 }));
|
|
203
|
+
blockers.push(...[{ kind: 'NATIVE_HERMES_SCHEDULES', sourceSha256: identity.schedulesSha256, count: identity.enabled },
|
|
204
|
+
// Native claim TTL/PID liveness cannot prove descendants stopped. A paused
|
|
205
|
+
// schedule with any unresolved claim remains uncertain until its owner proves completion.
|
|
206
|
+
{ kind: 'NATIVE_HERMES_UNSETTLED_CLAIMS', sourceSha256: identity.claimsSha256, count: identity.claims }].filter(row => row.count));
|
|
159
207
|
}
|
|
160
208
|
catch {
|
|
161
209
|
issues.push('HERMES_SCHEDULE_INVENTORY_INVALID:' + hash(path));
|
package/dist/hermes-sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { access, chown, chmod, lstat, mkdir, mkdtemp, readFile, readlink, readdi
|
|
|
7
7
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { parseGatewayStatusText } from './executors/hermes-gateway-status.js';
|
|
10
11
|
import { childEnvironment } from './utils/child-env.js';
|
|
11
12
|
import { armChildTimeout } from './utils/child-timeout.js';
|
|
12
13
|
// Compare real paths: the service may start this file through a link (node_modules -> current/node_modules).
|
|
@@ -667,7 +668,7 @@ async function coreUpdate() {
|
|
|
667
668
|
const current = (await runAsOwner('/usr/bin/git', home, ['-C', checkout, 'rev-parse', 'HEAD'])).trim();
|
|
668
669
|
evidence.beforeVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
|
|
669
670
|
const gatewayBefore = await runHermes(bin, home, ['gateway', 'status']);
|
|
670
|
-
evidence.beforeGatewayActive =
|
|
671
|
+
evidence.beforeGatewayActive = parseGatewayStatusText(gatewayBefore) === 'running';
|
|
671
672
|
const pluginsBefore = await runHermes(bin, home, ['plugins', 'list', '--json']);
|
|
672
673
|
const toolsBefore = await toolsSummary(bin, home);
|
|
673
674
|
if (current === slot.commit) {
|
|
@@ -730,7 +731,7 @@ async function coreUpdate() {
|
|
|
730
731
|
}
|
|
731
732
|
evidence.afterVersion = (await runHermes(bin, home, ['--version'])).trim().slice(0, 100);
|
|
732
733
|
const gatewayAfter = await runHermes(bin, home, ['gateway', 'status']);
|
|
733
|
-
evidence.afterGatewayActive =
|
|
734
|
+
evidence.afterGatewayActive = parseGatewayStatusText(gatewayAfter) === 'running';
|
|
734
735
|
const pluginsAfter = await runHermes(bin, home, ['plugins', 'list', '--json']);
|
|
735
736
|
const before = JSON.parse(pluginsBefore);
|
|
736
737
|
const after = JSON.parse(pluginsAfter);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Magnus node agent: a small daemon that enrolls a Linux host with a magnus-server and runs authenticated health, command, cron, file and skill management, with optional Hermes Agent, Codex and Ardent management.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"clean": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.rmSync('tsconfig.tsbuildinfo',{force:true})\""
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@hivemagnus/magnus-contracts": "1.1.
|
|
61
|
+
"@hivemagnus/magnus-contracts": "1.1.7",
|
|
62
62
|
"@hono/node-server": "^2.1.5",
|
|
63
63
|
"dotenv": "^18.0.9",
|
|
64
64
|
"hono": "^4.13.16",
|