@halcyonlabs/app 0.47.0-halcyon.113 → 0.47.0-halcyon.116

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/index.d.ts CHANGED
@@ -7456,8 +7456,8 @@ declare const hostDaemonCommandRegistry: {
7456
7456
  skillFilePath: z$1.ZodString;
7457
7457
  }, z$1.core.$strict>], "kind">>;
7458
7458
  instructionMode: z$1.ZodEnum<{
7459
- replace: "replace";
7460
7459
  append: "append";
7460
+ replace: "replace";
7461
7461
  }>;
7462
7462
  type: z$1.ZodLiteral<"thread.rewind.prepare">;
7463
7463
  leaseId: z$1.ZodString;
@@ -7605,8 +7605,8 @@ declare const hostDaemonCommandRegistry: {
7605
7605
  skillFilePath: z$1.ZodString;
7606
7606
  }, z$1.core.$strict>], "kind">>;
7607
7607
  instructionMode: z$1.ZodEnum<{
7608
- replace: "replace";
7609
7608
  append: "append";
7609
+ replace: "replace";
7610
7610
  }>;
7611
7611
  type: z$1.ZodLiteral<"thread.start">;
7612
7612
  requestId: z$1.ZodString;
@@ -8069,8 +8069,8 @@ declare const hostDaemonCommandRegistry: {
8069
8069
  envPassthrough: z$1.ZodArray<z$1.ZodString>;
8070
8070
  }, z$1.core.$strict>;
8071
8071
  instructionMode: z$1.ZodEnum<{
8072
- replace: "replace";
8073
8072
  append: "append";
8073
+ replace: "replace";
8074
8074
  }>;
8075
8075
  projectId: z$1.ZodString;
8076
8076
  providerId: z$1.ZodString;
@@ -8285,8 +8285,8 @@ declare const hostDaemonCommandRegistry: {
8285
8285
  envPassthrough: z$1.ZodArray<z$1.ZodString>;
8286
8286
  }, z$1.core.$strict>;
8287
8287
  instructionMode: z$1.ZodEnum<{
8288
- replace: "replace";
8289
8288
  append: "append";
8289
+ replace: "replace";
8290
8290
  }>;
8291
8291
  projectId: z$1.ZodString;
8292
8292
  providerId: z$1.ZodString;
@@ -9192,9 +9192,9 @@ declare const hostDaemonCommandRegistry: {
9192
9192
  base64: "base64";
9193
9193
  utf8: "utf8";
9194
9194
  }>;
9195
- sha256: z$1.ZodString;
9196
- sizeBytes: z$1.ZodNumber;
9197
9195
  mimeType: z$1.ZodOptional<z$1.ZodString>;
9196
+ sizeBytes: z$1.ZodNumber;
9197
+ sha256: z$1.ZodString;
9198
9198
  modifiedAtMs: z$1.ZodOptional<z$1.ZodNumber>;
9199
9199
  notModified: z$1.ZodLiteral<true>;
9200
9200
  }, z$1.core.$strip>]>, "onlineRpc", true>;
