@halcyonlabs/app 0.47.0-halcyon.33 → 0.47.0-halcyon.35

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
@@ -155,10 +155,10 @@ declare const uiPreferenceDefinitions: {
155
155
  }>>;
156
156
  readonly "sidebar.threadGrouping.environment": UiPreferenceDefinition<z$1.ZodUnion<readonly [z$1.ZodLiteral<"auto">, z$1.ZodBoolean]>>;
157
157
  readonly "sidebar.chronologicalSort": UiPreferenceDefinition<z$1.ZodEnum<{
158
+ none: "none";
158
159
  updated: "updated";
159
160
  created: "created";
160
161
  alpha: "alpha";
161
- none: "none";
162
162
  }>>;
163
163
  readonly "sidebar.sortDirection": UiPreferenceDefinition<z$1.ZodEnum<{
164
164
  default: "default";
@@ -591,9 +591,9 @@ declare const hostSchema: z$1.ZodObject<{
591
591
  }, z$1.core.$strip>>;
592
592
  }, z$1.core.$strip>;
593
593
  maxPermissionMode: z$1.ZodEnum<{
594
+ full: "full";
594
595
  auto: "auto";
595
596
  "accept-edits": "accept-edits";
596
- full: "full";
597
597
  }>;
598
598
  lastSeenAt: z$1.ZodNullable<z$1.ZodNumber>;
599
599
  lastRejectedProtocolVersion: z$1.ZodNullable<z$1.ZodNumber>;
@@ -2824,9 +2824,9 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
2824
2824
  tell: "tell";
2825
2825
  }>;
2826
2826
  initiator: z$1.ZodEnum<{
2827
+ user: "user";
2827
2828
  system: "system";
2828
2829
  agent: "agent";
2829
- user: "user";
2830
2830
  }>;
2831
2831
  request: z$1.ZodObject<{
2832
2832
  method: z$1.ZodEnum<{
@@ -2847,9 +2847,9 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
2847
2847
  tell: "tell";
2848
2848
  }>;
2849
2849
  initiator: z$1.ZodEnum<{
2850
+ user: "user";
2850
2851
  system: "system";
2851
2852
  agent: "agent";
2852
- user: "user";
2853
2853
  }>;
2854
2854
  senderThreadId: z$1.ZodNullable<z$1.ZodString>;
2855
2855
  systemMessageKind: z$1.ZodOptional<z$1.ZodEnum<{
@@ -2955,12 +2955,12 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
2955
2955
  }>;
2956
2956
  name: z$1.ZodString;
2957
2957
  source: z$1.ZodEnum<{
2958
- command: "command";
2959
2958
  skill: "skill";
2959
+ command: "command";
2960
2960
  }>;
2961
2961
  origin: z$1.ZodEnum<{
2962
- project: "project";
2963
2962
  user: "user";
2963
+ project: "project";
2964
2964
  builtin: "builtin";
2965
2965
  }>;
2966
2966
  label: z$1.ZodString;
@@ -3045,12 +3045,12 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3045
3045
  }>;
3046
3046
  name: z$1.ZodString;
3047
3047
  source: z$1.ZodEnum<{
3048
- command: "command";
3049
3048
  skill: "skill";
3049
+ command: "command";
3050
3050
  }>;
3051
3051
  origin: z$1.ZodEnum<{
3052
- project: "project";
3053
3052
  user: "user";
3053
+ project: "project";
3054
3054
  builtin: "builtin";
3055
3055
  }>;
3056
3056
  label: z$1.ZodString;
@@ -3123,9 +3123,9 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3123
3123
  }>;
3124
3124
  permissionMode: z$1.ZodEnum<{
3125
3125
  readonly: "readonly";
3126
+ full: "full";
3126
3127
  auto: "auto";
3127
3128
  "accept-edits": "accept-edits";
3128
- full: "full";
3129
3129
  "workspace-write": "workspace-write";
3130
3130
  }>;
3131
3131
  }, z$1.core.$strip>;
@@ -3144,9 +3144,9 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3144
3144
  tell: "tell";
3145
3145
  }>;
3146
3146
  initiator: z$1.ZodEnum<{
3147
+ user: "user";
3147
3148
  system: "system";
3148
3149
  agent: "agent";
3149
- user: "user";
3150
3150
  }>;
