@halcyonlabs/app 0.47.0-halcyon.113 → 0.47.0-halcyon.115
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 +7 -7
- package/package.json +1 -1
- package/server/dist/builtin-plugins/orbit/dist/assets/orbit/bundle/scripts/orbit-job-supervisor +128 -75
- package/server/dist/builtin-plugins/orbit/dist/assets/orbit/revision +1 -1
- package/server/dist/builtin-plugins/orbit/dist/server.js +95 -95
- package/server/dist/start-server.js +41 -24
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;
|
|
@@ -9187,13 +9187,13 @@ declare const hostDaemonCommandRegistry: {
|
|
|
9187
9187
|
modifiedAtMs: z$1.ZodOptional<z$1.ZodNumber>;
|
|
9188
9188
|
sha256: z$1.ZodString;
|
|
9189
9189
|
}, z$1.core.$strip>, z$1.ZodObject<{
|
|
9190
|
-
path: z$1.ZodString;
|
|
9191
9190
|
contentEncoding: z$1.ZodEnum<{
|
|
9192
9191
|
base64: "base64";
|
|
9193
9192
|
utf8: "utf8";
|
|
9194
9193
|
}>;
|
|
9195
9194
|
sha256: z$1.ZodString;
|
|
9196
9195
|
sizeBytes: z$1.ZodNumber;
|
|
9196
|
+
path: z$1.ZodString;
|
|
9197
9197
|
mimeType: z$1.ZodOptional<z$1.ZodString>;
|
|
9198
9198
|
modifiedAtMs: z$1.ZodOptional<z$1.ZodNumber>;
|
|
9199
9199
|
notModified: z$1.ZodLiteral<true>;
|
|
@@ -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>;
|
|
@@ -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
package/server/dist/builtin-plugins/orbit/dist/assets/orbit/bundle/scripts/orbit-job-supervisor
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 <
|
|
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
|
|
359
|
-
if inodes is not None and (type(inodes) is not int or inodes <
|
|
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'
|
|
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 <
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
24548d59c6e823a880b77272b3c83280929bec54
|