@@ -9459,10 +9459,10 @@ declare const hostDaemonCommandRegistry: {
9459
9459
  health: z$1.ZodObject<{
9460
9460
  status: z$1.ZodEnum<{
9461
9461
  unknown: "unknown";
9462
- ready: "ready";
9463
9462
  not_installed: "not_installed";
9464
9463
  unauthenticated: "unauthenticated";
9465
9464
  expired: "expired";
9465
+ ready: "ready";
9466
9466
  unsupported_version: "unsupported_version";
9467
9467
  }>;
9468
9468
  statusMessage: z$1.ZodNullable<z$1.ZodString>;
@@ -9510,9 +9510,9 @@ declare const hostDaemonCommandRegistry: {
9510
9510
  executablePath: z$1.ZodNullable<z$1.ZodString>;
9511
9511
  installed: z$1.ZodBoolean;
9512
9512
  installSource: z$1.ZodEnum<{
9513
+ external: "external";
9513
9514
  notInstalled: "notInstalled";
9514
9515
  npmGlobal: "npmGlobal";
9515
- external: "external";
9516
9516
  }>;
9517
9517
  currentVersion: z$1.ZodNullable<z$1.ZodString>;
9518
9518
  latestVersion: z$1.ZodNullable<z$1.ZodString>;
@@ -9958,8 +9958,8 @@ declare const hostDaemonCommandRegistry: {
9958
9958
  message: z$1.ZodNullable<z$1.ZodString>;
9959
9959
  state: z$1.ZodNullable<z$1.ZodEnum<{
9960
9960
  pending: "pending";
9961
- activating: "activating";
9962
9961
  ready: "ready";
9962
+ activating: "activating";
9963
9963
  }>>;
9964
9964
  }, z$1.core.$strict>, "onlineRpc", true>;
9965
9965
  "server_move.prepare": HostDaemonCommandDescriptor<"server_move.prepare", z$1.ZodObject<{
@@ -10061,9 +10061,9 @@ declare const providerCliStatusResponseSchema: z$1.ZodRecord<z$1.ZodString, z$1.
10061
10061
  executablePath: z$1.ZodNullable<z$1.ZodString>;
10062
10062
  installed: z$1.ZodBoolean;
10063
10063
  installSource: z$1.ZodEnum<{
10064
+ external: "external";
10064
10065
  notInstalled: "notInstalled";
10065
10066
  npmGlobal: "npmGlobal";
10066
- external: "external";
10067
10067
  }>;
10068
10068
  currentVersion: z$1.ZodNullable<z$1.ZodString>;
10069
10069
  latestVersion: z$1.ZodNullable<z$1.ZodString>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halcyonlabs/app",
3
- "version": "0.47.0-halcyon.113",
3
+ "version": "0.47.0-halcyon.116",
4
4
  "description": "bb app launcher for npx",
5
5
  "keywords": [
6
6
  "bb",
@@ -10,6 +10,7 @@ import errno
10
10
  import hashlib
11
11
  import time
12
12
  import re
13
+ import subprocess
13
14
 
14
15
  # A job has no size or file-count cap of its own. Work stops only when the disk
15
16
  # holding job folders falls below MAIN_FREE_FLOOR, which keeps room for the OS,
@@ -21,10 +22,9 @@ START_HEADROOM = 5_000_000_000
21
22
  # to free file entries on filesystems that have a fixed number of them.
22
23
  INODE_FLOOR = 500_000
23
24
  START_INODE_HEADROOM = 500_000
24
- MIN_CONCURRENT_JOBS = 2
25
- MAX_CONCURRENT_JOBS = 8
26
- JOB_MEMORY_BYTES = 6 * 2**30
27
- DESKTOP_MEMORY_BYTES = 6 * 2**30
25
+ ADMISSION_DEFAULTS = dict(memory_free_bytes=2**30, disk_free_bytes=MAIN_FREE_FLOOR,
26
+ disk_start_headroom_bytes=START_HEADROOM, free_inodes=INODE_FLOOR,
27
+ start_inode_headroom=START_INODE_HEADROOM)
28
28
  CAPACITY_FULL_STATUS = 75
29
29
  LIMITS = {}
30
30
  # Records written before the free-space rule carry the old fixed caps. They load
@@ -219,59 +219,89 @@ def write_record(directory, name, value, exclusive=False):
219
219
  raise
220
220
 
221
221
 
222
- def machine_concurrency(memory, cores, override):
223
- if override is not None:
224
- if not re.fullmatch('[0-9]{1,9}', override):
225
- raise ValueError('Job concurrency override must be a positive whole number.')
226
- requested = int(override)
227
- if requested < 1:
228
- raise ValueError('Job concurrency override must be a positive whole number.')
229
- slots = max(MIN_CONCURRENT_JOBS, min(MAX_CONCURRENT_JOBS, requested))
230
- return slots, f'override {requested}; floor {MIN_CONCURRENT_JOBS}, ceiling {MAX_CONCURRENT_JOBS}'
231
- memory_slots = max(0, memory - DESKTOP_MEMORY_BYTES) // JOB_MEMORY_BYTES
232
- core_slots = cores // 3
233
- slots = max(MIN_CONCURRENT_JOBS, min(MAX_CONCURRENT_JOBS, memory_slots, core_slots))
234
- return slots, (f'{memory / 2**30:.1f} GiB RAM minus 6 GiB for desktop, antfly and main session; '
235
- f'6 GiB per job gives {memory_slots} slots; {cores} cores / 3 gives {core_slots}; '
236
- f'floor {MIN_CONCURRENT_JOBS}, ceiling {MAX_CONCURRENT_JOBS}')
237
-
238
-
239
- def machine_resources():
240
- if sys.platform == 'darwin':
241
- import subprocess
242
- try:
243
- memory = int(subprocess.check_output(['sysctl', '-n', 'hw.memsize'], timeout=5))
244
- except subprocess.SubprocessError as error:
245
- raise OSError('Machine memory reading failed.') from error
246
- else:
247
- memory = os.sysconf('SC_PHYS_PAGES') * os.sysconf('SC_PAGE_SIZE')
248
- cores = os.cpu_count()
249
- if memory <= 0 or cores is None or cores <= 0:
250
- raise ValueError('Machine memory or core count is unavailable.')
251
- return memory, cores
222
+ def ledger_setting(state, name, maximum):
223
+ try:
224
+ fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, dir_fd=state)
225
+ except FileNotFoundError:
226
+ return None
227
+ with os.fdopen(fd) as stream:
228
+ metadata = os.fstat(stream.fileno())
229
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > maximum:
230
+ raise ValueError('Invalid admission settings file.')
231
+ value = stream.read(maximum + 1)
232
+ if len(value) > maximum:
233
+ raise ValueError('Invalid admission settings file.')
234
+ return value
235
+
236
+
237
+ def admission_settings(state):
238
+ raw = ledger_setting(state, 'admission', 4096)
239
+ values = {} if raw is None else json.loads(raw)
240
+ if (not isinstance(values, dict) or set(values) - ADMISSION_DEFAULTS.keys()
241
+ or any(type(v) is not int or v <= 0 or v > 2**63 - 1 for v in values.values())):
242
+ raise ValueError('Admission settings must name positive whole-number pressure thresholds.')
243
+ if any(key != 'memory_free_bytes' and value < ADMISSION_DEFAULTS[key] for key, value in values.items()):
244
+ raise ValueError('Admission settings cannot lower the existing disk or file-entry floors.')
245
+ return dict(ADMISSION_DEFAULTS, **values)
252
246
 
253
247
 
254
248
  def concurrent_jobs(state):
255
- try:
256
- fd = os.open('concurrency', os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK, dir_fd=state)
257
- except FileNotFoundError:
258
- override = None
259
- else:
260
- with os.fdopen(fd) as stream:
261
- metadata = os.fstat(stream.fileno())
262
- if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 32:
263
- raise ValueError('Invalid job concurrency override file.')
264
- override = stream.read(33).strip()
265
- try:
266
- memory, cores = machine_resources()
267
- slots, reason = machine_concurrency(memory, cores, override)
268
- except (OSError, ValueError) as error:
269
- if override is not None:
270
- slots, reason = machine_concurrency(0, 0, override)
271
- else:
272
- slots, reason = MIN_CONCURRENT_JOBS, f'machine reading unavailable: {error}; floor {MIN_CONCURRENT_JOBS}'
273
- print(f'Job concurrency: {slots}; {reason}.', file=sys.stderr)
274
- return slots
249
+ raw = ledger_setting(state, 'concurrency', 32)
250
+ if raw is None:
251
+ return None
252
+ value = raw.strip()
253
+ if not re.fullmatch('[0-9]{1,9}', value) or int(value) < 1:
254
+ raise ValueError('The temporary task ceiling must be a positive whole number.')
255
+ return int(value)
256
+
257
+
258
+ def linux_available_memory(proc=Path('/proc'), cgroups=Path('/sys/fs/cgroup')):
259
+ fields = dict(line.split(':', 1) for line in (proc / 'meminfo').read_text().splitlines())
260
+ value = fields['MemAvailable'].split()
261
+ if len(value) != 2 or value[1] != 'kB':
262
+ raise ValueError('Invalid available memory reading.')
263
+ memory = int(value[0]) * 1024
264
+ membership = [line[3:] for line in (proc / 'self/cgroup').read_text().splitlines() if line.startswith('0::')]
265
+ if membership:
266
+ path = Path(membership[0])
267
+ if not path.is_absolute() or '..' in path.parts:
268
+ raise ValueError('Invalid memory group path.')
269
+ group = cgroups / str(path).lstrip('/')
270
+ while True:
271
+ try:
272
+ limit = (group / 'memory.max').read_text().strip()
273
+ except FileNotFoundError:
274
+ limit = 'max'
275
+ if limit != 'max':
276
+ maximum = int(limit)
277
+ current = int((group / 'memory.current').read_text())
278
+ if maximum < 0 or current < 0:
279
+ raise ValueError('Invalid memory group reading.')
280
+ statistics = dict(line.split() for line in (group / 'memory.stat').read_text().splitlines())
281
+ reclaimable = max(0, int(statistics.get('inactive_file', '0'))
282
+ - int(statistics.get('file_dirty', '0'))
283
+ - int(statistics.get('file_writeback', '0')))
284
+ if reclaimable < 0 or reclaimable > current:
285
+ raise ValueError('Invalid reclaimable memory reading.')
286
+ memory = min(memory, max(0, maximum - current + reclaimable))
287
+ if group == cgroups:
288
+ break
289
+ group = group.parent
290
+ if memory < 0:
291
+ raise ValueError('Invalid available memory reading.')
292
+ return memory
293
+
294
+
295
+ def available_memory():
296
+ if sys.platform == 'linux':
297
+ return linux_available_memory()
298
+ if sys.platform == 'darwin':
299
+ reading = subprocess.check_output(['vm_stat'], timeout=5).decode()
300
+ page_size = int(re.search(r'page size of ([0-9]+) bytes', reading)[1])
301
+ pages = sum(int(re.search(r'^' + name + r':\s+([0-9]+)\.', reading, re.M)[1])
302
+ for name in ('Pages free', 'Pages inactive', 'Pages speculative'))
303
+ return pages * page_size
304
+ raise OSError('Available memory is not supported on this system.')
275
305
 
276
306
 
277
307
  class Workspaces:
@@ -339,30 +369,30 @@ class Workspaces:
339
369
  if workspace:
340
370
  self.environment(existing['id'])
341
371
  return existing
342
- slots = [r for r in records if holds_slot(r)]
343
- if len(slots) >= concurrent_jobs(self.state):
344
- raise CapacityFull('Job capacity is reserved; wait for disposal.')
372
+ settings = self.require_admission(records)
345
373
  if free is None:
346
374
  free = work_free(self)
375
+ inodes = work_inodes(self)
347
376
  if workspace:
348
377
  results_free = available(self.results)
349
- inodes = work_inodes(self)
350
378
  if not workspace:
351
- if type(free) is not int or free < MAIN_FREE_FLOOR:
379
+ if inodes is not None and (type(inodes) is not int or inodes < settings['free_inodes']):
380
+ raise CapacityFull(f'Station disk has {inodes} free file entries; waiting for free file entries.')
381
+ if type(free) is not int or free < settings['disk_free_bytes']:
352
382
  raise CapacityFull(f'Station disk has {gigabytes(free)} GB free space; a start needs '
353
- f'{gigabytes(MAIN_FREE_FLOOR)} GB. Archive handled work to free space.')
383
+ f"{gigabytes(settings['disk_free_bytes'])} GB. Archive handled work to free space.")
354
384
  else:
355
385
  retained = sum(self.result_reservation(r) for r in records) + result_metadata_space(manifest)
356
- if type(free) is not int or free < MAIN_FREE_FLOOR + START_HEADROOM:
386
+ if type(free) is not int or free < settings['disk_free_bytes'] + settings['disk_start_headroom_bytes']:
357
387
  raise CapacityFull(f'Station disk has {gigabytes(free)} GB free space; a new job needs '
358
- f'{gigabytes(MAIN_FREE_FLOOR + START_HEADROOM)} GB. Archive handled work to free space.')
359
- if inodes is not None and (type(inodes) is not int or inodes < INODE_FLOOR + START_INODE_HEADROOM):
388
+ f"{gigabytes(settings['disk_free_bytes'] + settings['disk_start_headroom_bytes'])} GB. Archive handled work to free space.")
389
+ if inodes is not None and (type(inodes) is not int or inodes < settings['free_inodes'] + settings['start_inode_headroom']):
360
390
  raise CapacityFull(f'Station disk has {inodes} free file entries; a new job needs '
361
- f'{INODE_FLOOR + START_INODE_HEADROOM}. Archive handled work to free space.')
391
+ f"{settings['free_inodes'] + settings['start_inode_headroom']}. Archive handled work to free space.")
362
392
  results_free = free if results_free is None else results_free
363
- if type(results_free) is not int or results_free < MAIN_FREE_FLOOR + retained:
393
+ if type(results_free) is not int or results_free < settings['disk_free_bytes'] + retained:
364
394
  raise CapacityFull(f'The saved-results disk has {gigabytes(results_free)} GB free space; a new job needs '
365
- f'{gigabytes(MAIN_FREE_FLOOR + retained)} GB there. Free space on it first.')
395
+ f"{gigabytes(settings['disk_free_bytes'] + retained)} GB there. Free space on it first.")
366
396
  # Persist ownership first. A crash before mkdir still leaves a reserved