3151
3151
  request: z$1.ZodObject<{
3152
3152
  method: z$1.ZodEnum<{
@@ -3191,10 +3191,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3191
3191
  interaction: z$1.ZodUnion<readonly [z$1.ZodObject<{
3192
3192
  id: z$1.ZodString;
3193
3193
  status: z$1.ZodEnum<{
3194
+ resolved: "resolved";
3194
3195
  pending: "pending";
3195
3196
  interrupted: "interrupted";
3196
3197
  resolving: "resolving";
3197
- resolved: "resolved";
3198
3198
  }>;
3199
3199
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3200
3200
  origin: z$1.ZodObject<{
@@ -3327,10 +3327,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3327
3327
  }, z$1.core.$strip>, z$1.ZodObject<{
3328
3328
  id: z$1.ZodString;
3329
3329
  status: z$1.ZodEnum<{
3330
+ resolved: "resolved";
3330
3331
  pending: "pending";
3331
3332
  interrupted: "interrupted";
3332
3333
  resolving: "resolving";
3333
- resolved: "resolved";
3334
3334
  }>;
3335
3335
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3336
3336
  origin: z$1.ZodObject<{
@@ -3363,10 +3363,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3363
3363
  }, z$1.core.$strip>, z$1.ZodObject<{
3364
3364
  id: z$1.ZodString;
3365
3365
  status: z$1.ZodEnum<{
3366
+ resolved: "resolved";
3366
3367
  pending: "pending";
3367
3368
  interrupted: "interrupted";
3368
3369
  resolving: "resolving";
3369
- resolved: "resolved";
3370
3370
  }>;
3371
3371
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3372
3372
  origin: z$1.ZodObject<{
@@ -3414,10 +3414,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3414
3414
  }, z$1.core.$strip>, z$1.ZodObject<{
3415
3415
  id: z$1.ZodString;
3416
3416
  status: z$1.ZodEnum<{
3417
+ resolved: "resolved";
3417
3418
  pending: "pending";
3418
3419
  interrupted: "interrupted";
3419
3420
  resolving: "resolving";
3420
- resolved: "resolved";
3421
3421
  }>;
3422
3422
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3423
3423
  origin: z$1.ZodObject<{
@@ -3440,10 +3440,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3440
3440
  providerId: z$1.ZodString;
3441
3441
  providerRequestId: z$1.ZodString;
3442
3442
  status: z$1.ZodEnum<{
3443
+ resolved: "resolved";
3443
3444
  pending: "pending";
3444
3445
  interrupted: "interrupted";
3445
3446
  resolving: "resolving";
3446
- resolved: "resolved";
3447
3447
  }>;
3448
3448
  resolution: z$1.ZodDefault<z$1.ZodNullable<z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
3449
3449
  decision: z$1.ZodLiteral<"allow_once">;
@@ -3492,10 +3492,10 @@ declare const threadEventSchema: z$1.ZodUnion<readonly [z$1.ZodIntersection<z$1.
3492
3492
  providerId: z$1.ZodString;
3493
3493
  providerRequestId: z$1.ZodString;
3494
3494
  status: z$1.ZodEnum<{
3495
+ resolved: "resolved";
3495
3496
  pending: "pending";
3496
3497
  interrupted: "interrupted";
3497
3498
  resolving: "resolving";
3498
- resolved: "resolved";
3499
3499
  }>;
3500
3500
  resolution: z$1.ZodDefault<z$1.ZodNullable<z$1.ZodObject<{
3501
3501
  kind: z$1.ZodLiteral<"user_answer">;
@@ -3617,10 +3617,10 @@ declare const providerPendingInteractionSchema: z$1.ZodUnion<readonly [z$1.ZodOb
3617
3617
  id: z$1.ZodString;
3618
3618
  threadId: z$1.ZodString;
3619
3619
  status: z$1.ZodEnum<{
3620
+ resolved: "resolved";
3620
3621
  pending: "pending";
3621
3622
  interrupted: "interrupted";
3622
3623
  resolving: "resolving";
3623
- resolved: "resolved";
3624
3624
  }>;
3625
3625
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3626
3626
  createdAt: z$1.ZodNumber;
@@ -3767,10 +3767,10 @@ declare const providerPendingInteractionSchema: z$1.ZodUnion<readonly [z$1.ZodOb
3767
3767
  id: z$1.ZodString;
3768
3768
  threadId: z$1.ZodString;
3769
3769
  status: z$1.ZodEnum<{
3770
+ resolved: "resolved";
3770
3771
  pending: "pending";
3771
3772
  interrupted: "interrupted";
3772
3773
  resolving: "resolving";
3773
- resolved: "resolved";
3774
3774
  }>;
3775
3775
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3776
3776
  createdAt: z$1.ZodNumber;
@@ -3812,10 +3812,10 @@ declare const providerPendingInteractionSchema: z$1.ZodUnion<readonly [z$1.ZodOb
3812
3812
  id: z$1.ZodString;
3813
3813
  threadId: z$1.ZodString;
3814
3814
  status: z$1.ZodEnum<{
3815
+ resolved: "resolved";
3815
3816
  pending: "pending";
3816
3817
  interrupted: "interrupted";
3817
3818
  resolving: "resolving";
3818
- resolved: "resolved";
3819
3819
  }>;
3820
3820
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3821
3821
  createdAt: z$1.ZodNumber;
@@ -3846,10 +3846,10 @@ declare const pluginPendingInteractionSchema: z$1.ZodObject<{
3846
3846
  id: z$1.ZodString;
3847
3847
  threadId: z$1.ZodString;
3848
3848
  status: z$1.ZodEnum<{
3849
+ resolved: "resolved";
3849
3850
  pending: "pending";
3850
3851
  interrupted: "interrupted";
3851
3852
  resolving: "resolving";
3852
- resolved: "resolved";
3853
3853
  }>;
3854
3854
  statusReason: z$1.ZodNullable<z$1.ZodString>;
3855
3855
  createdAt: z$1.ZodNumber;
@@ -3922,9 +3922,9 @@ type ReasoningLevel = z$1.infer<typeof reasoningLevelSchema>;
3922
3922
  declare const serviceTierSchema: z$1.ZodString;
3923
3923
  type ServiceTier = z$1.infer<typeof serviceTierSchema>;
3924
3924
  declare const permissionModeSchema: z$1.ZodEnum<{
3925
+ full: "full";
3925
3926
  auto: "auto";
3926
3927
  "accept-edits": "accept-edits";
3927
- full: "full";
3928
3928
  }>;
3929
3929
  type PermissionMode = z$1.infer<typeof permissionModeSchema>;
3930
3930
  declare const promptTextMentionSchema: z$1.ZodObject<{
@@ -3963,12 +3963,12 @@ declare const promptTextMentionSchema: z$1.ZodObject<{
3963
3963
  }>;
3964
3964
  name: z$1.ZodString;
3965
3965
  source: z$1.ZodEnum<{
3966
- command: "command";
3967
3966
  skill: "skill";
3967
+ command: "command";
3968
3968
  }>;
3969
3969
  origin: z$1.ZodEnum<{
3970
- project: "project";
3971
3970
  user: "user";
3971
+ project: "project";
3972
3972
  builtin: "builtin";
3973
3973
  }>;
3974
3974
  label: z$1.ZodString;
@@ -4028,12 +4028,12 @@ declare const promptInputSchema: z$1.ZodDiscriminatedUnion<[z$1.ZodObject<{
4028
4028
  }>;
4029
4029
  name: z$1.ZodString;
4030
4030
  source: z$1.ZodEnum<{
4031
- command: "command";
4032
4031
  skill: "skill";
4032
+ command: "command";
4033
4033
  }>;
4034
4034
  origin: z$1.ZodEnum<{
4035
- project: "project";
4036
4035
  user: "user";
4036
+ project: "project";
4037
4037
  builtin: "builtin";
4038
4038
  }>;
4039
4039
  label: z$1.ZodString;
@@ -4092,9 +4092,9 @@ declare const resolvedThreadExecutionOptionsSchema: z$1.ZodObject<{
4092
4092
  serviceTier: z$1.ZodString;
4093
4093
  reasoningLevel: z$1.ZodString;
4094
4094
  permissionMode: z$1.ZodEnum<{
4095
+ full: "full";
4095
4096
  auto: "auto";
4096
4097
  "accept-edits": "accept-edits";
4097
- full: "full";
4098
4098
  }>;
4099
4099
  source: z$1.ZodEnum<{
4100
4100
  "client/thread/start": "client/thread/start";
@@ -4109,9 +4109,9 @@ declare const projectExecutionDefaultsSchema: z$1.ZodObject<{
4109
4109
  serviceTier: z$1.ZodString;
4110
4110
  reasoningLevel: z$1.ZodString;
4111
4111
  permissionMode: z$1.ZodEnum<{
4112
+ full: "full";
4112
4113
  auto: "auto";
4113
4114
  "accept-edits": "accept-edits";
4114
- full: "full";
4115
4115
  }>;
4116
4116
  }, z$1.core.$strip>;
4117
4117
  type ProjectExecutionDefaults = z$1.infer<typeof projectExecutionDefaultsSchema>;
@@ -4150,9 +4150,9 @@ declare const providerInfoSchema: z$1.ZodObject<{
4150
4150
  supportsFork: z$1.ZodBoolean;
4151
4151
  supportsSessionRewind: z$1.ZodBoolean;
4152
4152
  permissionModes: z$1.ZodArray<z$1.ZodEnum<{
4153
+ full: "full";
4153
4154
  auto: "auto";
4154
4155
  "accept-edits": "accept-edits";
4155
- full: "full";
4156
4156
  }>>;
4157
4157
  modelCatalogScope: z$1.ZodEnum<{
4158
4158
  workspace: "workspace";
@@ -4292,9 +4292,9 @@ declare const threadQueuedMessageSchema: z$1.ZodObject<{
4292
4292
  }>>;
4293
4293
  originPluginId: z$1.ZodNullable<z$1.ZodString>;
4294
4294
  initiator: z$1.ZodEnum<{
4295
+ user: "user";
4295
4296
  system: "system";
4296
4297
  agent: "agent";
4297
- user: "user";
4298
4298
  }>;
4299
4299
  senderThreadId: z$1.ZodNullable<z$1.ZodString>;
4300
4300
  threadId: z$1.ZodString;
@@ -4340,12 +4340,12 @@ declare const threadQueuedMessageSchema: z$1.ZodObject<{
4340
4340
  }>;
4341
4341
  name: z$1.ZodString;
4342
4342
  source: z$1.ZodEnum<{
4343
- command: "command";
4344
4343
  skill: "skill";
4344
+ command: "command";
4345
4345
  }>;
4346
4346
  origin: z$1.ZodEnum<{
4347
- project: "project";
4348
4347
  user: "user";
4348
+ project: "project";
4349
4349
  builtin: "builtin";
4350
4350
  }>;
4351
4351
  label: z$1.ZodString;
@@ -4391,9 +4391,9 @@ declare const threadQueuedMessageSchema: z$1.ZodObject<{
4391
4391
  model: z$1.ZodString;
4392
4392
  reasoningLevel: z$1.ZodString;
4393
4393
  permissionMode: z$1.ZodEnum<{
4394
+ full: "full";
4394
4395
  auto: "auto";
4395
4396
  "accept-edits": "accept-edits";
4396
- full: "full";
4397
4397
  }>;
4398
4398
  serviceTier: z$1.ZodString;
4399
4399
  groupWithNext: z$1.ZodBoolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halcyonlabs/app",
3
- "version": "0.47.0-halcyon.33",
3
+ "version": "0.47.0-halcyon.35",
4
4
  "description": "bb app launcher for npx",
5
5
  "keywords": [
6
6
  "bb",
@@ -9,16 +9,25 @@ import sys
9
9
  import errno
10
10
  import hashlib
11
11
  import time
12
- import sys
13
12
  import re
14
13
 
15
- JOB_BYTES = 6_000_000_000
16
- JOB_FILES = 250_000
17
- WORKSPACE_BYTES = 20_000_000_000
14
+ # A job has no size or file-count cap of its own. Work stops only when the disk
15
+ # holding job folders falls below MAIN_FREE_FLOOR, which keeps room for the OS,
16
+ # bb and Orbit's own state. A new job starts only with START_HEADROOM above it.
18
17
  RETAINED_JOB_BYTES = 1_000_000_000
19
18
  RETAINED_TOTAL_BYTES = 8_000_000_000
20
19
  MAIN_FREE_FLOOR = 15_000_000_000
20
+ START_HEADROOM = 5_000_000_000
21
+ # Many tiny files can exhaust inodes while bytes remain: the same rule applies
22
+ # to free file entries on filesystems that have a fixed number of them.
23
+ INODE_FLOOR = 500_000
24
+ START_INODE_HEADROOM = 500_000
21
25
  CONCURRENT_JOBS = 2
26
+ LIMITS = dict(retained_bytes=RETAINED_JOB_BYTES)
27
+ # Records written before the free-space rule carry the old fixed caps. They load
28
+ # as current records and are rewritten in that form at their next transition.
29
+ LEGACY_LIMITS = dict(bytes=6_000_000_000, files=250_000, retained_bytes=RETAINED_JOB_BYTES)
30
+ assert START_HEADROOM >= RETAINED_JOB_BYTES
22
31
  STATES = ('reserved', 'running', 'stopping', 'exporting', 'destroying', 'closed')
23
32
  ENDS = ('finished', 'failed', 'cancelled', 'timed_out', 'crashed', 'storage_limit')
24
33
 
@@ -42,9 +51,7 @@ def output_names(outputs):
42
51
 
43
52
  def new_manifest(job_id, owner, outputs):
44
53
  manifest = dict(version=1, id=job_id, owner=owner, state='reserved', end=None,
45
- limits=dict(bytes=JOB_BYTES, files=JOB_FILES,
46
- retained_bytes=RETAINED_JOB_BYTES),
47
- outputs=output_names(outputs))
54
+ limits=dict(LIMITS), outputs=output_names(outputs))
48
55
  return validate_manifest(manifest)
49
56
 
50
57
 
@@ -57,14 +64,15 @@ def validate_manifest(manifest):
57
64
  or not isinstance(manifest['owner'], str)
58
65
  or re.fullmatch('thr_[A-Za-z0-9_-]{1,128}', manifest['owner']) is None
59
66
  or manifest['state'] not in STATES
60
- or manifest['limits'] != dict(bytes=JOB_BYTES, files=JOB_FILES,
61
- retained_bytes=RETAINED_JOB_BYTES)):
67
+ or manifest['limits'] not in (LIMITS, LEGACY_LIMITS)):
62
68
  raise ValueError('Invalid job record.')
63
69
  ending = manifest['state'] in ('stopping', 'exporting', 'destroying', 'closed')
64
70
  if (ending and manifest['end'] not in ENDS) or (not ending and manifest['end'] is not None):
65
71
  raise ValueError('Invalid job end.')
66
72
  output_names(manifest['outputs'])
67
- return copy.deepcopy(manifest)
73
+ current = copy.deepcopy(manifest)
74
+ current['limits'] = dict(LIMITS)
75
+ return current
68
76
 
69
77
 
70
78
  def transition(manifest, state, reason=None):
@@ -197,7 +205,7 @@ class Workspaces:
197
205
  # or thread state. A corrupt or unreadable entry blocks admission.
198
206
  return [self.read(name[:-5]) for name in os.listdir(self.state) if name.endswith('.json')]
199
207
 
200
- def create(self, owner, outputs, *, main_free, job_id=None):
208
+ def create(self, owner, outputs, *, free, results_free=None, inodes=None, job_id=None):
201
209
  manifest = new_manifest(job_id if job_id is not None else secrets.token_hex(16), owner, outputs)
202
210
  with self.locked():
203
211
  records = self.records()
@@ -208,13 +216,23 @@ class Workspaces:
208
216
  self.environment(existing['id'])
209
217
  return existing
210
218
  pending = [r for r in records if holds_capacity(r)]
211
- if len(pending) >= CONCURRENT_JOBS or (len(pending) + 1) * JOB_BYTES > WORKSPACE_BYTES:
219
+ if len(pending) >= CONCURRENT_JOBS:
212
220
  raise ValueError('Job capacity is reserved; wait for disposal.')
213
221
  retained = sum(self.result_reservation(r) for r in records)
214
222
  if retained + RETAINED_JOB_BYTES > RETAINED_TOTAL_BYTES:
215
223
  raise ValueError('Result capacity is reserved.')
216
- if type(main_free) is not int or main_free < MAIN_FREE_FLOOR + RETAINED_JOB_BYTES:
217
- raise ValueError('Insufficient main-disk free space to reserve results.')
224
+ if type(free) is not int or free < MAIN_FREE_FLOOR + START_HEADROOM:
225
+ raise ValueError(f'Station disk has {gigabytes(free)} GB free space; a new job needs '
226
+ f'{gigabytes(MAIN_FREE_FLOOR + START_HEADROOM)} GB. Archive handled work to free space.')
227
+ if inodes is not None and (type(inodes) is not int or inodes < INODE_FLOOR + START_INODE_HEADROOM):
228
+ raise ValueError(f'Station disk has {inodes} free file entries; a new job needs '
229
+ f'{INODE_FLOOR + START_INODE_HEADROOM}. Archive handled work to free space.')
230
+ # Saved results may sit on another disk: it must keep its floor
231
+ # after this job's result reservation.
232
+ results_free = free if results_free is None else results_free
233
+ if type(results_free) is not int or results_free < MAIN_FREE_FLOOR + RETAINED_JOB_BYTES:
234
+ raise ValueError(f'The saved-results disk has {gigabytes(results_free)} GB free space; a new job needs '
235
+ f'{gigabytes(MAIN_FREE_FLOOR + RETAINED_JOB_BYTES)} GB there. Free space on it first.')
218
236
  # Persist ownership first. A crash before mkdir still leaves a reserved
219
237
  # attempt which restart must explicitly finish, never silently forget.
220
238
  write_record(self.state, manifest['id'] + '.json', manifest, exclusive=True)
@@ -280,17 +298,34 @@ class Workspaces:
280
298
 
281
299
 
282
300
  def usage(self, job_id):
301
+ """Size a workspace to choose which job to stop when the disk is low.
302
+
303
+ The walk is bounded to five seconds. A walk cut short by the time limit
304
+ or by a file system error reports what it counted, a lower bound, with
305
+ complete=False rather than failing: the watchdog ranks an incomplete
306
+ size ahead of every complete one, so a folder cannot escape by being
307
+ slow or awkward to walk. Size is allocated blocks: a sparse file counts
308
+ only the disk it occupies, which is what stopping its writer protects.
309
+ """
283
310
  self.read(job_id)
284
311
  root = self.open_workspace(job_id)
285
312
  device = os.fstat(root).st_dev
286
313
  stack = [(root, os.scandir(root))]
287
314
  deadline = time.monotonic() + 5
315
+ try:
316
+ return self._walk(stack, device, deadline)
317
+ finally:
318
+ for fd, entries in reversed(stack):
319
+ entries.close()
320
+ os.close(fd)
321
+
322
+ def _walk(self, stack, device, deadline):
288
323
  files = size = 0
289
324
  seen = set()
290
325
  try:
291
326
  while stack:
292
327
  if time.monotonic() > deadline:
293
- raise ValueError('Workspace measurement exceeded its time allowance.')
328
+ return dict(files=files, bytes=size, complete=False)
294
329
  current, entries = stack[-1]
295
330
  entry = next(entries, None)
296
331
  if entry is None:
@@ -307,10 +342,8 @@ class Workspaces:
307
342
  files += 1
308
343
  identity = (metadata.st_dev, metadata.st_ino)
309
344
  if identity not in seen:
310
- size += max(metadata.st_size, metadata.st_blocks * 512)
345
+ size += metadata.st_blocks * 512
311
346
  seen.add(identity)
312
- if files > JOB_FILES or size > JOB_BYTES:
313
- return dict(files=files, bytes=size)
314
347
  if stat.S_ISDIR(metadata.st_mode):
315
348
  try:
316
349
  child = os.open(entry.name, DIR_FLAGS, dir_fd=current)
@@ -322,11 +355,30 @@ class Workspaces:
322
355
  os.close(child)
323
356
  raise ValueError('A workspace cannot contain another filesystem.')
324
357
  stack.append((child, os.scandir(child)))
325
- return dict(files=files, bytes=size)
326
- finally:
327
- for fd, entries in reversed(stack):
328
- entries.close()
329
- os.close(fd)
358
+ return dict(files=files, bytes=size, complete=True)
359
+ except OSError:
360
+ return dict(files=files, bytes=size, complete=False)
361
+
362
+ def measure(self, job_id, sized=True):
363
+ """Free space and file entries for the stop rule; the folder's size only when low.
364
+
365
+ Free space is read first and reported whatever happens to the size walk.
366
+ A folder that cannot be read or walked reports complete=False, which the
367
+ watchdog treats as the largest, so an error never keeps work running
368
+ while the disk is low. sized=False is the quick recheck: no walk.
369
+ """
370
+ free, inodes, low = work_reading(self)
371
+ size = files = complete = None
372
+ if low and sized:
373
+ try:
374
+ usage = self.usage(job_id)
375
+ size, files, complete = usage['bytes'], usage['files'], usage['complete']
376
+ except (OSError, ValueError):
377
+ complete = False
378
+ # The walk can take seconds; report the disk as it is now.
379
+ free, inodes, low = work_reading(self)
380
+ return dict(free=free, floor=MAIN_FREE_FLOOR, inodes=inodes, inodeFloor=INODE_FLOOR,
381
+ low=low, bytes=size, files=files, complete=complete)
330
382
 
331
383
  def result_reservation(self, manifest):
332
384
  if holds_capacity(manifest):
@@ -363,7 +415,12 @@ class Workspaces:
363
415
  finally:
364
416
  os.close(root)
365
417
  extra = json.loads(raw)
366
- if not isinstance(extra, list) or len(extra) > 32:
418
+ if not isinstance(extra, list):
419
+ raise ValueError('Declare at most 32 extra output files.')
420
+ # Repeating a name declared at start adds nothing and is not refused;
421
+ # only new names must be reports under results/ or logs under logs/.
422
+ extra = [name for name in extra if name not in manifest['outputs']]
423
+ if len(extra) > 32:
367
424
  raise ValueError('Declare at most 32 extra output files.')
368
425
  output_names(extra)
369
426
  if any(not name.startswith(('results/', 'logs/')) for name in extra):
@@ -631,6 +688,31 @@ class Workspaces:
631
688
  os.close(fd)
632
689
 
633
690
 
691
+ def gigabytes(value):
692
+ return format(value / 1_000_000_000, '.1f') if type(value) is int else 'unknown'
693
+
694
+
695
+ def available(fd):
696
+ disk = os.fstatvfs(fd)
697
+ return disk.f_bavail * disk.f_frsize
698
+
699
+
700
+ def work_free(store):
701
+ """Available bytes on the disk holding job folders: the one stopping work can relieve."""
702
+ return available(store.work)
703
+
704
+
705
+ def work_inodes(store):
706
+ """Free file entries on that disk, or None where the filesystem has no fixed count."""
707
+ disk = os.fstatvfs(store.work)
708
+ return disk.f_favail if disk.f_files else None
709
+
710
+
711
+ def work_reading(store):
712
+ free, inodes = work_free(store), work_inodes(store)
713
+ return free, inodes, free < MAIN_FREE_FLOOR or (inodes is not None and inodes < INODE_FLOOR)
714
+
715
+
634
716
  def writable_directory(parent, name, expected=None, device=None):
635
717
  """Open even mode-000 directories without chmod through a worker symlink.
636
718
 
@@ -912,8 +994,9 @@ def managed_request(request):
912
994
  store = Workspaces(state, data / 'workspaces', data / 'results' / 'jobs', prefix='job-')
913
995
  try:
914
996
  if request['operation'] == 'create':
915
- free = os.fstatvfs(store.results)
916
- job = store.create(request['owner'], request['outputs'], main_free=free.f_bavail * free.f_frsize, job_id=request['id'])
997
+ job = store.create(request['owner'], request['outputs'], free=work_free(store),
998
+ results_free=available(store.results), inodes=work_inodes(store),
999
+ job_id=request['id'])
917
1000
  root = store.open_workspace(job['id'])
918
1001
  try:
919
1002
  prepare_hooks(root)
@@ -930,11 +1013,9 @@ def managed_request(request):
930
1013
  finally:
931
1014
  os.close(root)
932
1015
  elif request['operation'] == 'measure':
933
- usage = store.usage(request['id'])
934
- disk = os.fstatvfs(store.results)
935
- free = disk.f_bavail * disk.f_frsize
936
- limited = usage['bytes'] > JOB_BYTES or usage['files'] > JOB_FILES or free < MAIN_FREE_FLOOR
937
- print(json.dumps(dict(usage, free=free, limited=limited)))
1016
+ if request.get('size', True) not in (True, False):
1017
+ raise ValueError('Invalid measure request.')
1018
+ print(json.dumps(store.measure(request['id'], sized=request.get('size', True))))
938
1019
  elif request['operation'] == 'check':
939
1020
  try:
940
1021
  os.stat('job-' + request['id'], dir_fd=store.work, follow_symlinks=False)
@@ -1 +1 @@
1
- ac9bce3a1b992f6e6167102e6f62eaca3fb8cd20
1
+ c70a6c7040a62a4ffc186d65f0830b7741f45ab4