@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 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] && !['-', '—'].includes(cells[2]) ? cells[2] : 'unknown',
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 gateway, API-server, and model routing at the installation
462
- * level. Profile-list output varies by Hermes release and often contains only
463
- * names, so carry the observed installation posture into every profile unless
464
- * that profile explicitly reported its own value.
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: profileString(profile, 'gatewayStatus', 'gateway_status') ?? installation.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
- const version = installed ? firstLine(versionResult.stdout || fallbackVersion?.stdout || versionResult.stderr) : null;
609
- const gatewayResult = versionResult.code === 0 || fallbackVersion?.code === 0 ? await runHermes(runtime.requested, ['gateway', 'status']) : null;
610
- const profileListResult = versionResult.code === 0 || fallbackVersion?.code === 0 ? await runHermes(runtime.requested, ['profile', 'list', '--json'], 15_000) : null;
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 && (versionResult.code === 0 || fallbackVersion?.code === 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
- : /running|active|healthy/.test(gatewayText) ? 'running'
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: !installed ? 'not_installed' : gatewayStatus === 'running' ? 'running' : configPresent ? 'detected' : 'needs_setup',
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
- * A process is a possible Hermes runtime when what it executes is Hermes: its
55
- * executable, the script or module an interpreter runs, or an explicit Hermes
56
- * home option. Other argument text is data, not identity: an operator's
57
- * `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
58
- * code mentions Hermes is not a runtime (any Hermes it starts is a separate
59
- * process observed on its own).
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
- * A process is a possible Hermes runtime when what it executes is Hermes: its
140
- * executable, the script or module an interpreter runs, or an explicit Hermes
141
- * home option. Other argument text is data, not identity: an operator's
142
- * `grep hermes`, a report filter naming a Hermes kind, or a shell whose inline
143
- * code mentions Hermes is not a runtime (any Hermes it starts is a separate
144
- * process observed on its own).
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 (HERMES_RUNTIME_TEXT.test(executable))
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.split('/').pop() ?? ''))
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 HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
177
+ return hermesModuleName(args[index + 1] ?? '');
158
178
  if (/^-m./.test(arg))
159
- return HERMES_RUNTIME_TEXT.test(arg.slice(2));
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 HERMES_RUNTIME_TEXT.test(args[index + 1] ?? '');
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 HERMES_RUNTIME_TEXT.test(arg);
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 value = JSON.parse(raw);
151
- const jobs = Array.isArray(value) ? value : value.jobs;
152
- if (!Array.isArray(jobs) || jobs.length > 1000 || jobs.some(job => !job || typeof job !== 'object'
153
- || job.enabled !== undefined && typeof job.enabled !== 'boolean' || job.paused !== undefined && typeof job.paused !== 'boolean'))
154
- throw new Error('Invalid native jobs');
155
- blockContent('NATIVE_HERMES_SCHEDULES', raw, jobs.filter(job => job.enabled !== false && job.paused !== true).length);
156
- // Native claim TTL/PID liveness cannot prove descendants stopped. A paused
157
- // schedule with any unresolved claim remains uncertain until its owner proves completion.
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));
@@ -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 = /active|running/i.test(gatewayBefore) && !/inactive|not active|not running|stopped|dead/i.test(gatewayBefore);
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 = /active|running/i.test(gatewayAfter) && !/inactive|not active|not running|stopped|dead/i.test(gatewayAfter);
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.5",
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.5",
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",