367
397
  # attempt which restart must explicitly finish, never silently forget.
368
398
  write_record(self.state, manifest['id'] + '.json', manifest, exclusive=True)
@@ -380,6 +410,22 @@ class Workspaces:
380
410
  self.environment(manifest['id'])
381
411
  return manifest
382
412
 
413
+ def require_admission(self, records):
414
+ settings = admission_settings(self.state)
415
+ ceiling = concurrent_jobs(self.state)
416
+ if ceiling is not None and sum(holds_slot(r) for r in records) >= ceiling:
417
+ raise CapacityFull('The temporary task ceiling is reached; wait for a task to finish or park.')
418
+ try:
419
+ memory = available_memory()
420
+ if type(memory) is not int or memory < 0:
421
+ raise ValueError('Invalid available memory reading.')
422
+ except (OSError, ValueError, KeyError, TypeError, IndexError, subprocess.SubprocessError) as error:
423
+ raise CapacityFull('Station available memory could not be checked; waiting for a memory reading.') from error
424
+ if memory < settings['memory_free_bytes']:
425
+ raise CapacityFull(f'Station has {gigabytes(memory)} GB available memory; a task needs '
426
+ f"{gigabytes(settings['memory_free_bytes'])} GB. Waiting for memory to be freed.")
427
+ return settings
428
+
383
429
  def workspace_identity(self, job_id):
