@miller-tech/uap 2.21.2 → 2.36.1
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 +22 -10
- package/config/model-profiles/qwen38-coder390.json +46 -0
- package/config/model-profiles/qwen38.json +3 -2
- package/config/model-registry.json +110 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/paired/types.d.ts +2 -2
- package/dist/bin/cli.js +315 -20
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/attention.d.ts +6 -0
- package/dist/cli/attention.d.ts.map +1 -0
- package/dist/cli/attention.js +115 -0
- package/dist/cli/attention.js.map +1 -0
- package/dist/cli/bench-report.d.ts +82 -0
- package/dist/cli/bench-report.d.ts.map +1 -0
- package/dist/cli/bench-report.js +237 -0
- package/dist/cli/bench-report.js.map +1 -0
- package/dist/cli/clients.d.ts +6 -0
- package/dist/cli/clients.d.ts.map +1 -0
- package/dist/cli/clients.js +142 -0
- package/dist/cli/clients.js.map +1 -0
- package/dist/cli/dashboard-grants.d.ts +26 -0
- package/dist/cli/dashboard-grants.d.ts.map +1 -0
- package/dist/cli/dashboard-grants.js +69 -0
- package/dist/cli/dashboard-grants.js.map +1 -0
- package/dist/cli/dashboard-pace.d.ts +31 -0
- package/dist/cli/dashboard-pace.d.ts.map +1 -0
- package/dist/cli/dashboard-pace.js +42 -0
- package/dist/cli/dashboard-pace.js.map +1 -0
- package/dist/cli/dashboard-session.d.ts +25 -0
- package/dist/cli/dashboard-session.d.ts.map +1 -0
- package/dist/cli/dashboard-session.js +814 -0
- package/dist/cli/dashboard-session.js.map +1 -0
- package/dist/cli/dashboard-types.d.ts +18 -0
- package/dist/cli/dashboard-types.d.ts.map +1 -0
- package/dist/cli/dashboard-types.js +9 -0
- package/dist/cli/dashboard-types.js.map +1 -0
- package/dist/cli/dashboard.d.ts +2 -18
- package/dist/cli/dashboard.d.ts.map +1 -1
- package/dist/cli/dashboard.js +43 -793
- package/dist/cli/dashboard.js.map +1 -1
- package/dist/cli/deliver.d.ts +35 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +93 -9
- package/dist/cli/deliver.js.map +1 -1
- package/dist/cli/diff.d.ts +85 -0
- package/dist/cli/diff.d.ts.map +1 -0
- package/dist/cli/diff.js +293 -0
- package/dist/cli/diff.js.map +1 -0
- package/dist/cli/eta.d.ts +35 -0
- package/dist/cli/eta.d.ts.map +1 -0
- package/dist/cli/eta.js +101 -0
- package/dist/cli/eta.js.map +1 -0
- package/dist/cli/handsfree.d.ts +7 -0
- package/dist/cli/handsfree.d.ts.map +1 -1
- package/dist/cli/handsfree.js +27 -0
- package/dist/cli/handsfree.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +126 -5
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/loops.d.ts +24 -5
- package/dist/cli/loops.d.ts.map +1 -1
- package/dist/cli/loops.js +92 -12
- package/dist/cli/loops.js.map +1 -1
- package/dist/cli/models.d.ts +62 -0
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +567 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/plan.d.ts +9 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +57 -4
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/proxy-relay.d.ts +55 -0
- package/dist/cli/proxy-relay.d.ts.map +1 -0
- package/dist/cli/proxy-relay.js +116 -0
- package/dist/cli/proxy-relay.js.map +1 -0
- package/dist/cli/proxy.d.ts +4 -0
- package/dist/cli/proxy.d.ts.map +1 -1
- package/dist/cli/proxy.js +26 -1
- package/dist/cli/proxy.js.map +1 -1
- package/dist/cli/sandbox.d.ts +101 -4
- package/dist/cli/sandbox.d.ts.map +1 -1
- package/dist/cli/sandbox.js +393 -44
- package/dist/cli/sandbox.js.map +1 -1
- package/dist/cli/self.d.ts +13 -0
- package/dist/cli/self.d.ts.map +1 -0
- package/dist/cli/self.js +94 -0
- package/dist/cli/self.js.map +1 -0
- package/dist/cli/systemd-services.d.ts +2 -8
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +5 -20
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/cli/task.js +1 -1
- package/dist/cli/task.js.map +1 -1
- package/dist/cli/wizard-config.d.ts.map +1 -1
- package/dist/cli/wizard-config.js +31 -2
- package/dist/cli/wizard-config.js.map +1 -1
- package/dist/config/settings-registry.d.ts +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +108 -0
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/coordination/capability-router.js +2 -2
- package/dist/coordination/capability-router.js.map +1 -1
- package/dist/coordination/database.d.ts +13 -0
- package/dist/coordination/database.d.ts.map +1 -1
- package/dist/coordination/database.js +63 -7
- package/dist/coordination/database.js.map +1 -1
- package/dist/coordination/eta-inject.d.ts +32 -0
- package/dist/coordination/eta-inject.d.ts.map +1 -0
- package/dist/coordination/eta-inject.js +64 -0
- package/dist/coordination/eta-inject.js.map +1 -0
- package/dist/coordination/reactor.d.ts.map +1 -1
- package/dist/coordination/reactor.js +14 -0
- package/dist/coordination/reactor.js.map +1 -1
- package/dist/coordination/service.d.ts +18 -8
- package/dist/coordination/service.d.ts.map +1 -1
- package/dist/coordination/service.js +38 -28
- package/dist/coordination/service.js.map +1 -1
- package/dist/dashboard/client-registry.d.ts +163 -0
- package/dist/dashboard/client-registry.d.ts.map +1 -0
- package/dist/dashboard/client-registry.js +549 -0
- package/dist/dashboard/client-registry.js.map +1 -0
- package/dist/dashboard/controls.d.ts.map +1 -1
- package/dist/dashboard/controls.js +33 -4
- package/dist/dashboard/controls.js.map +1 -1
- package/dist/dashboard/data-seeder.d.ts +10 -7
- package/dist/dashboard/data-seeder.d.ts.map +1 -1
- package/dist/dashboard/data-seeder.js +31 -75
- package/dist/dashboard/data-seeder.js.map +1 -1
- package/dist/dashboard/data-service.d.ts +9 -0
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +32 -3
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/dashboard/placement-routes.d.ts +119 -0
- package/dist/dashboard/placement-routes.d.ts.map +1 -0
- package/dist/dashboard/placement-routes.js +331 -0
- package/dist/dashboard/placement-routes.js.map +1 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +408 -2
- package/dist/dashboard/server.js.map +1 -1
- package/dist/delivery/attention.d.ts +130 -0
- package/dist/delivery/attention.d.ts.map +1 -0
- package/dist/delivery/attention.js +341 -0
- package/dist/delivery/attention.js.map +1 -0
- package/dist/delivery/decompose.d.ts +18 -0
- package/dist/delivery/decompose.d.ts.map +1 -1
- package/dist/delivery/decompose.js +22 -1
- package/dist/delivery/decompose.js.map +1 -1
- package/dist/delivery/eta-estimate.d.ts +111 -0
- package/dist/delivery/eta-estimate.d.ts.map +1 -0
- package/dist/delivery/eta-estimate.js +171 -0
- package/dist/delivery/eta-estimate.js.map +1 -0
- package/dist/delivery/eta.d.ts +23 -0
- package/dist/delivery/eta.d.ts.map +1 -0
- package/dist/delivery/eta.js +59 -0
- package/dist/delivery/eta.js.map +1 -0
- package/dist/delivery/orchestrated-mission.d.ts +10 -0
- package/dist/delivery/orchestrated-mission.d.ts.map +1 -1
- package/dist/delivery/orchestrated-mission.js +15 -1
- package/dist/delivery/orchestrated-mission.js.map +1 -1
- package/dist/delivery/plan-check.d.ts +7 -1
- package/dist/delivery/plan-check.d.ts.map +1 -1
- package/dist/delivery/plan-check.js +23 -2
- package/dist/delivery/plan-check.js.map +1 -1
- package/dist/delivery/run-state.d.ts +1 -1
- package/dist/delivery/run-state.d.ts.map +1 -1
- package/dist/delivery/run-state.js +8 -1
- package/dist/delivery/run-state.js.map +1 -1
- package/dist/delivery/task-orchestrator.d.ts +8 -3
- package/dist/delivery/task-orchestrator.d.ts.map +1 -1
- package/dist/delivery/task-orchestrator.js +75 -43
- package/dist/delivery/task-orchestrator.js.map +1 -1
- package/dist/delivery/task-sync.d.ts +21 -0
- package/dist/delivery/task-sync.d.ts.map +1 -1
- package/dist/delivery/task-sync.js +44 -8
- package/dist/delivery/task-sync.js.map +1 -1
- package/dist/delivery/task-workspace.d.ts +3 -3
- package/dist/delivery/task-workspace.js +3 -3
- package/dist/generators/self-card.d.ts +65 -0
- package/dist/generators/self-card.d.ts.map +1 -0
- package/dist/generators/self-card.js +256 -0
- package/dist/generators/self-card.js.map +1 -0
- package/dist/inference/probe.js +1 -1
- package/dist/inference/probe.js.map +1 -1
- package/dist/memory/prepopulate.d.ts.map +1 -1
- package/dist/memory/prepopulate.js +2 -0
- package/dist/memory/prepopulate.js.map +1 -1
- package/dist/models/analytics.d.ts +32 -0
- package/dist/models/analytics.d.ts.map +1 -1
- package/dist/models/analytics.js +68 -5
- package/dist/models/analytics.js.map +1 -1
- package/dist/models/plan-readability.d.ts +55 -0
- package/dist/models/plan-readability.d.ts.map +1 -0
- package/dist/models/plan-readability.js +339 -0
- package/dist/models/plan-readability.js.map +1 -0
- package/dist/placement/admission.d.ts +59 -0
- package/dist/placement/admission.d.ts.map +1 -0
- package/dist/placement/admission.js +197 -0
- package/dist/placement/admission.js.map +1 -0
- package/dist/placement/auto.d.ts +44 -0
- package/dist/placement/auto.d.ts.map +1 -0
- package/dist/placement/auto.js +212 -0
- package/dist/placement/auto.js.map +1 -0
- package/dist/placement/enforce.d.ts +128 -0
- package/dist/placement/enforce.d.ts.map +1 -0
- package/dist/placement/enforce.js +655 -0
- package/dist/placement/enforce.js.map +1 -0
- package/dist/placement/idle.d.ts +50 -0
- package/dist/placement/idle.d.ts.map +1 -0
- package/dist/placement/idle.js +213 -0
- package/dist/placement/idle.js.map +1 -0
- package/dist/placement/ledger.d.ts +323 -0
- package/dist/placement/ledger.d.ts.map +1 -0
- package/dist/placement/ledger.js +263 -0
- package/dist/placement/ledger.js.map +1 -0
- package/dist/placement/preview.d.ts +51 -0
- package/dist/placement/preview.d.ts.map +1 -0
- package/dist/placement/preview.js +61 -0
- package/dist/placement/preview.js.map +1 -0
- package/dist/placement/probes.d.ts +47 -0
- package/dist/placement/probes.d.ts.map +1 -0
- package/dist/placement/probes.js +207 -0
- package/dist/placement/probes.js.map +1 -0
- package/dist/placement/proxy-env.d.ts +21 -0
- package/dist/placement/proxy-env.d.ts.map +1 -0
- package/dist/placement/proxy-env.js +42 -0
- package/dist/placement/proxy-env.js.map +1 -0
- package/dist/placement/registry.d.ts +1038 -0
- package/dist/placement/registry.d.ts.map +1 -0
- package/dist/placement/registry.js +274 -0
- package/dist/placement/registry.js.map +1 -0
- package/dist/policies/liveness.d.ts +6 -8
- package/dist/policies/liveness.d.ts.map +1 -1
- package/dist/policies/liveness.js +6 -25
- package/dist/policies/liveness.js.map +1 -1
- package/dist/policies/sandbox-anchor.d.ts +91 -0
- package/dist/policies/sandbox-anchor.d.ts.map +1 -0
- package/dist/policies/sandbox-anchor.js +187 -0
- package/dist/policies/sandbox-anchor.js.map +1 -0
- package/dist/policies/sandbox-grants.d.ts +131 -0
- package/dist/policies/sandbox-grants.d.ts.map +1 -0
- package/dist/policies/sandbox-grants.js +562 -0
- package/dist/policies/sandbox-grants.js.map +1 -0
- package/dist/state/manifest.d.ts +4 -2
- package/dist/state/manifest.d.ts.map +1 -1
- package/dist/state/manifest.js +32 -16
- package/dist/state/manifest.js.map +1 -1
- package/dist/utils/checkout-root.d.ts +2 -0
- package/dist/utils/checkout-root.d.ts.map +1 -0
- package/dist/utils/checkout-root.js +43 -0
- package/dist/utils/checkout-root.js.map +1 -0
- package/dist/utils/resolve-uap-bin.d.ts +5 -0
- package/dist/utils/resolve-uap-bin.d.ts.map +1 -0
- package/dist/utils/resolve-uap-bin.js +33 -0
- package/dist/utils/resolve-uap-bin.js.map +1 -0
- package/dist/utils/terminal-safe.d.ts +12 -0
- package/dist/utils/terminal-safe.d.ts.map +1 -0
- package/dist/utils/terminal-safe.js +53 -0
- package/dist/utils/terminal-safe.js.map +1 -0
- package/docs/ATTRIBUTION.md +1 -0
- package/docs/DASHBOARD_UPLIFT_SPEC.md +8 -8
- package/docs/INDEX.md +6 -5
- package/docs/architecture/OVERVIEW.md +15 -3
- package/docs/architecture/PROTOCOL.md +7 -1
- package/docs/architecture/adr/0006-operator-directed-model-placement.md +86 -0
- package/docs/architecture/adr/0007-auto-displacement-allowlist-as-standing-consent.md +79 -0
- package/docs/architecture/adr/0008-fleet-clients-link-out.md +92 -0
- package/docs/architecture/adr/0009-completion-triggered-dispatch-and-per-leaf-model-dials.md +71 -0
- package/docs/architecture/adr/0010-attention-queue-and-afk-auto-decider.md +159 -0
- package/docs/architecture/adr/0011-sandbox-grants-and-network-egress-boundary.md +219 -0
- package/docs/architecture/adr/0012-operator-legibility-surfaces.md +175 -0
- package/docs/getting-started/INSTALLATION.md +1 -1
- package/docs/guides/AUTOMATIC_FEATURES.md +8 -0
- package/docs/guides/LOCAL_MODELS.md +1 -1
- package/docs/guides/PROXY.md +2 -1
- package/docs/guides/SANDBOX.md +91 -4
- package/docs/integrations/MCP_ROUTER.md +1 -1
- package/docs/plans/model-performance-proof-options.md +176 -0
- package/docs/reference/API.md +1 -1
- package/docs/reference/CLI.md +169 -4
- package/docs/reference/COMPARISON.md +1 -1
- package/docs/reference/CONFIGURATION.md +12 -3
- package/docs/reference/CONFIGURATION_REFERENCE.md +269 -14
- package/docs/reference/DATABASE_SCHEMA.md +1 -1
- package/docs/reference/FEATURES.md +15 -3
- package/docs/reference/PATTERNS.md +1 -1
- package/docs/reference/PLATFORMS.md +14 -1
- package/docs/specs/operator-model-placement.md +1314 -0
- package/docs/specs/operator-model-placement.review.md +339 -0
- package/package.json +2 -2
- package/scripts/maintenance/publish-npm.sh +23 -11
- package/scripts/version-bump.sh +6 -51
- package/src/policies/enforcers/__pycache__/_common.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-311.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-311.pyc +0 -0
- package/src/policies/schemas/policies/mandatory-testing-deployment.md +2 -5
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-311.pyc +0 -0
- package/templates/hooks/session-start.sh +44 -0
- package/tools/agents/UAP/cli.py +14 -3
- package/tools/agents/UAP/paths.py +50 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-311.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1386 -99
- package/tools/agents/scripts/toolcall_path_normalizer.py +46 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +29 -6
- package/tools/agents/tests/test_doubling_break.py +64 -5
- package/tools/agents/tests/test_error_loop_break.py +10 -4
- package/tools/agents/tests/test_error_loop_ignores_correctives.py +13 -2
- package/tools/agents/tests/test_lockstep_escalation.py +280 -9
- package/tools/agents/tests/test_path_containment.py +94 -0
- package/tools/agents/tests/test_proxy_unix_socket.py +162 -0
- package/tools/agents/tests/test_signature_quality.py +349 -0
- package/tools/agents/tests/test_uap_compliance.py +11 -1
- package/web/dash/core.js +1 -0
- package/web/dash/tab-clients.js +196 -0
- package/web/dash/tab-models.js +387 -8
- package/web/dash/tab-policies.js +87 -4
- package/web/dash/tabs.js +77 -73
- package/web/dashboard.html +6 -0
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/web/dash/tab-agents.js +0 -21
- package/web/dash/tab-deliver.js +0 -12
- package/web/dash/tab-memory.js +0 -12
- package/web/dash/tab-orchestration.js +0 -21
- package/web/dash/tab-overview.js +0 -21
- package/web/dash/tab-tasks.js +0 -21
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placement enforcement (spec §4.6) — the operator-approved swap sequence.
|
|
3
|
+
*
|
|
4
|
+
* Runs ONLY after an explicit operator yes (`uap models apply <id>
|
|
5
|
+
* <option> --yes`, or the dashboard's resolve route). The sequence:
|
|
6
|
+
* re-derive and refuse on drift → mark draining → drain → stop → verify the
|
|
7
|
+
* VRAM actually came back → start → verify up and advertising → invalidate
|
|
8
|
+
* the proxy's cached view → resolve the pending entry. Any failure after a
|
|
9
|
+
* stop rolls back: the previous units are restarted rather than leaving the
|
|
10
|
+
* machine with nothing loaded (step 8 — the step that decides whether this
|
|
11
|
+
* feature is trustworthy).
|
|
12
|
+
*
|
|
13
|
+
* Every external effect is injectable (EnforceDeps) so the sequence is
|
|
14
|
+
* testable against simulated failures, which the spec demands: "The
|
|
15
|
+
* rollback must be exercised in tests against a simulated failed start, not
|
|
16
|
+
* only in the success path."
|
|
17
|
+
*
|
|
18
|
+
* The drain/start/free timeouts ship as conservative defaults labelled
|
|
19
|
+
* UNVALIDATED (spec §10.4): the enforcement time envelope is unmeasured
|
|
20
|
+
* until the operator allows briefly taking the live backend down.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from 'child_process';
|
|
23
|
+
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
24
|
+
import { homedir } from 'os';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { computeOptions } from './admission.js';
|
|
27
|
+
import { loadLedger, residentTargetId, syncLedger, withLedger, placementLedgerPath, } from './ledger.js';
|
|
28
|
+
import { gpuStatsByIndex, isUnitActive } from './probes.js';
|
|
29
|
+
import { loadModelRegistry, validUnitName } from './registry.js';
|
|
30
|
+
import { previewStillValid } from './preview.js';
|
|
31
|
+
import { proxyBaseAndHeaders } from './proxy-env.js';
|
|
32
|
+
export function enforcementTimeouts() {
|
|
33
|
+
return {
|
|
34
|
+
drainMs: numEnv('UAP_PLACEMENT_DRAIN_MS', 60_000),
|
|
35
|
+
startMs: numEnv('UAP_PLACEMENT_START_MS', 120_000),
|
|
36
|
+
freeMs: numEnv('UAP_PLACEMENT_FREE_MS', 30_000),
|
|
37
|
+
pollMs: numEnv('UAP_PLACEMENT_POLL_MS', 1_000),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function numEnv(key, dflt) {
|
|
41
|
+
const v = Number(process.env[key]);
|
|
42
|
+
return Number.isFinite(v) && v > 0 ? v : dflt;
|
|
43
|
+
}
|
|
44
|
+
export function defaultEnforceDeps(opts) {
|
|
45
|
+
const { base: proxyBase, headers: authorizedHeaders } = proxyBaseAndHeaders(opts ?? {});
|
|
46
|
+
const systemd = (unit, verb) => new Promise((resolve, reject) => {
|
|
47
|
+
if (!validUnitName(unit)) {
|
|
48
|
+
reject(new Error(`refusing systemctl --user ${verb}: invalid unit name ${JSON.stringify(unit)}`));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
// 30s cap: the dashboard event loop serves the loopback admit the
|
|
53
|
+
// proxy polls, so a hung systemctl job must not freeze it forever.
|
|
54
|
+
execFileSync('systemctl', ['--user', verb, '--', unit], {
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
timeout: 30_000,
|
|
57
|
+
});
|
|
58
|
+
resolve();
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
reject(new Error(`systemctl --user ${verb} ${unit} failed: ${err.message}`));
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
isUnitActive,
|
|
66
|
+
stopUnit: (unit) => systemd(unit, 'stop'),
|
|
67
|
+
startUnit: (unit) => systemd(unit, 'start'),
|
|
68
|
+
gpuFreeMiB: (device) => {
|
|
69
|
+
const idx = /^gpu(\d+)$/.exec(device);
|
|
70
|
+
return idx ? gpuStatsByIndex().get(Number(idx[1]))?.free_mib ?? null : null;
|
|
71
|
+
},
|
|
72
|
+
inflightForTarget: async (target) => {
|
|
73
|
+
try {
|
|
74
|
+
const resp = await fetch(`${proxyBase}/internal/placement/inflight`, {
|
|
75
|
+
headers: authorizedHeaders,
|
|
76
|
+
signal: AbortSignal.timeout(2000),
|
|
77
|
+
});
|
|
78
|
+
if (!resp.ok)
|
|
79
|
+
return null; // unknown, not "idle": a 401 is a mismatched token
|
|
80
|
+
const data = (await resp.json());
|
|
81
|
+
// A 200 is authoritative: a missing key = no request ever hit that
|
|
82
|
+
// target through this proxy = idle.
|
|
83
|
+
return data.targets?.[target] ?? [];
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null; // unknown: proxy down/restarted mid-drain — never guessed
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
httpGetJson: async (url) => {
|
|
90
|
+
try {
|
|
91
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
92
|
+
if (!resp.ok)
|
|
93
|
+
return null;
|
|
94
|
+
return await resp.json();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
refreshProxy: async () => {
|
|
101
|
+
try {
|
|
102
|
+
const resp = await fetch(`${proxyBase}/internal/placement/refresh`, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: authorizedHeaders,
|
|
105
|
+
signal: AbortSignal.timeout(2000),
|
|
106
|
+
});
|
|
107
|
+
return resp.ok;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
114
|
+
nowMs: () => Date.now(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function loadRegistry(projectDir, opts) {
|
|
118
|
+
return loadModelRegistry(projectDir, { repoPath: opts.repoPath, localPath: opts.localPath }).registry;
|
|
119
|
+
}
|
|
120
|
+
function step(steps, name, ok, detail) {
|
|
121
|
+
steps.push(ok ? { name, ok: true, ...(detail ? { detail } : {}) } : { name, ok: false, detail });
|
|
122
|
+
}
|
|
123
|
+
const sameResident = (a, b) => a.model === b.model && a.config === b.config;
|
|
124
|
+
/** The §4.6 sequence for ONE approved option. Re-derives the option from live
|
|
125
|
+
* state and refuses on drift (a preview that lies is worse than none). */
|
|
126
|
+
export async function enforceOption(registry, requestedModel, approved, opts = {}) {
|
|
127
|
+
const ledgerPath = opts.ledgerPath ?? placementLedgerPath();
|
|
128
|
+
const deps = opts.deps ?? defaultEnforceDeps();
|
|
129
|
+
const timeouts = opts.timeouts ?? enforcementTimeouts();
|
|
130
|
+
const steps = [];
|
|
131
|
+
const ledger = syncLedger(registry, ledgerPath, deps.isUnitActive, deps.probeDevices ? { deviceProbe: deps.probeDevices } : undefined);
|
|
132
|
+
const victims = approved.victims ?? [];
|
|
133
|
+
const fail = (error, rolledBack) => ({ ok: false, steps, error, ...(rolledBack ? { rolledBack: true } : {}) });
|
|
134
|
+
// Step 0 — re-derive the approved option from live state; abort on drift.
|
|
135
|
+
// Per-request cost (spec §4.4): re-derive against the SAME context need
|
|
136
|
+
// the option was computed for (opts.cells), or a small-pool config whose
|
|
137
|
+
// option existed for a 2k-cell request "drifts" under the 32k default.
|
|
138
|
+
const rederived = computeOptions(registry, ledger, requestedModel, { cells: opts.cells }).options.find((o) => o.kind === approved.kind && o.model === approved.model && o.config === approved.config);
|
|
139
|
+
if (!rederived) {
|
|
140
|
+
step(steps, 'revalidate', false, 'the approved option no longer exists in live state');
|
|
141
|
+
return fail('approved option drifted: re-run `uap models pending` and approve again');
|
|
142
|
+
}
|
|
143
|
+
if (approved.kind === 'displace' && !previewStillValid(approved.victims ?? [], rederived.victims ?? [])) {
|
|
144
|
+
step(steps, 'revalidate', false, 'victim set differs from what was approved');
|
|
145
|
+
return fail('victim set drifted since approval: aborting rather than acting on a stale preview');
|
|
146
|
+
}
|
|
147
|
+
// A victim already marked draining means another enforcement is in
|
|
148
|
+
// progress on it — refuse rather than silently skipping a VRAM holder.
|
|
149
|
+
const alreadyDraining = victims.filter((v) => v.state === 'draining');
|
|
150
|
+
if (alreadyDraining.length > 0) {
|
|
151
|
+
step(steps, 'revalidate', false, 'a victim is already draining (concurrent enforcement?)');
|
|
152
|
+
return fail(`victim ${alreadyDraining.map((v) => `${v.model}/${v.config}`).join(',')} is already draining — refusing to run a concurrent enforcement`);
|
|
153
|
+
}
|
|
154
|
+
step(steps, 'revalidate', true, approved.kind);
|
|
155
|
+
// Reuse: already resident — nothing to load, nothing to displace.
|
|
156
|
+
if (approved.kind === 'reuse') {
|
|
157
|
+
step(steps, 'resolve', true, 'resident already serves the request');
|
|
158
|
+
return { ok: true, steps };
|
|
159
|
+
}
|
|
160
|
+
// Steps 1-2 — mark draining, then wait for live generations to finish.
|
|
161
|
+
const liveVictims = ledger.residents.filter((r) => victims.some((v) => sameResident(v, r)));
|
|
162
|
+
if (liveVictims.length !== victims.length) {
|
|
163
|
+
step(steps, 'mark-draining', false, 'a victim is no longer resident');
|
|
164
|
+
return fail('victim set drifted: a victim stopped being resident before enforcement');
|
|
165
|
+
}
|
|
166
|
+
withLedger(ledgerPath, (l) => {
|
|
167
|
+
for (const r of l.residents) {
|
|
168
|
+
if (victims.some((v) => sameResident(v, r)))
|
|
169
|
+
r.state = 'draining';
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
step(steps, 'mark-draining', true, victims.map((v) => `${v.model}/${v.config}`).join(', '));
|
|
173
|
+
const drained = await drain(deps, timeouts, liveVictims);
|
|
174
|
+
step(steps, 'drain', drained.ok, drained.detail);
|
|
175
|
+
if (!drained.ok)
|
|
176
|
+
return await rollback(registry, ledgerPath, opts, steps, [], `drain failed: ${drained.detail}`, { drainedVictims: liveVictims });
|
|
177
|
+
// Step 3-4 — stop, then verify the VRAM actually came back. The free
|
|
178
|
+
// baseline is read BEFORE the first stop (a snapshot taken after the
|
|
179
|
+
// stops would diff post-stop against post-stop and never see the delta),
|
|
180
|
+
// and the in-flight view is re-checked ONE last time right before the
|
|
181
|
+
// first stop — a request admitted into the drain's poll gap must not be
|
|
182
|
+
// cut silently (spec §4.6 step 2).
|
|
183
|
+
const byDevice = new Map();
|
|
184
|
+
for (const v of liveVictims) {
|
|
185
|
+
if (v.gpu_mib)
|
|
186
|
+
byDevice.set(v.device, (byDevice.get(v.device) ?? 0) + v.gpu_mib);
|
|
187
|
+
}
|
|
188
|
+
const freeBefore = new Map();
|
|
189
|
+
for (const device of byDevice.keys())
|
|
190
|
+
freeBefore.set(device, deps.gpuFreeMiB(device));
|
|
191
|
+
const lastCheck = await allTargetsIdle(deps, liveVictims);
|
|
192
|
+
if (!lastCheck.ok) {
|
|
193
|
+
step(steps, 'stop', false, lastCheck.detail);
|
|
194
|
+
return await rollback(registry, ledgerPath, opts, steps, [], `refusing to stop: ${lastCheck.detail}`, { drainedVictims: liveVictims });
|
|
195
|
+
}
|
|
196
|
+
const stoppedUnits = [];
|
|
197
|
+
for (const v of liveVictims) {
|
|
198
|
+
if (!v.unit) {
|
|
199
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `victim ${v.model}/${v.config} has no systemd unit — cannot be displaced (never treated as zero cost)`, { drainedVictims: liveVictims });
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
await deps.stopUnit(v.unit);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
step(steps, 'stop', false, err.message);
|
|
206
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `stopping ${v.unit} failed: ${err.message}`, { drainedVictims: liveVictims });
|
|
207
|
+
}
|
|
208
|
+
stoppedUnits.push(v.unit);
|
|
209
|
+
}
|
|
210
|
+
step(steps, 'stop', true, stoppedUnits.join(', '));
|
|
211
|
+
const freeOk = await verifyFreed(deps, timeouts, byDevice, freeBefore);
|
|
212
|
+
step(steps, 'verify-free', freeOk.ok, freeOk.detail);
|
|
213
|
+
if (!freeOk.ok)
|
|
214
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `VRAM did not come back: ${freeOk.detail} (orphaned engine?)`, { drainedVictims: liveVictims });
|
|
215
|
+
// Step 5 — start the approved config's unit.
|
|
216
|
+
const model = registry.models[approved.model];
|
|
217
|
+
const cfg = model?.configs[approved.config];
|
|
218
|
+
const newUnit = cfg?.unit ?? model?.unit;
|
|
219
|
+
if (!newUnit) {
|
|
220
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `config ${approved.model}/${approved.config} has no systemd unit to start`, { drainedVictims: liveVictims });
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
await deps.startUnit(newUnit);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
step(steps, 'start', false, err.message);
|
|
227
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `starting ${newUnit} failed: ${err.message}`, { drainedVictims: liveVictims });
|
|
228
|
+
}
|
|
229
|
+
step(steps, 'start', true, newUnit);
|
|
230
|
+
// Step 6 — poll health, then the advertised ids: the load-bearing check
|
|
231
|
+
// is that the requested model is actually servable now. (Deep
|
|
232
|
+
// metricsMustMatch drift stays `uap doctor`'s check.)
|
|
233
|
+
const endpoint = (model?.endpoint ?? '').replace(/\/$/, '');
|
|
234
|
+
const upOk = await waitForUp(deps, timeouts, newUnit, endpoint, requestedModel, model?.advertises ?? []);
|
|
235
|
+
step(steps, 'verify-up', upOk.ok, upOk.detail);
|
|
236
|
+
if (!upOk.ok) {
|
|
237
|
+
// The new unit is RUNNING but wrong: rollback must stop it too, or the
|
|
238
|
+
// machine ends up with two same-device residents (VRAM oversubscribed)
|
|
239
|
+
// — a partial-enforcement variant of the step-8 outcome.
|
|
240
|
+
return await rollback(registry, ledgerPath, opts, steps, stoppedUnits, `new unit did not come up serving the request: ${upOk.detail}`, { startedUnit: newUnit, drainedVictims: liveVictims });
|
|
241
|
+
}
|
|
242
|
+
// Step 7 — invalidate the proxy's cached view, then resolve + sync.
|
|
243
|
+
const refreshed = await deps.refreshProxy();
|
|
244
|
+
step(steps, 'refresh-proxy', refreshed, refreshed ? 'proxy caches invalidated' : 'proxy unreachable (no caches to invalidate)');
|
|
245
|
+
syncLedger(registry, ledgerPath, deps.isUnitActive);
|
|
246
|
+
step(steps, 'resolve', true, 'ledger synced, pending resolved by the caller');
|
|
247
|
+
return { ok: true, steps };
|
|
248
|
+
}
|
|
249
|
+
/** One in-flight check across every victim target; used as the FINAL check
|
|
250
|
+
* immediately before the first stop. Unknown (null) reads as busy. */
|
|
251
|
+
async function allTargetsIdle(deps, victims) {
|
|
252
|
+
for (const v of victims) {
|
|
253
|
+
const inflight = await deps.inflightForTarget(residentTargetId(v));
|
|
254
|
+
if (inflight === null)
|
|
255
|
+
return { ok: false, detail: `in-flight view unavailable for ${v.model}/${v.config} (proxy unreachable) — refusing to guess` };
|
|
256
|
+
if (inflight.length > 0)
|
|
257
|
+
return { ok: false, detail: `a generation is still in flight on ${v.model}/${v.config}` };
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, detail: 'all targets idle' };
|
|
260
|
+
}
|
|
261
|
+
async function drain(deps, t, victims) {
|
|
262
|
+
const deadline = deps.nowMs() + t.drainMs;
|
|
263
|
+
const targets = victims.map((v) => residentTargetId(v));
|
|
264
|
+
for (;;) {
|
|
265
|
+
const busy = [];
|
|
266
|
+
const unknown = [];
|
|
267
|
+
for (const target of targets) {
|
|
268
|
+
const inflight = await deps.inflightForTarget(target);
|
|
269
|
+
// Unknown (null) is BUSY, not idle: a proxy restart mid-drain, or a
|
|
270
|
+
// token mismatch, must never read as "drained" (fail-closed, §4.6).
|
|
271
|
+
if (inflight === null)
|
|
272
|
+
unknown.push(target);
|
|
273
|
+
else if (inflight.length > 0)
|
|
274
|
+
busy.push(target);
|
|
275
|
+
}
|
|
276
|
+
if (busy.length === 0 && unknown.length === 0) {
|
|
277
|
+
return { ok: true, detail: 'in-flight generations finished' };
|
|
278
|
+
}
|
|
279
|
+
if (deps.nowMs() >= deadline) {
|
|
280
|
+
const why = busy.length ? `still in flight on ${busy.join(',')}` : `in-flight view unavailable for ${unknown.join(',')}`;
|
|
281
|
+
return { ok: false, detail: `${why} (UNVALIDATED envelope)` };
|
|
282
|
+
}
|
|
283
|
+
await deps.sleep(t.pollMs);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/** Verify the VRAM actually came back (spec §4.6 step 4). The baseline is
|
|
287
|
+
* snapshotted by the CALLER before the stops — the whole point is the
|
|
288
|
+
* before/after delta, and an orphaned engine outside systemd (the recorded
|
|
289
|
+
* failure mode) leaves free flat after a clean unit stop. */
|
|
290
|
+
async function verifyFreed(deps, t, byDevice, before) {
|
|
291
|
+
if (byDevice.size === 0)
|
|
292
|
+
return { ok: true, detail: 'no GPU cost to verify' };
|
|
293
|
+
const deadline = deps.nowMs() + t.freeMs;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const pending = [];
|
|
296
|
+
const unprobed = [];
|
|
297
|
+
for (const [device, freed] of byDevice) {
|
|
298
|
+
const free = deps.gpuFreeMiB(device);
|
|
299
|
+
const base = before.get(device) ?? null;
|
|
300
|
+
if (free === null || base === null) {
|
|
301
|
+
unprobed.push(device); // trust the unit stop, but SAY it
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (free < base + freed * 0.9)
|
|
305
|
+
pending.push(device);
|
|
306
|
+
}
|
|
307
|
+
// Unmeasured = refuse (spec doctrine): with no device probed at all,
|
|
308
|
+
// "free recovered" would be a vacuous pass.
|
|
309
|
+
if (unprobed.length === byDevice.size) {
|
|
310
|
+
return { ok: false, detail: `no device could be probed (${unprobed.join(',')}) — refusing to verify-free by trust alone` };
|
|
311
|
+
}
|
|
312
|
+
if (pending.length === 0) {
|
|
313
|
+
return {
|
|
314
|
+
ok: true,
|
|
315
|
+
detail: unprobed.length
|
|
316
|
+
? `device free recovered; unprobed (trusted the unit stop): ${unprobed.join(',')}`
|
|
317
|
+
: 'device free recovered',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (deps.nowMs() >= deadline) {
|
|
321
|
+
return { ok: false, detail: `free did not recover on ${pending.join(',')} (expected ~${[...byDevice.values()].join('/')} MiB back)` };
|
|
322
|
+
}
|
|
323
|
+
await deps.sleep(t.pollMs);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async function waitForUp(deps, t, unit, endpoint, requestedModel, advertises) {
|
|
327
|
+
const deadline = deps.nowMs() + t.startMs;
|
|
328
|
+
for (;;) {
|
|
329
|
+
const active = deps.isUnitActive(unit);
|
|
330
|
+
const health = endpoint ? await deps.httpGetJson(`${endpoint}/health`) : null;
|
|
331
|
+
const models = endpoint ? (await deps.httpGetJson(`${endpoint}/v1/models`)) : null;
|
|
332
|
+
const ids = (models?.data ?? []).flatMap((m) => [m.id ?? '', ...(m.aliases ?? [])]);
|
|
333
|
+
const serves = ids.length > 0 && (ids.includes(requestedModel) || advertises.some((a) => ids.includes(a)));
|
|
334
|
+
if (active && health && serves)
|
|
335
|
+
return { ok: true, detail: 'healthy and advertising the requested id' };
|
|
336
|
+
if (deps.nowMs() >= deadline) {
|
|
337
|
+
return { ok: false, detail: `active=${active} health=${health ? 'ok' : 'fail'} serves=${serves} after ${t.startMs}ms (UNVALIDATED envelope)` };
|
|
338
|
+
}
|
|
339
|
+
await deps.sleep(t.pollMs);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** Step 8 — restart the previous resident set rather than leaving the
|
|
343
|
+
* machine with nothing loaded. Best-effort per unit, loudly reported.
|
|
344
|
+
* A unit STARTED during the failed sequence is stopped first: leaving it
|
|
345
|
+
* running alongside the restarted previous set would oversubscribe the
|
|
346
|
+
* device — the partial-enforcement variant of the outcome step 8 exists
|
|
347
|
+
* to prevent. */
|
|
348
|
+
async function rollback(registry, ledgerPath, opts, steps, stoppedUnits, error, extras = {}) {
|
|
349
|
+
const { startedUnit, drainedVictims = [] } = extras;
|
|
350
|
+
const deps = opts.deps ?? defaultEnforceDeps();
|
|
351
|
+
const restarted = [];
|
|
352
|
+
const failed = [];
|
|
353
|
+
const stopFailed = [];
|
|
354
|
+
if (startedUnit) {
|
|
355
|
+
try {
|
|
356
|
+
await deps.stopUnit(startedUnit);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
stopFailed.push(startedUnit);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
for (const unit of stoppedUnits) {
|
|
363
|
+
try {
|
|
364
|
+
await deps.startUnit(unit);
|
|
365
|
+
restarted.push(unit);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
failed.push(unit);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const rollbackErrors = [...stopFailed.map((u) => `${u} did not stop`), ...failed.map((u) => `${u} did not restart`)];
|
|
372
|
+
step(steps, 'rollback', rollbackErrors.length === 0, rollbackErrors.length
|
|
373
|
+
? `failed: ${rollbackErrors.join(',')}`
|
|
374
|
+
: `${startedUnit ? `stopped ${startedUnit}, ` : ''}restarted ${restarted.join(',')}`);
|
|
375
|
+
syncLedger(registry, ledgerPath, deps.isUnitActive);
|
|
376
|
+
// The sequence is OVER: clear this sequence's draining markers (the
|
|
377
|
+
// syncLedger preserve exists for CONCURRENT reads mid-drain, not for a
|
|
378
|
+
// ledger leak after the rollback restored the previous residents — a
|
|
379
|
+
// stale marker would keep refusing admission forever).
|
|
380
|
+
if (drainedVictims.length > 0) {
|
|
381
|
+
withLedger(ledgerPath, (l) => {
|
|
382
|
+
for (const r of l.residents) {
|
|
383
|
+
if (r.state === 'draining' && drainedVictims.some((v) => v.model === r.model && v.config === r.config)) {
|
|
384
|
+
r.state = 'hot';
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
ok: false,
|
|
391
|
+
steps,
|
|
392
|
+
error: rollbackErrors.length
|
|
393
|
+
? `${error}; ROLLBACK INCOMPLETE: ${rollbackErrors.join(',')}`
|
|
394
|
+
: `${error}; rolled back to the previous residents`,
|
|
395
|
+
rolledBack: true,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** Best-effort live in-flight map from the proxy's loopback
|
|
399
|
+
* /internal/placement/inflight, for victim previews at the approval moment
|
|
400
|
+
* (spec §4.5): the operator sees "active generation aborted" versus "session
|
|
401
|
+
* idle" honestly. NULL when the proxy does not answer — unknown, never a
|
|
402
|
+
* guessed "idle". */
|
|
403
|
+
export async function fetchProxyInflight(opts = {}) {
|
|
404
|
+
const { base, headers } = proxyBaseAndHeaders(opts);
|
|
405
|
+
try {
|
|
406
|
+
const resp = await fetch(`${base}/internal/placement/inflight`, {
|
|
407
|
+
headers,
|
|
408
|
+
signal: AbortSignal.timeout(2000),
|
|
409
|
+
});
|
|
410
|
+
if (!resp.ok)
|
|
411
|
+
return null;
|
|
412
|
+
const data = (await resp.json());
|
|
413
|
+
return new Map(Object.entries(data.targets ?? {}));
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Operator entry points (CLI + dashboard call the same functions)
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
/** Resolve a parked request: find its pending entry, recompute options,
|
|
423
|
+
* pick option #optionIndex (1-based, as `uap models pending` prints them),
|
|
424
|
+
* enforce, and remove the pending entry on success.
|
|
425
|
+
*
|
|
426
|
+
* `expectedVictims` is the approval signature: the victim list the operator
|
|
427
|
+
* actually SAW (the impact list the CLI printed, or the dashboard preview).
|
|
428
|
+
* When provided, enforcement refuses if the recomputed option's victims
|
|
429
|
+
* differ — "option N" can silently mean a different swap minutes after the
|
|
430
|
+
* preview, and this feature never displaces what the operator did not see
|
|
431
|
+
* (spec §4.5: a preview that lies is worse than no preview). */
|
|
432
|
+
export async function resolvePendingPlacement(projectDir, placementId, optionIndex, opts = {}) {
|
|
433
|
+
const ledgerPath = opts.ledgerPath ?? placementLedgerPath();
|
|
434
|
+
const ledger = loadLedger(ledgerPath);
|
|
435
|
+
const pending = ledger.pending.find((p) => p.id === placementId);
|
|
436
|
+
if (!pending)
|
|
437
|
+
return { ok: false, steps: [], error: `no pending placement ${placementId}` };
|
|
438
|
+
const loaded = loadRegistry(projectDir, opts);
|
|
439
|
+
// Options are per-request (spec §4.4): recompute against the context the
|
|
440
|
+
// parked request needs (recorded by the proxy in the pending entry) — the
|
|
441
|
+
// 32k default fail-closed-refuses a small-pool config whose cost is known
|
|
442
|
+
// for THIS request, and then the operator's `apply N` could never reach
|
|
443
|
+
// the option `pending` just printed for the same entry.
|
|
444
|
+
const options = computeOptions(loaded, loadLedger(ledgerPath), pending.requested_model, {
|
|
445
|
+
cells: pending.cells,
|
|
446
|
+
}).options;
|
|
447
|
+
const approved = options[optionIndex - 1];
|
|
448
|
+
if (!approved) {
|
|
449
|
+
return { ok: false, steps: [], error: `option ${optionIndex} does not exist (1-${options.length}); re-run \`uap models pending\`` };
|
|
450
|
+
}
|
|
451
|
+
if (opts.expectedVictims) {
|
|
452
|
+
const actual = (approved.victims ?? []).map((v) => ({ model: v.model, config: v.config }));
|
|
453
|
+
const expected = opts.expectedVictims.map((v) => ({ model: v.model, config: v.config }));
|
|
454
|
+
const same = actual.length === expected.length &&
|
|
455
|
+
expected.every((e) => actual.some((a) => a.model === e.model && a.config === e.config));
|
|
456
|
+
if (!same) {
|
|
457
|
+
return {
|
|
458
|
+
ok: false,
|
|
459
|
+
steps: [],
|
|
460
|
+
error: `the option's victim set changed since the impact list you approved (${actual.map((v) => `${v.model}/${v.config}`).join(', ') || 'no victims'} now); re-run \`uap models pending\` and approve again`,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const result = await enforceOption(loaded, pending.requested_model, approved, {
|
|
465
|
+
...opts,
|
|
466
|
+
cells: pending.cells,
|
|
467
|
+
});
|
|
468
|
+
if (result.ok) {
|
|
469
|
+
withLedger(ledgerPath, (l) => {
|
|
470
|
+
l.pending = l.pending.filter((p) => p.id !== placementId);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return { ...result, placementId };
|
|
474
|
+
}
|
|
475
|
+
/** Refuse a parked request: the client gets a clean fallback on expiry. */
|
|
476
|
+
export function dismissPendingPlacement(placementId, opts = {}) {
|
|
477
|
+
const ledgerPath = opts.ledgerPath ?? placementLedgerPath();
|
|
478
|
+
// Presence, not length: a concurrent append during the dismissal must not
|
|
479
|
+
// misreport a successful removal as a failure.
|
|
480
|
+
const existed = loadLedger(ledgerPath).pending.some((p) => p.id === placementId);
|
|
481
|
+
withLedger(ledgerPath, (l) => {
|
|
482
|
+
l.pending = l.pending.filter((p) => p.id !== placementId);
|
|
483
|
+
});
|
|
484
|
+
return { dismissed: existed };
|
|
485
|
+
}
|
|
486
|
+
/** Operator-initiated load: same gate, but NEVER displacement — pick the
|
|
487
|
+
* best non-displacing option (reuse, then load_alongside) and refuse if
|
|
488
|
+
* only displacement would fit. Displacement always needs the pending path. */
|
|
489
|
+
export async function loadPlacement(projectDir, modelKey, opts = {}) {
|
|
490
|
+
const loaded = loadRegistry(projectDir, opts);
|
|
491
|
+
const ledgerPath = opts.ledgerPath ?? placementLedgerPath();
|
|
492
|
+
const options = computeOptions(loaded, loadLedger(ledgerPath), modelKey).options;
|
|
493
|
+
const approved = options.find((o) => o.kind !== 'displace');
|
|
494
|
+
if (!approved) {
|
|
495
|
+
return { ok: false, steps: [], error: 'no non-displacing option fits; displacement requires a pending request and an explicit operator approval' };
|
|
496
|
+
}
|
|
497
|
+
return enforceOption(loaded, modelKey, approved, opts);
|
|
498
|
+
}
|
|
499
|
+
/** Operator-initiated unload: every live resident of the model is drained,
|
|
500
|
+
* stopped, verified free, and the proxy view refreshed. */
|
|
501
|
+
export async function unloadPlacement(projectDir, modelKey, opts = {}) {
|
|
502
|
+
const loaded = loadRegistry(projectDir, opts);
|
|
503
|
+
const ledgerPath = opts.ledgerPath ?? placementLedgerPath();
|
|
504
|
+
const deps = opts.deps ?? defaultEnforceDeps();
|
|
505
|
+
const timeouts = opts.timeouts ?? enforcementTimeouts();
|
|
506
|
+
const steps = [];
|
|
507
|
+
const ledger = syncLedger(loaded, ledgerPath, deps.isUnitActive);
|
|
508
|
+
const residents = ledger.residents.filter((r) => r.model === modelKey);
|
|
509
|
+
if (residents.length === 0)
|
|
510
|
+
return { ok: false, steps, error: `no live residents of ${modelKey}` };
|
|
511
|
+
withLedger(ledgerPath, (l) => {
|
|
512
|
+
for (const r of l.residents)
|
|
513
|
+
if (r.model === modelKey)
|
|
514
|
+
r.state = 'draining';
|
|
515
|
+
});
|
|
516
|
+
step(steps, 'mark-draining', true, residents.map((r) => `${r.model}/${r.config}`).join(', '));
|
|
517
|
+
const drained = await drain(deps, timeouts, residents);
|
|
518
|
+
step(steps, 'drain', drained.ok, drained.detail);
|
|
519
|
+
if (!drained.ok) {
|
|
520
|
+
return await rollback(loaded, ledgerPath, opts, steps, [], `drain failed: ${drained.detail}`, { drainedVictims: residents });
|
|
521
|
+
}
|
|
522
|
+
const stopped = [];
|
|
523
|
+
const byDevice = new Map();
|
|
524
|
+
for (const r of residents) {
|
|
525
|
+
if (r.gpu_mib)
|
|
526
|
+
byDevice.set(r.device, (byDevice.get(r.device) ?? 0) + r.gpu_mib);
|
|
527
|
+
}
|
|
528
|
+
const freeBefore = new Map();
|
|
529
|
+
for (const device of byDevice.keys())
|
|
530
|
+
freeBefore.set(device, deps.gpuFreeMiB(device));
|
|
531
|
+
// Final in-flight check right before the first stop — same guard as the
|
|
532
|
+
// displacement path (a request admitted into the drain's poll gap).
|
|
533
|
+
const lastCheck = await allTargetsIdle(deps, residents);
|
|
534
|
+
if (!lastCheck.ok) {
|
|
535
|
+
step(steps, 'stop', false, lastCheck.detail);
|
|
536
|
+
return await rollback(loaded, ledgerPath, opts, steps, [], `refusing to stop: ${lastCheck.detail}`, { drainedVictims: residents });
|
|
537
|
+
}
|
|
538
|
+
// Guarded like the displacement path: a stop failure rolls the already
|
|
539
|
+
// stopped units back instead of throwing raw through the CLI/dashboard.
|
|
540
|
+
for (const r of residents) {
|
|
541
|
+
if (!r.unit)
|
|
542
|
+
return await rollback(loaded, ledgerPath, opts, steps, stopped, `resident ${r.config} has no unit`, { drainedVictims: residents });
|
|
543
|
+
try {
|
|
544
|
+
await deps.stopUnit(r.unit);
|
|
545
|
+
}
|
|
546
|
+
catch (err) {
|
|
547
|
+
step(steps, 'stop', false, err.message);
|
|
548
|
+
return await rollback(loaded, ledgerPath, opts, steps, stopped, `stopping ${r.unit} failed: ${err.message}`, { drainedVictims: residents });
|
|
549
|
+
}
|
|
550
|
+
stopped.push(r.unit);
|
|
551
|
+
}
|
|
552
|
+
step(steps, 'stop', true, stopped.join(', '));
|
|
553
|
+
const freed = await verifyFreed(deps, timeouts, byDevice, freeBefore);
|
|
554
|
+
step(steps, 'verify-free', freed.ok, freed.detail);
|
|
555
|
+
if (!freed.ok)
|
|
556
|
+
return await rollback(loaded, ledgerPath, opts, steps, stopped, freed.detail, { drainedVictims: residents });
|
|
557
|
+
const refreshed = await deps.refreshProxy();
|
|
558
|
+
step(steps, 'refresh-proxy', refreshed, refreshed ? 'proxy caches invalidated' : 'proxy unreachable');
|
|
559
|
+
syncLedger(loaded, ledgerPath, deps.isUnitActive);
|
|
560
|
+
return { ok: true, steps };
|
|
561
|
+
}
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Tracked unit edges: Conflicts= drop-ins derived from the registry
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
/** Write `Conflicts=` drop-ins for every registry-named unit so the
|
|
566
|
+
* systemd graph itself refuses two same-device residents (the machine-local
|
|
567
|
+
* graph today knows nothing about placement). Drop-ins, not unit rewrites:
|
|
568
|
+
* the operator's unit files stay theirs. Unit names are validated BEFORE
|
|
569
|
+
* any path or content is built from them — the registry comes from the
|
|
570
|
+
* repo's config/, and a name carrying `../` or a newline must never reach
|
|
571
|
+
* a file path, the drop-in content, or systemctl. Stale drop-ins (a unit
|
|
572
|
+
* whose registry conflicts disappeared, or a unit gone from the registry)
|
|
573
|
+
* are removed so orphan Conflicts= can't brick a legitimate future start.
|
|
574
|
+
* Best-effort daemon-reload. */
|
|
575
|
+
export function writePlacementUnitDropins(registry, opts = {}) {
|
|
576
|
+
const dir = opts.userUnitsDir ?? join(homedir(), '.config', 'systemd', 'user');
|
|
577
|
+
const unitsByDevice = new Map();
|
|
578
|
+
// A KNOWN systemd suffix is kept as-is; anything else gets .service.
|
|
579
|
+
// (The old `u.includes('.')` heuristic treated 'llama.server' as a
|
|
580
|
+
// suffixed name and never added the suffix.)
|
|
581
|
+
const SUFFIXED = /\.(service|socket|target|timer|path|slice|scope)$/;
|
|
582
|
+
const unitOf = (u) => {
|
|
583
|
+
if (!validUnitName(u)) {
|
|
584
|
+
throw new Error(`refusing to write drop-ins: invalid unit name ${JSON.stringify(u)} (registry unit names must match ${'/^[A-Za-z0-9_][A-Za-z0-9_.@-]*$/ with no .. or /'})`);
|
|
585
|
+
}
|
|
586
|
+
return SUFFIXED.test(u) ? u : `${u}.service`;
|
|
587
|
+
};
|
|
588
|
+
for (const model of Object.values(registry.models)) {
|
|
589
|
+
const units = new Set(model.unit ? [unitOf(model.unit)] : []);
|
|
590
|
+
for (const cfg of Object.values(model.configs))
|
|
591
|
+
if (cfg.unit)
|
|
592
|
+
units.add(unitOf(cfg.unit));
|
|
593
|
+
for (const device of model.affinity?.device ?? []) {
|
|
594
|
+
const set = unitsByDevice.get(device) ?? new Set();
|
|
595
|
+
units.forEach((u) => set.add(u));
|
|
596
|
+
unitsByDevice.set(device, set);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const conflictsOf = new Map();
|
|
600
|
+
for (const units of unitsByDevice.values()) {
|
|
601
|
+
for (const unit of units) {
|
|
602
|
+
const set = conflictsOf.get(unit) ?? new Set();
|
|
603
|
+
units.forEach((other) => { if (other !== unit)
|
|
604
|
+
set.add(other); });
|
|
605
|
+
conflictsOf.set(unit, set);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const written = [];
|
|
609
|
+
for (const [unit, conflicts] of conflictsOf) {
|
|
610
|
+
if (conflicts.size === 0)
|
|
611
|
+
continue;
|
|
612
|
+
const dropinDir = join(dir, `${unit}.d`);
|
|
613
|
+
mkdirSync(dropinDir, { recursive: true });
|
|
614
|
+
const path = join(dropinDir, '50-uap-placement.conf');
|
|
615
|
+
writeFileSync(path, [
|
|
616
|
+
'# Generated by uap models units (operator-directed model placement,',
|
|
617
|
+
'# spec §4.3): units whose residents share a device conflict, so',
|
|
618
|
+
'# systemd itself refuses two same-device residents. Safe to delete.',
|
|
619
|
+
'[Unit]',
|
|
620
|
+
`Conflicts=${[...conflicts].join(' ')}`,
|
|
621
|
+
'',
|
|
622
|
+
].join('\n'));
|
|
623
|
+
written.push(path);
|
|
624
|
+
}
|
|
625
|
+
// Reconcile: remove OUR drop-ins for units that no longer conflict —
|
|
626
|
+
// an orphan Conflicts= line fails legitimate future starts with a
|
|
627
|
+
// confusing error (start failure → rollback → placement bricked).
|
|
628
|
+
const removed = [];
|
|
629
|
+
const conflictingUnits = new Set([...conflictsOf.entries()].filter(([, c]) => c.size > 0).map(([u]) => u));
|
|
630
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
631
|
+
if (!entry.isDirectory() || !entry.name.endsWith('.d'))
|
|
632
|
+
continue;
|
|
633
|
+
const dropin = join(dir, entry.name, '50-uap-placement.conf');
|
|
634
|
+
if (!conflictingUnits.has(entry.name.replace(/\.d$/, ''))) {
|
|
635
|
+
try {
|
|
636
|
+
rmSync(dropin, { force: true });
|
|
637
|
+
removed.push(dropin);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
/* best-effort reconciliation */
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
execFileSync('systemctl', ['--user', 'daemon-reload'], {
|
|
646
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
647
|
+
timeout: 30_000,
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
/* best-effort: next systemctl call reloads anyway */
|
|
652
|
+
}
|
|
653
|
+
return { written, removed, dir };
|
|
654
|
+
}
|
|
655
|
+
//# sourceMappingURL=enforce.js.map
|