@adhdev/daemon-core 0.9.82-rc.380 → 0.9.82-rc.381

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.
@@ -352,6 +352,114 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
352
352
  maxTaskRetries: 1,
353
353
  };
354
354
 
355
+ // ─── Policy normalization (single source of truth) ──────────────────────────
356
+ //
357
+ // Every mesh policy passes through mergeAndNormalizePolicy exactly once on write
358
+ // (createMesh/updateMesh) and again whenever a policy is materialized for display
359
+ // or scheduling. Co-locating the default constant, the per-field normalizers, and
360
+ // the merge here keeps the three former layers (DEFAULT_MESH_POLICY, the merge in
361
+ // mesh-config, and the scattered field clamps) from drifting apart. The function
362
+ // is idempotent: feeding it an already-normalized policy yields the same object.
363
+
364
+ const SESSION_CLEANUP_MODES = new Set<RepoMeshSessionCleanupMode>([
365
+ 'preserve', 'stop', 'delete_stopped', 'stop_and_delete',
366
+ ]);
367
+ const SPAWNED_SESSION_VISIBILITY_MODES = new Set<RepoMeshSpawnedSessionVisibility>([
368
+ 'visible', 'hidden',
369
+ ]);
370
+ const DIRTY_WORKSPACE_BEHAVIORS = new Set<RepoMeshPolicy['dirtyWorkspaceBehavior']>([
371
+ 'block', 'warn', 'checkpoint_then_continue',
372
+ ]);
373
+
374
+ /** Min/max bounds for the global write-task parallel cap. */
375
+ export const MESH_MAX_PARALLEL_TASKS_MIN = 1;
376
+ export const MESH_MAX_PARALLEL_TASKS_MAX = 8;
377
+
378
+ /**
379
+ * Resolve the effective global write-task parallel cap from a raw policy value,
380
+ * clamped to [MESH_MAX_PARALLEL_TASKS_MIN, MESH_MAX_PARALLEL_TASKS_MAX] and
381
+ * defaulting to DEFAULT_MESH_POLICY.maxParallelTasks for a missing/NaN value.
382
+ * Both the config write path and the runtime scheduler read the cap through this
383
+ * helper so they can never disagree on what "max parallel" means.
384
+ */
385
+ export function resolveMaxParallelTasks(value: unknown): number {
386
+ const n = Number(value);
387
+ if (!Number.isFinite(n)) return DEFAULT_MESH_POLICY.maxParallelTasks;
388
+ return Math.max(MESH_MAX_PARALLEL_TASKS_MIN, Math.min(MESH_MAX_PARALLEL_TASKS_MAX, Math.floor(n)));
389
+ }
390
+
391
+ /**
392
+ * Normalize an autoFastForward sub-policy, filling defaults and dropping an
393
+ * invalid maxBehind. Mirrors the (previously mesh-config-local) shape so the merge
394
+ * always emits a fully-populated, valid autoFastForward object.
395
+ */
396
+ export function normalizeAutoFastForwardPolicy(value: unknown): NonNullable<RepoMeshPolicy['autoFastForward']> {
397
+ const record = value && typeof value === 'object' && !Array.isArray(value)
398
+ ? value as Record<string, unknown>
399
+ : {};
400
+ const maxBehind = Number(record.maxBehind);
401
+ return {
402
+ enabled: record.enabled !== false,
403
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
404
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
405
+ };
406
+ }
407
+
408
+ /**
409
+ * Canonical merge+normalize for a RepoMeshPolicy. Layers (lowest→highest):
410
+ * DEFAULT_MESH_POLICY → base (existing persisted policy) → patch (incoming change),
411
+ * then applies every per-field normalizer so the result is always valid regardless
412
+ * of what a hand-edited meshes.json or a partial patch contained.
413
+ *
414
+ * Persistence economy is preserved: schedulingStrategy is dropped when it
415
+ * normalizes to the 'first_eligible' default, and autoConvergeCodeChange is dropped
416
+ * unless explicitly true — so an untouched meshes.json stays byte-for-byte the same.
417
+ */
418
+ export function mergeAndNormalizePolicy(
419
+ base: RepoMeshPolicy | undefined,
420
+ patch: Partial<RepoMeshPolicy> | undefined,
421
+ ): RepoMeshPolicy {
422
+ const autoFastForward = normalizeAutoFastForwardPolicy({
423
+ ...DEFAULT_MESH_POLICY.autoFastForward,
424
+ ...((base?.autoFastForward && typeof base.autoFastForward === 'object') ? base.autoFastForward : {}),
425
+ ...((patch?.autoFastForward && typeof patch.autoFastForward === 'object') ? patch.autoFastForward : {}),
426
+ });
427
+ const policy: RepoMeshPolicy = {
428
+ ...DEFAULT_MESH_POLICY,
429
+ ...(base || {}),
430
+ ...(patch || {}),
431
+ autoFastForward,
432
+ };
433
+ if (!DIRTY_WORKSPACE_BEHAVIORS.has(policy.dirtyWorkspaceBehavior)) {
434
+ policy.dirtyWorkspaceBehavior = 'warn';
435
+ }
436
+ policy.maxParallelTasks = resolveMaxParallelTasks(policy.maxParallelTasks);
437
+ policy.allowAutoPublishSubmoduleMainCommits = policy.allowAutoPublishSubmoduleMainCommits === true;
438
+ if (!SESSION_CLEANUP_MODES.has(policy.sessionCleanupOnNodeRemove as RepoMeshSessionCleanupMode)) {
439
+ policy.sessionCleanupOnNodeRemove = 'preserve';
440
+ }
441
+ if (!SPAWNED_SESSION_VISIBILITY_MODES.has(policy.spawnedSessionVisibility as RepoMeshSpawnedSessionVisibility)) {
442
+ policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
443
+ }
444
+ // Load-balancing: normalize the scheduling strategy so an invalid/blank value
445
+ // falls back to 'first_eligible' (strict no-change). Only persist the field when
446
+ // it is explicitly a non-default value to keep existing meshes.json untouched.
447
+ const normalizedStrategy = normalizeMeshSchedulingStrategy(policy.schedulingStrategy);
448
+ if (normalizedStrategy === 'first_eligible') {
449
+ delete policy.schedulingStrategy;
450
+ } else {
451
+ policy.schedulingStrategy = normalizedStrategy;
452
+ }
453
+ // Convergence routing: strict opt-in (default false). Only persist when explicitly
454
+ // enabled so existing meshes.json stays byte-for-byte untouched.
455
+ if (policy.autoConvergeCodeChange === true) {
456
+ policy.autoConvergeCodeChange = true;
457
+ } else {
458
+ delete policy.autoConvergeCodeChange;
459
+ }
460
+ return policy;
461
+ }
462
+
355
463
  /**
356
464
  * Resolve whether a delegated worker session launched onto `nodePolicy` (within a mesh
357
465
  * governed by `meshPolicy`) should auto-approve. Precedence: node override → mesh policy
@@ -585,6 +693,44 @@ export interface LocalMeshNodeEntry {
585
693
 
586
694
  // ─── Mesh Status (runtime, not persisted) ───────
587
695
 
696
+ /**
697
+ * Per-(node, provider) cap + consumption, as surfaced on a node's scheduling
698
+ * status. Wire-shape mirror of MeshNodeProviderSchedulingRuntime.
699
+ */
700
+ export interface RepoMeshNodeProviderSchedulingStatus {
701
+ providerType: string;
702
+ maxParallel?: number;
703
+ activeAssigned: number;
704
+ capReached: boolean;
705
+ }
706
+
707
+ /**
708
+ * Per-node scheduling runtime exposed on RepoMeshNodeStatus.scheduling. Carried in
709
+ * full by verbose mesh_status; compact mesh_status sends only {load, capReached}.
710
+ */
711
+ export interface RepoMeshNodeSchedulingStatus {
712
+ load: number;
713
+ schedulingPriority?: number;
714
+ maxConcurrentSessions?: number;
715
+ providerRoles?: RepoMeshNodeProviderSchedulingStatus[];
716
+ capReached: boolean;
717
+ capReasons?: string[];
718
+ }
719
+
720
+ /**
721
+ * Mesh-level scheduling rollup exposed on RepoMeshStatus.scheduling: which tie-break
722
+ * strategy is live and how much of the global parallel caps is consumed.
723
+ */
724
+ export interface RepoMeshSchedulingStatus {
725
+ strategy: RepoMeshSchedulingStrategy;
726
+ maxParallelTasks: number;
727
+ maxReadonlyParallelTasks: number;
728
+ activeWriteAssigned: number;
729
+ activeReadonlyAssigned: number;
730
+ globalWriteCapReached: boolean;
731
+ globalReadonlyCapReached: boolean;
732
+ }
733
+
588
734
  export interface RepoMeshStatus {
589
735
  meshId: string;
590
736
  meshName: string;
@@ -595,6 +741,11 @@ export interface RepoMeshStatus {
595
741
  nodes: RepoMeshNodeStatus[];
596
742
  queue?: RepoMeshQueueStatus;
597
743
  ledger?: RepoMeshLedgerStatus;
744
+ /**
745
+ * Mesh-level scheduling rollup (strategy + global cap consumption). Omitted by
746
+ * daemons predating the scheduling-runtime exposure — treat as optional.
747
+ */
748
+ scheduling?: RepoMeshSchedulingStatus;
598
749
  /**
599
750
  * Mission summaries for the dashboard overview. Active/paused missions plus a
600
751
  * capped, newest-first slice of completed/abandoned history. Omitted by older
@@ -674,6 +825,19 @@ export interface RepoMeshNodeStatus {
674
825
  lastSeenAt?: string;
675
826
  updatedAt?: string;
676
827
  connection?: RepoMeshPeerConnectionStatus;
828
+ /**
829
+ * Per-node scheduling runtime (load / priority / provider caps / claim-block
830
+ * reasons). Verbose mesh_status carries the full shape; compact carries only
831
+ * {load, capReached}. Omitted by daemons predating the exposure.
832
+ */
833
+ scheduling?: RepoMeshNodeSchedulingStatus;
834
+ /**
835
+ * Stale-daemon-build marker: the live daemon's build commit is a strict ancestor
836
+ * of this node's workspace HEAD (merged code not yet live). Best-effort, set by
837
+ * mesh_status when the git probe reports daemonBuildBehind; shape is daemon-defined
838
+ * (scope/isDaemonAffecting flags). Omitted when the build is current.
839
+ */
840
+ staleDaemonBuild?: Record<string, unknown>;
677
841
  error?: string;
678
842
  }
679
843