384
430
  if not self.prefix:
385
431
  return None
@@ -555,9 +601,10 @@ class Workspaces:
555
601
 
556
602
  def require_result_space(self, required):
557
603
  free = available(self.results)
558
- if free < MAIN_FREE_FLOOR + required:
604
+ floor = admission_settings(self.state)['disk_free_bytes']
605
+ if free < floor + required:
559
606
  raise CapacityFull(f'The saved-results disk has {gigabytes(free)} GB free space; saving results needs '
560
- f'{gigabytes(MAIN_FREE_FLOOR + required)} GB there. Free space on it first.')
607
+ f'{gigabytes(floor + required)} GB there. Free space on it first.')
561
608
 
562
609
  def read_output_declaration(self, root):
563
610
  fd = open_output(root, '.orbit-outputs.json')
@@ -613,8 +660,13 @@ class Workspaces:
613
660
  return manifest
614
661
  if manifest['state'] != 'parked':
615
662
  raise ValueError('Only a parked job can take a slot again.')
616
- if len([r for r in self.records() if holds_slot(r)]) >= concurrent_jobs(self.state):
617
- raise CapacityFull('Job capacity is reserved; wait for disposal.')
663
+ self.require_admission(self.records())
664
+ free, inodes, low = work_reading(self)
665
+ if low:
666
+ raise CapacityFull(f'Station disk has {gigabytes(free)} GB free space and {inodes} free file entries; '
667
+ 'a waiting task needs the disk pressure to clear.')
668
+ if has_workspace(manifest):
669
+ self.require_result_space(sum(self.result_reservation(r) for r in self.records()))
618
670
  return self.save_transition(manifest, 'running')
619
671
 
620
672
  def retire(self, job_id):
@@ -899,7 +951,12 @@ def work_inodes(store):
899
951
 
900
952
  def work_reading(store):
901
953
  free, inodes = work_free(store), work_inodes(store)
902
- return free, inodes, free < MAIN_FREE_FLOOR or (inodes is not None and inodes < INODE_FLOOR)
954
+ try:
955
+ settings = admission_settings(store.state)
956
+ except (OSError, ValueError):
957
+ settings = ADMISSION_DEFAULTS
958
+ print('Pressure settings could not be read; disk monitoring uses its default floors.', file=sys.stderr)
959
+ return free, inodes, free < settings['disk_free_bytes'] or (inodes is not None and inodes < settings['free_inodes'])
903
960
 
904
961
 
905
962
  def writable_directory(parent, name, expected=None, device=None):
@@ -1343,10 +1400,6 @@ def managed_operation(store, account, request):
1343
1400
  finally:
1344
1401
  os.close(root)
1345
1402
  elif request['operation'] == 'measure':
1346
- try:
1347
- concurrent_jobs(store.state)
1348
- except (OSError, ValueError) as error:
1349
- print(f'Job concurrency setting could not be read: {error}', file=sys.stderr)
1350
1403
  if request.get('size', True) not in (True, False):
1351
1404
  raise ValueError('Invalid measure request.')
1352
1405
  print(json.dumps(store.measure(request['id'], sized=request.get('size', True))))
@@ -1 +1 @@
1
- 5f5bbcef81e9074d0c70249ce75f778b27538f4e
1
+ f5c079730906116f483166528b77ea54cc75d2f5